diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 20d53e81e8c..6c632f7e072 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -11,10 +11,25 @@ runs: restore-keys: | ${{ runner.os }}-bun- + - name: Get baseline download URL + id: bun-url + shell: bash + run: | + if [ "$RUNNER_ARCH" = "X64" ]; then + V=$(node -p "require('./package.json').packageManager.split('@')[1]") + case "$RUNNER_OS" in + macOS) OS=darwin ;; + Linux) OS=linux ;; + Windows) OS=windows ;; + esac + echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/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: Install dependencies run: bun install diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 20d2bc18d82..a7106667b11 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -27,7 +27,11 @@ jobs: opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + - name: Install OpenCode + run: bun i -g opencode-ai + - name: Sync beta branch env: GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} run: bun script/beta.ts diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index 5b424d0adfa..c3bcf9f686f 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -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, diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index 1aafc5d1e3b..f62afae4b9f 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -65,9 +65,9 @@ jobs: "packages/web/src/content/docs/*/*.mdx": "allow", ".opencode": "allow", ".opencode/agent": "allow", - ".opencode/agent/glossary": "allow", + ".opencode/glossary": "allow", ".opencode/agent/translator.md": "allow", - ".opencode/agent/glossary/*.md": "allow" + ".opencode/glossary/*.md": "allow" }, "edit": { "*": "deny", @@ -76,7 +76,7 @@ jobs: "glob": { "*": "deny", "packages/web/src/content/docs*": "allow", - ".opencode/agent/glossary*": "allow" + ".opencode/glossary*": "allow" }, "task": { "*": "deny", @@ -90,7 +90,7 @@ jobs: "read": { "*": "deny", ".opencode/agent/translator.md": "allow", - ".opencode/agent/glossary/*.md": "allow" + ".opencode/glossary/*.md": "allow" } } } diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 27581d06b76..1edbd5d061d 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -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); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 647b9e18869..f7b00516f8d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index 94569f47312..4c2aa960b2a 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -42,15 +42,17 @@ jobs: throw error; } - // Parse the .td file for denounced users + // Parse the .td file for vouched and denounced users + const vouched = new Set(); const denounced = new Map(); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; - if (!trimmed.startsWith('-')) continue; - const rest = trimmed.slice(1).trim(); + const isDenounced = trimmed.startsWith('-'); + const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; if (!rest) continue; + const spaceIdx = rest.indexOf(' '); const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); @@ -65,32 +67,50 @@ jobs: const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); if (!username) continue; - denounced.set(username.toLowerCase(), reason); + if (isDenounced) { + denounced.set(username.toLowerCase(), reason); + continue; + } + + vouched.add(username.toLowerCase()); } // Check if the author is denounced const reason = denounced.get(author.toLowerCase()); - if (reason === undefined) { - core.info(`User ${author} is not denounced. Allowing issue.`); + if (reason !== undefined) { + // Author is denounced — close the issue + const body = 'This issue has been automatically closed.'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: 'closed', + state_reason: 'not_planned', + }); + + core.info(`Closed issue #${issueNumber} from denounced user ${author}`); return; } - // Author is denounced — close the issue - const body = 'This issue has been automatically closed.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body, - }); + // Author is positively vouched — add label + if (!vouched.has(author.toLowerCase())) { + core.info(`User ${author} is not denounced or vouched. Allowing issue.`); + return; + } - await github.rest.issues.update({ + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, - state: 'closed', - state_reason: 'not_planned', + labels: ['Vouched'], }); - core.info(`Closed issue #${issueNumber} from denounced user ${author}`); + core.info(`Added vouched label to issue #${issueNumber} from ${author}`); diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index 470b8e0a5ad..51816dfb759 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + issues: write pull-requests: write jobs: @@ -42,15 +43,17 @@ jobs: throw error; } - // Parse the .td file for denounced users + // Parse the .td file for vouched and denounced users + const vouched = new Set(); const denounced = new Map(); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; - if (!trimmed.startsWith('-')) continue; - const rest = trimmed.slice(1).trim(); + const isDenounced = trimmed.startsWith('-'); + const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; if (!rest) continue; + const spaceIdx = rest.indexOf(' '); const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); @@ -65,29 +68,47 @@ jobs: const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); if (!username) continue; - denounced.set(username.toLowerCase(), reason); + if (isDenounced) { + denounced.set(username.toLowerCase(), reason); + continue; + } + + vouched.add(username.toLowerCase()); } // Check if the author is denounced const reason = denounced.get(author.toLowerCase()); - if (reason === undefined) { - core.info(`User ${author} is not denounced. Allowing PR.`); + if (reason !== undefined) { + // Author is denounced — close the PR + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: 'This pull request has been automatically closed.', + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed', + }); + + core.info(`Closed PR #${prNumber} from denounced user ${author}`); return; } - // Author is denounced — close the PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: 'This pull request has been automatically closed.', - }); + // Author is positively vouched — add label + if (!vouched.has(author.toLowerCase())) { + core.info(`User ${author} is not denounced or vouched. Allowing PR.`); + return; + } - await github.rest.pulls.update({ + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: prNumber, - state: 'closed', + issue_number: prNumber, + labels: ['Vouched'], }); - core.info(`Closed PR #${prNumber} from denounced user ${author}`); + core.info(`Added vouched label to PR #${prNumber} from ${author}`); diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index cf0524c21a8..9604bf87f37 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -33,5 +33,6 @@ jobs: with: issue-id: ${{ github.event.issue.number }} comment-id: ${{ github.event.comment.id }} + roles: admin,maintain env: GITHUB_TOKEN: ${{ steps.committer.outputs.token }} diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index f0b3f8e9270..6ef6d0847a3 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -13,7 +13,7 @@ Requirements: - Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure). - Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks. - Also preserve every term listed in the Do-Not-Translate glossary below. -- Also apply locale-specific guidance from `.opencode/agent/glossary/.md` when available (for example, `zh-cn.md`). +- Also apply locale-specific guidance from `.opencode/glossary/.md` when available (for example, `zh-cn.md`). - Do not modify fenced code blocks. - Output ONLY the translation (no commentary). diff --git a/.opencode/agent/glossary/README.md b/.opencode/glossary/README.md similarity index 100% rename from .opencode/agent/glossary/README.md rename to .opencode/glossary/README.md diff --git a/.opencode/agent/glossary/ar.md b/.opencode/glossary/ar.md similarity index 100% rename from .opencode/agent/glossary/ar.md rename to .opencode/glossary/ar.md diff --git a/.opencode/agent/glossary/br.md b/.opencode/glossary/br.md similarity index 100% rename from .opencode/agent/glossary/br.md rename to .opencode/glossary/br.md diff --git a/.opencode/agent/glossary/bs.md b/.opencode/glossary/bs.md similarity index 100% rename from .opencode/agent/glossary/bs.md rename to .opencode/glossary/bs.md diff --git a/.opencode/agent/glossary/da.md b/.opencode/glossary/da.md similarity index 100% rename from .opencode/agent/glossary/da.md rename to .opencode/glossary/da.md diff --git a/.opencode/agent/glossary/de.md b/.opencode/glossary/de.md similarity index 100% rename from .opencode/agent/glossary/de.md rename to .opencode/glossary/de.md diff --git a/.opencode/agent/glossary/es.md b/.opencode/glossary/es.md similarity index 100% rename from .opencode/agent/glossary/es.md rename to .opencode/glossary/es.md diff --git a/.opencode/agent/glossary/fr.md b/.opencode/glossary/fr.md similarity index 100% rename from .opencode/agent/glossary/fr.md rename to .opencode/glossary/fr.md diff --git a/.opencode/agent/glossary/ja.md b/.opencode/glossary/ja.md similarity index 100% rename from .opencode/agent/glossary/ja.md rename to .opencode/glossary/ja.md diff --git a/.opencode/agent/glossary/ko.md b/.opencode/glossary/ko.md similarity index 100% rename from .opencode/agent/glossary/ko.md rename to .opencode/glossary/ko.md diff --git a/.opencode/agent/glossary/no.md b/.opencode/glossary/no.md similarity index 100% rename from .opencode/agent/glossary/no.md rename to .opencode/glossary/no.md diff --git a/.opencode/agent/glossary/pl.md b/.opencode/glossary/pl.md similarity index 100% rename from .opencode/agent/glossary/pl.md rename to .opencode/glossary/pl.md diff --git a/.opencode/agent/glossary/ru.md b/.opencode/glossary/ru.md similarity index 100% rename from .opencode/agent/glossary/ru.md rename to .opencode/glossary/ru.md diff --git a/.opencode/agent/glossary/th.md b/.opencode/glossary/th.md similarity index 100% rename from .opencode/agent/glossary/th.md rename to .opencode/glossary/th.md diff --git a/.opencode/agent/glossary/zh-cn.md b/.opencode/glossary/zh-cn.md similarity index 100% rename from .opencode/agent/glossary/zh-cn.md rename to .opencode/glossary/zh-cn.md diff --git a/.opencode/agent/glossary/zh-tw.md b/.opencode/glossary/zh-tw.md similarity index 100% rename from .opencode/agent/glossary/zh-tw.md rename to .opencode/glossary/zh-tw.md diff --git a/bun.lock b/bun.lock index d68a9228fe7..f9f48eddd0e 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -75,7 +75,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -109,7 +109,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -136,7 +136,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -160,7 +160,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -184,7 +184,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -217,7 +217,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -246,7 +246,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -262,7 +262,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.10", + "version": "1.2.15", "bin": { "opencode": "./bin/opencode", }, @@ -376,7 +376,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -396,7 +396,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.10", + "version": "1.2.15", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -407,7 +407,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -418,9 +418,30 @@ "typescript": "catalog:", }, }, + "packages/storybook": { + "name": "@opencode-ai/storybook", + "devDependencies": { + "@opencode-ai/ui": "workspace:*", + "@solidjs/meta": "catalog:", + "@storybook/addon-a11y": "^10.2.10", + "@storybook/addon-docs": "^10.2.10", + "@storybook/addon-links": "^10.2.10", + "@storybook/addon-onboarding": "^10.2.10", + "@storybook/addon-vitest": "^10.2.10", + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@types/react": "18.0.25", + "react": "18.2.0", + "solid-js": "catalog:", + "storybook": "^10.2.10", + "storybook-solidjs-vite": "^10.0.9", + "typescript": "catalog:", + "vite": "catalog:", + }, + }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -462,7 +483,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "zod": "catalog:", }, @@ -473,7 +494,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1136,6 +1157,8 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.6.4", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-6PyZBYKnnVNqOSB0YFly+62R7dmov8segT27A+RVTBVd4iAE6kbW9QBJGlyR2yG4D4ohzhZSTIu7BK1UTtmFFA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -1208,6 +1231,8 @@ "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], @@ -1302,6 +1327,8 @@ "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], + "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], + "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], @@ -1774,6 +1801,26 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q=="], + + "@storybook/addon-docs": ["@storybook/addon-docs@10.2.10", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.10", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.10", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ=="], + + "@storybook/addon-links": ["@storybook/addon-links@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" }, "optionalPeers": ["react"] }, "sha512-oo9Xx4/2OVJtptXKpqH4ySri7ZuBdiSOXlZVGejEfLa0Jeajlh/KIlREpGvzPPOqUVT7dSddWzBjJmJUyQC3ew=="], + + "@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.10", "", { "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-DkzZQTXHp99SpHMIQ5plbbHcs4EWVzWhLXlW+icA8sBlKo5Bwj540YcOApKbqB0m/OzWprsznwN7Kv4vfvHu4w=="], + + "@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.10", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-U2oHw+Ar+Xd06wDTB74VlujhIIW89OHThpJjwgqgM6NWrOC/XLllJ53ILFDyREBkMwpBD7gJQIoQpLEcKBIEhw=="], + + "@storybook/builder-vite": ["@storybook/builder-vite@10.2.10", "", { "dependencies": { "@storybook/csf-plugin": "10.2.10", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Wd6CYL7LvRRNiXMz977x9u/qMm7nmMw/7Dow2BybQo+Xbfy1KhVjIoZ/gOiG515zpojSozctNrJUbM0+jH1jwg=="], + + "@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="], + + "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], + + "@storybook/icons": ["@storybook/icons@2.0.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg=="], + + "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.10", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" } }, "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w=="], + "@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], "@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], @@ -1866,6 +1913,12 @@ "@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -1876,6 +1929,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -2010,7 +2065,7 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], @@ -2020,7 +2075,7 @@ "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], - "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], @@ -2116,6 +2171,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "astro": ["astro@5.7.13", "", { "dependencies": { "@astrojs/compiler": "^2.11.0", "@astrojs/internal-helpers": "0.6.1", "@astrojs/markdown-remark": "6.3.1", "@astrojs/telemetry": "3.2.1", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.2.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.0", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.6.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.3.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.17", "magicast": "^0.3.5", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.0", "package-manager-detector": "^1.1.0", "picomatch": "^4.0.2", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", "unifont": "~0.5.0", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", "vite": "^6.3.4", "vitefu": "^1.0.6", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-cRGq2llKOhV3XMcYwQpfBIUcssN6HEK5CRbcMxAfd9OcFhvWE7KUy50zLioAZVVl3AqgUTJoNTlmZfD2eG0G1w=="], @@ -2144,6 +2201,8 @@ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -2258,7 +2317,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], @@ -2274,6 +2333,8 @@ "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cheerio": ["cheerio@1.0.0-rc.12", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "htmlparser2": "^8.0.1", "parse5": "^7.0.0", "parse5-htmlparser2-tree-adapter": "^7.0.0" } }, "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -2368,6 +2429,8 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -2388,6 +2451,8 @@ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -2440,6 +2505,8 @@ "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -2834,6 +2901,8 @@ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -3074,6 +3143,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], @@ -3084,6 +3155,8 @@ "luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], @@ -3234,6 +3307,8 @@ "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "miniflare": ["miniflare@4.20251118.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251118.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-uLSAE/DvOm392fiaig4LOaatxLjM7xzIniFRG5Y3yF9IduOYLLK/pkCPQNCgKQH3ou0YJRHnTN+09LPfqYNTQQ=="], "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], @@ -3426,6 +3501,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], @@ -3492,6 +3569,8 @@ "pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], @@ -3534,8 +3613,12 @@ "react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="], + "react-docgen-typescript": ["react-docgen-typescript@2.4.0", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg=="], + "react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.5.5", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.3", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.0", "use-sidecar": "^1.1.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw=="], @@ -3560,6 +3643,8 @@ "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], @@ -3568,6 +3653,8 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -3760,7 +3847,7 @@ "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -3808,6 +3895,10 @@ "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + "storybook": ["storybook@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-N4U42qKgzMHS7DjqLz5bY4P7rnvJtYkWFCyKspZr3FhPUuy6CWOae3aYC2BjXkHrdug0Jyta6VxFTuB1tYUKhg=="], + + "storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="], + "stream-replace-string": ["stream-replace-string@2.0.0", "", {}, "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="], "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], @@ -3834,6 +3925,8 @@ "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="], "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], @@ -3896,6 +3989,8 @@ "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "titleize": ["titleize@4.0.0", "", {}, "sha512-ZgUJ1K83rhdu7uh7EHAC2BgY5DzoX8V5rTvoWI4vFysggi6YjLe5gUXABPWAU7VkvGP7P/0YiWq+dcPeYDsf1g=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -3920,6 +4015,8 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], @@ -4020,6 +4117,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unstorage": ["unstorage@2.0.0-alpha.5", "", { "peerDependencies": { "@azure/app-configuration": "^1.9.0", "@azure/cosmos": "^4.7.0", "@azure/data-tables": "^13.3.1", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.29.1", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.12.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.35.6", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.8.2", "lru-cache": "^11.2.2", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-Sj8btci21Twnd6M+N+MHhjg3fVn6lAPElPmvFTe0Y/wR0WImErUdA1PzlAaUavHylJ7uDiFwlZDQKm0elG4b7g=="], "unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="], @@ -4032,6 +4131,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], @@ -4106,6 +4207,8 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -4260,6 +4363,8 @@ "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.10", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.5", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.19.0", "smol-toml": "^1.5.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A=="], + "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "@astrojs/sitemap/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@astrojs/solid-js/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], @@ -4488,6 +4593,8 @@ "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], @@ -4632,8 +4739,18 @@ "@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@vitest/mocker/@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -4692,8 +4809,6 @@ "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], @@ -4714,6 +4829,8 @@ "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -4814,6 +4931,10 @@ "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -4842,12 +4963,16 @@ "sitemap/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], "sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], + "storybook/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + + "storybook/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -4880,6 +5005,10 @@ "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "vitest/@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "vitest/@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + "vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], @@ -5210,6 +5339,8 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="], @@ -5304,6 +5435,60 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "storybook/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "storybook/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "storybook/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "storybook/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "storybook/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "storybook/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "storybook/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "storybook/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "storybook/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "storybook/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "storybook/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "storybook/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "storybook/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "storybook/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "storybook/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "storybook/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "storybook/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "storybook/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "storybook/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "storybook/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "storybook/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "storybook/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "storybook/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "storybook/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -5372,6 +5557,8 @@ "vite-plugin-icons-spritesheet/glob/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="], + "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], diff --git a/infra/console.ts b/infra/console.ts index 283fe2c37ca..de72cb072ee 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -101,7 +101,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", }) const zenLiteProduct = new stripe.Product("ZenLite", { - name: "OpenCode Lite", + name: "OpenCode Go", }) const zenLitePrice = new stripe.Price("ZenLitePrice", { product: zenLiteProduct.id, diff --git a/nix/hashes.json b/nix/hashes.json index 426f484f031..eaba0d8f0c0 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-3hfy6nfEnGq4J6inH0pXANw05oas+81iuayn7J0pj9c=", - "aarch64-linux": "sha256-dxWaLtzSeI5NfHwB6u0K10yxoA0ESz/r+zTEQ3FdKFY=", - "aarch64-darwin": "sha256-kkK4rj4g0j2jJFXVmVH7CJcXlI8Dj/KmL/VC3iE4Z+8=", - "x86_64-darwin": "sha256-jt51irxZd48kb0BItd8InP7lfsELUh0unVYO2es+a98=" + "x86_64-linux": "sha256-dZoLhWe4smBsOF7WczMySLXSAB1YRO1vfhiOCL1rBf0=", + "aarch64-linux": "sha256-J7nIz1xuVZEHun5WRZkYRySz29B0A8g5g0RRxnIWTYU=", + "aarch64-darwin": "sha256-R2PuhX+EjUBuLE8MF0G0fcUwNaU+5n6V6uVeK89ulzw=", + "x86_64-darwin": "sha256-Bvzfz9TsTpYriZNLSLgpNcNb+BgtkgpjoWqdOtF2IBg=" } } diff --git a/package.json b/package.json index 2e7c1172aa6..3fd9f306676 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.10", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index f17557a800a..74b3890888f 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -9,7 +9,7 @@ import { sessionIDFromUrl, } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" -import { createSdk, dirSlug } from "../utils" +import { createSdk, dirSlug, sessionPath } from "../utils" function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" @@ -51,7 +51,6 @@ test("switching back to a project opens the latest workspace session", async ({ const other = await createTestProject() const otherSlug = dirSlug(other) - const stamp = Date.now() let rootDir: string | undefined let workspaceDir: string | undefined let sessionID: string | undefined @@ -80,6 +79,7 @@ test("switching back to a project opens the latest workspace session", async ({ const workspaceSlug = slugFromUrl(page.url()) workspaceDir = base64Decode(workspaceSlug) + if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`) await openSidebar(page) const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first() @@ -92,15 +92,14 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`)) - const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - await prompt.fill(`project switch remembers workspace ${stamp}`) - await prompt.press("Enter") - - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") - const created = sessionIDFromUrl(page.url()) - if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`) + const created = await createSdk(workspaceDir) + .session.create() + .then((x) => x.data?.id) + if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`) sessionID = created + + await page.goto(sessionPath(workspaceDir, created)) + await expect(page.locator(promptSelector)).toBeVisible() await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`)) await openSidebar(page) @@ -114,7 +113,8 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(rootButton).toBeVisible() await rootButton.click() - await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`)) + await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) + await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) }, { extra: [other] }, ) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 6bf7714a66d..e9cfc03e485 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions" +import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" import { permissionDockSelector, promptSelector, @@ -11,11 +11,23 @@ import { } from "../selectors" type Sdk = Parameters[0] - -async function withDockSession(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise) { - const session = await sdk.session.create({ title }).then((r) => r.data) +type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } + +async function withDockSession( + sdk: Sdk, + title: string, + fn: (session: { id: string; title: string }) => Promise, + opts?: { permission?: PermissionRule[] }, +) { + const session = await sdk.session + .create(opts?.permission ? { title, permission: opts.permission } : { title }) + .then((r) => r.data) if (!session?.id) throw new Error("Session create did not return an id") - return fn(session) + try { + return await fn(session) + } finally { + await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) + } } test.setTimeout(120_000) @@ -28,6 +40,85 @@ async function withDockSeed(sdk: Sdk, sessionID: string, fn: () => Promise } } +async function clearPermissionDock(page: any, label: RegExp) { + const dock = page.locator(permissionDockSelector) + for (let i = 0; i < 3; i++) { + const count = await dock.count() + if (count === 0) return + await dock.getByRole("button", { name: label }).click() + await page.waitForTimeout(150) + } +} + +async function withMockPermission( + page: any, + request: { + id: string + sessionID: string + permission: string + patterns: string[] + metadata?: Record + always?: string[] + }, + opts: { child?: any } | undefined, + fn: () => Promise, +) { + let pending = [ + { + ...request, + always: request.always ?? ["*"], + metadata: request.metadata ?? {}, + }, + ] + + const list = async (route: any) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(pending), + }) + } + + const reply = async (route: any) => { + const url = new URL(route.request().url()) + const id = url.pathname.split("/").pop() + pending = pending.filter((item) => item.id !== id) + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(true), + }) + } + + await page.route("**/permission", list) + await page.route("**/session/*/permissions/*", reply) + + const sessionList = opts?.child + ? async (route: any) => { + const res = await route.fetch() + const json = await res.json() + const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined + if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child) + await route.fulfill({ + status: res.status(), + headers: res.headers(), + contentType: "application/json", + body: JSON.stringify(json), + }) + } + : undefined + + if (sessionList) await page.route("**/session?*", sessionList) + + try { + return await fn() + } finally { + await page.unroute("**/permission", list) + await page.unroute("**/session/*/permissions/*", reply) + if (sessionList) await page.unroute("**/session?*", sessionList) + } +} + test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock default", async (session) => { await gotoSession(session.id) @@ -76,72 +167,175 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock permission once", async (session) => { - await withDockSeed(sdk, session.id, async () => { - await gotoSession(session.id) - - await seedSessionPermission(sdk, { + await gotoSession(session.id) + await withMockPermission( + page, + { + id: "per_e2e_once", sessionID: session.id, permission: "bash", - patterns: ["README.md"], - description: "Need permission for command", - }) - - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) - - await page - .locator(permissionDockSelector) - .getByRole("button", { name: /allow once/i }) - .click() - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() - }) + patterns: ["/tmp/opencode-e2e-perm-once"], + metadata: { description: "Need permission for command" }, + }, + undefined, + async () => { + await page.goto(page.url()) + await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) + await expect(page.locator(promptSelector)).toHaveCount(0) + + await clearPermissionDock(page, /allow once/i) + await page.goto(page.url()) + await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) + await expect(page.locator(promptSelector)).toBeVisible() + }, + ) }) }) test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock permission reject", async (session) => { - await withDockSeed(sdk, session.id, async () => { - await gotoSession(session.id) - - await seedSessionPermission(sdk, { + await gotoSession(session.id) + await withMockPermission( + page, + { + id: "per_e2e_reject", sessionID: session.id, permission: "bash", - patterns: ["REJECT.md"], - }) - - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) - - await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click() - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() - }) + patterns: ["/tmp/opencode-e2e-perm-reject"], + }, + undefined, + async () => { + await page.goto(page.url()) + await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) + await expect(page.locator(promptSelector)).toHaveCount(0) + + await clearPermissionDock(page, /deny/i) + await page.goto(page.url()) + await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) + await expect(page.locator(promptSelector)).toBeVisible() + }, + ) }) }) test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock permission always", async (session) => { - await withDockSeed(sdk, session.id, async () => { - await gotoSession(session.id) - - await seedSessionPermission(sdk, { + await gotoSession(session.id) + await withMockPermission( + page, + { + id: "per_e2e_always", sessionID: session.id, permission: "bash", - patterns: ["README.md"], - description: "Need permission for command", + patterns: ["/tmp/opencode-e2e-perm-always"], + metadata: { description: "Need permission for command" }, + }, + undefined, + async () => { + await page.goto(page.url()) + await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) + await expect(page.locator(promptSelector)).toHaveCount(0) + + await clearPermissionDock(page, /allow always/i) + await page.goto(page.url()) + await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) + await expect(page.locator(promptSelector)).toBeVisible() + }, + ) + }) +}) + +test("child session question request blocks parent dock and unblocks after submit", async ({ + page, + sdk, + gotoSession, +}) => { + await withDockSession(sdk, "e2e composer dock child question parent", async (session) => { + await gotoSession(session.id) + + const child = await sdk.session + .create({ + title: "e2e composer dock child question", + parentID: session.id, + }) + .then((r) => r.data) + if (!child?.id) throw new Error("Child session create did not return an id") + + try { + await withDockSeed(sdk, child.id, async () => { + await seedSessionQuestion(sdk, { + sessionID: child.id, + questions: [ + { + header: "Child input", + question: "Pick one child option", + options: [ + { label: "Continue", description: "Continue child" }, + { label: "Stop", description: "Stop child" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + await expect(page.locator(promptSelector)).toHaveCount(0) + + await dock.locator('[data-slot="question-option"]').first().click() + await dock.getByRole("button", { name: /submit/i }).click() + + await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0) + await expect(page.locator(promptSelector)).toBeVisible() }) + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) +test("child session permission request blocks parent dock and supports allow once", async ({ + page, + sdk, + gotoSession, +}) => { + await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => { + await gotoSession(session.id) - await page - .locator(permissionDockSelector) - .getByRole("button", { name: /allow always/i }) - .click() - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() - }) + const child = await sdk.session + .create({ + title: "e2e composer dock child permission", + parentID: session.id, + }) + .then((r) => r.data) + if (!child?.id) throw new Error("Child session create did not return an id") + + try { + await withMockPermission( + page, + { + id: "per_e2e_child", + sessionID: child.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-child"], + metadata: { description: "Need child permission" }, + }, + { child }, + async () => { + await page.goto(page.url()) + const dock = page.locator(permissionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + await expect(page.locator(promptSelector)).toHaveCount(0) + + await clearPermissionDock(page, /allow once/i) + await page.goto(page.url()) + + await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) + await expect(page.locator(promptSelector)).toBeVisible() + }, + ) + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } }) }) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index ec6cdf83023..e015a1e9b96 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -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}` diff --git a/packages/app/package.json b/packages/app/package.json index b9397b0f40d..446c14e9671 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.10", + "version": "1.2.15", "description": "", "type": "module", "exports": { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index ea85829e0bc..a97c8265144 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -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 diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index af788d05b03..5ca29a520a0 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -97,9 +97,20 @@ export const DialogSelectModelUnpaid: Component = () => {
{i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
{language.t("dialog.provider.tag.recommended")} + + <> +
+ {language.t("dialog.provider.opencodeGo.tagline")} +
+ {language.t("dialog.provider.tag.recommended")} + +
{language.t("dialog.provider.anthropic.note")}
diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 8bbd3054b9a..76e718bb001 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -29,6 +29,7 @@ export const DialogSelectProvider: Component = () => { if (id === "anthropic") return language.t("dialog.provider.anthropic.note") if (id === "openai") return language.t("dialog.provider.openai.note") if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") } return ( @@ -70,6 +71,9 @@ export const DialogSelectProvider: Component = () => {
{i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
{language.t("settings.providers.tag.custom")} @@ -77,6 +81,9 @@ export const DialogSelectProvider: Component = () => { {language.t("dialog.provider.tag.recommended")} {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} +
)} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index cec09435425..3840f18ed87 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -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 ( - - - {prefix} - - {leaf} - - {(text) => ( - <> - - {text()} - - )} - - - <> - - Ignored - - -
- } - > - {props.children} - - ) -} - export default function FileTree(props: { path: string class?: string @@ -255,7 +201,6 @@ export default function FileTree(props: { modified?: readonly string[] kinds?: ReadonlyMap 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))} > - - -
- -
-
-
+ +
+ +
+
- - props.onFileClick?.(node)} - > -
- - + props.onFileClick?.(node)} + > +
+ + + + + + + + + - - - - - - - - - - - - + + + + ) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index adfd592f8d0..9174133acd2 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1048,6 +1048,11 @@ export const PromptInput: Component = (props) => { } const variants = createMemo(() => ["default", ...local.model.variant.list()]) + const accepting = createMemo(() => { + const id = params.id + if (!id) return false + return permission.isAutoAccepting(id, sdk.directory) + }) return (
@@ -1233,7 +1238,9 @@ export const PromptInput: Component = (props) => { diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 825d1dab6cf..d531fa50ab6 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,28 +1,28 @@ +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Keybind } from "@opencode-ai/ui/keybind" +import { Popover } from "@opencode-ai/ui/popover" +import { Spinner } from "@opencode-ai/ui/spinner" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@opencode-ai/ui/toast" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { getFilename } from "@opencode-ai/util/path" +import { useParams } from "@solidjs/router" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" -import { useParams } from "@solidjs/router" -import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" -import { useGlobalSDK } from "@/context/global-sdk" -import { getFilename } from "@opencode-ai/util/path" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" - -import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Button } from "@opencode-ai/ui/button" -import { AppIcon } from "@opencode-ai/ui/app-icon" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { Popover } from "@opencode-ai/ui/popover" -import { TextField } from "@opencode-ai/ui/text-field" -import { Keybind } from "@opencode-ai/ui/keybind" -import { showToast } from "@opencode-ai/ui/toast" import { StatusPopover } from "../status-popover" const OPEN_APPS = [ @@ -45,32 +45,67 @@ type OpenApp = (typeof OPEN_APPS)[number] type OS = "macos" | "windows" | "linux" | "unknown" const MAC_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { + id: "vscode", + label: "VS Code", + icon: "vscode", + openWith: "Visual Studio Code", + }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, - { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { + id: "antigravity", + label: "Antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, - { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + { + id: "android-studio", + label: "Android Studio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, ] as const const WINDOWS_APPS = [ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + { + id: "powershell", + label: "PowerShell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, ] as const const LINUX_APPS = [ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, ] as const type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number] @@ -213,7 +248,9 @@ export function SessionHeader() { const view = createMemo(() => layout.view(sessionKey)) const os = createMemo(() => detectOS(platform)) - const [exists, setExists] = createStore>>({ finder: true }) + const [exists, setExists] = createStore>>({ + finder: true, + }) const apps = createMemo(() => { if (os() === "macos") return MAC_APPS @@ -259,18 +296,34 @@ export function SessionHeader() { const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) + const opening = createMemo(() => openRequest.app !== undefined) + + createEffect(() => { + const value = prefs.app + if (options().some((o) => o.id === value)) return + setPrefs("app", options()[0]?.id ?? "finder") + }) const openDir = (app: OpenApp) => { + if (opening() || !canOpen() || !platform.openPath) return const directory = projectDirectory() if (!directory) return - if (!canOpen()) return const item = options().find((o) => o.id === app) const openWith = item && "openWith" in item ? item.openWith : undefined - Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err)) + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) } const copyPath = () => { @@ -315,7 +368,9 @@ export function SessionHeader() {
- {language.t("session.header.search.placeholder", { project: name() })} + {language.t("session.header.search.placeholder", { + project: name(), + })}
@@ -357,12 +412,21 @@ export function SessionHeader() {
@@ -377,7 +441,11 @@ export function SessionHeader() { as={IconButton} icon="chevron-down" variant="ghost" - class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover" + disabled={opening()} + class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default" + classList={{ + "bg-surface-raised-base-active": opening(), + }} aria-label={language.t("session.header.open.menu")} /> @@ -395,6 +463,7 @@ export function SessionHeader() { {(o) => ( { setMenu("open", false) openDir(o.id) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index b94e7a8e96c..c1e2da71291 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -46,6 +46,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v title={language.t("common.closeTab")} keybind={command.keybind("tab.close")} placement="bottom" + gutter={10} > {
{item.name} + + + {language.t("dialog.provider.opencode.tagline")} + + {language.t("dialog.provider.tag.recommended")} + + <> + + {language.t("dialog.provider.opencodeGo.tagline")} + + {language.t("dialog.provider.tag.recommended")} + +
{(key) => {language.t(key())}} diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 7eb5e8b2a35..feef6d466ef 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -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", () => { diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 72c058aec6b..53f072b6cb2 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -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 } diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 8c0035d555b..c1a87b95b89 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -49,9 +49,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let queue: Queued[] = [] let buffer: Queued[] = [] const coalesced = new Map() + const staleDeltas = new Set() let timer: ReturnType | 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) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 7e242130f15..f87c3fb394e 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -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), }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 6e771482890..b2610656103 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -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 } diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 988723834f9..ccfda5e698c 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -16,10 +16,6 @@ type PermissionRespondFn = (input: { directory?: string }) => void -function shouldAutoAccept(perm: PermissionRequest) { - return perm.permission === "edit" -} - function isNonAllowRule(rule: unknown) { if (!rule) return false if (typeof rule === "string") return rule !== "allow" @@ -40,10 +36,7 @@ function hasPermissionPromptRules(permission: unknown) { if (Array.isArray(permission)) return false const config = permission as Record - if (isNonAllowRule(config.edit)) return true - if (isNonAllowRule(config.write)) return true - - return false + return Object.values(config).some(isNonAllowRule) } export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ @@ -61,9 +54,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) const [store, setStore, _, ready] = persisted( - Persist.global("permission", ["permission.v3"]), + { + ...Persist.global("permission", ["permission.v3"]), + migrate(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value + + const data = value as Record + if (data.autoAccept) return value + + return { + ...data, + autoAccept: + typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits) + ? data.autoAcceptEdits + : {}, + } + }, + }, createStore({ - autoAcceptEdits: {} as Record, + autoAccept: {} as Record, }), ) @@ -112,7 +121,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple function isAutoAccepting(sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) - return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false + return store.autoAccept[key] ?? store.autoAccept[sessionID] ?? false } function bumpEnableVersion(sessionID: string, directory?: string) { @@ -128,7 +137,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const perm = event.properties if (!isAutoAccepting(perm.sessionID, e.name)) return - if (!shouldAutoAccept(perm)) return respondOnce(perm, e.name) }) @@ -139,8 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const version = bumpEnableVersion(sessionID, directory) setStore( produce((draft) => { - draft.autoAcceptEdits[key] = true - delete draft.autoAcceptEdits[sessionID] + draft.autoAccept[key] = true + delete draft.autoAccept[sessionID] }), ) @@ -152,7 +160,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple for (const perm of x.data ?? []) { if (!perm?.id) continue if (perm.sessionID !== sessionID) continue - if (!shouldAutoAccept(perm)) continue respondOnce(perm, directory) } }) @@ -164,8 +171,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const key = directory ? acceptKey(sessionID, directory) : undefined setStore( produce((draft) => { - if (key) delete draft.autoAcceptEdits[key] - delete draft.autoAcceptEdits[sessionID] + if (key) delete draft.autoAccept[key] + delete draft.autoAccept[sessionID] }), ) } @@ -174,7 +181,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple ready, respond, autoResponds(permission: PermissionRequest, directory?: string) { - return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission) + return isAutoAccepting(permission.sessionID, directory) }, isAutoAccepting, toggleAutoAccept(sessionID: string, directory: string) { diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 502364afdf3..9ef5272ef54 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -3,7 +3,16 @@ import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" -export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] +export const popularProviders = [ + "opencode", + "opencode-go", + "anthropic", + "github-copilot", + "openai", + "google", + "openrouter", + "vercel", +] const popularProviderSet = new Set(popularProviders) export function useProviders() { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 91a16b3b853..0046a8bc450 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -65,8 +65,8 @@ export const dict = { "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا", - "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا", + "command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا", + "command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا", "command.workspace.toggle": "تبديل مساحات العمل", "command.workspace.toggle.description": "تمكين أو تعطيل مساحات العمل المتعددة في الشريط الجانبي", "command.session.undo": "تراجع", @@ -91,6 +91,8 @@ export const dict = { "dialog.provider.group.other": "آخر", "dialog.provider.tag.recommended": "موصى به", "dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد", + "dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة", + "dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع", "dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API", "dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API", "dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API", @@ -364,10 +366,10 @@ export const dict = { "toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي", "toast.workspace.disabled.title": "تم تعطيل مساحات العمل", "toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي", - "toast.permissions.autoaccept.on.title": "قبول التعديلات تلقائيًا", - "toast.permissions.autoaccept.on.description": "سيتم الموافقة تلقائيًا على أذونات التحرير والكتابة", - "toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا", - "toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة", + "toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا", + "toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا", + "toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا", + "toast.permissions.autoaccept.off.description": "ستتطلب طلبات الأذونات موافقة", "toast.model.none.title": "لم يتم تحديد نموذج", "toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة", "toast.file.loadFailed.title": "فشل تحميل الملف", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 7682a12b697..0d41ba7fcac 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -65,8 +65,8 @@ export const dict = { "command.model.variant.cycle.description": "Mudar para o próximo nível de esforço", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Aceitar edições automaticamente", - "command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente", + "command.permissions.autoaccept.enable": "Aceitar permissões automaticamente", + "command.permissions.autoaccept.disable": "Parar de aceitar permissões automaticamente", "command.workspace.toggle": "Alternar espaços de trabalho", "command.workspace.toggle.description": "Habilitar ou desabilitar múltiplos espaços de trabalho na barra lateral", "command.session.undo": "Desfazer", @@ -91,6 +91,8 @@ export const dict = { "dialog.provider.group.other": "Outro", "dialog.provider.tag.recommended": "Recomendado", "dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais", + "dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis", + "dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos", "dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API", "dialog.provider.copilot.note": "Conectar com Copilot ou chave de API", "dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API", @@ -365,10 +367,10 @@ export const dict = { "toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral", "toast.workspace.disabled.title": "Espaços de trabalho desativados", "toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral", - "toast.permissions.autoaccept.on.title": "Aceitando edições automaticamente", - "toast.permissions.autoaccept.on.description": "Permissões de edição e escrita serão aprovadas automaticamente", - "toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente", - "toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação", + "toast.permissions.autoaccept.on.title": "Aceitando permissões automaticamente", + "toast.permissions.autoaccept.on.description": "Solicitações de permissão serão aprovadas automaticamente", + "toast.permissions.autoaccept.off.title": "Parou de aceitar permissões automaticamente", + "toast.permissions.autoaccept.off.description": "Solicitações de permissão exigirão aprovação", "toast.model.none.title": "Nenhum modelo selecionado", "toast.model.none.description": "Conecte um provedor para resumir esta sessão", "toast.file.loadFailed.title": "Falha ao carregar arquivo", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index d658926268e..a34d857b967 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -71,8 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Prebaci na sljedeći nivo", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Automatski prihvataj izmjene", - "command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena", + "command.permissions.autoaccept.enable": "Automatski prihvati dozvole", + "command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje dozvola", "command.workspace.toggle": "Prikaži/sakrij radne prostore", "command.workspace.toggle.description": "Omogući ili onemogući više radnih prostora u bočnoj traci", "command.session.undo": "Poništi", @@ -99,8 +99,10 @@ export const dict = { "dialog.provider.group.other": "Ostalo", "dialog.provider.tag.recommended": "Preporučeno", "dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge", + "dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli", + "dialog.provider.opencodeGo.tagline": "Povoljna pretplata za sve", "dialog.provider.anthropic.note": "Direktan pristup Claude modelima, uključujući Pro i Max", - "dialog.provider.copilot.note": "Claude modeli za pomoć pri kodiranju", + "dialog.provider.copilot.note": "AI modeli za pomoć pri kodiranju putem GitHub Copilot", "dialog.provider.openai.note": "GPT modeli za brze, sposobne opšte AI zadatke", "dialog.provider.google.note": "Gemini modeli za brze, strukturirane odgovore", "dialog.provider.openrouter.note": "Pristup svim podržanim modelima preko jednog provajdera", @@ -403,10 +405,10 @@ export const dict = { "toast.workspace.disabled.title": "Radni prostori onemogućeni", "toast.workspace.disabled.description": "Samo glavni worktree se prikazuje u bočnoj traci", - "toast.permissions.autoaccept.on.title": "Automatsko prihvatanje izmjena", - "toast.permissions.autoaccept.on.description": "Dozvole za izmjene i pisanje biće automatski odobrene", - "toast.permissions.autoaccept.off.title": "Zaustavljeno automatsko prihvatanje izmjena", - "toast.permissions.autoaccept.off.description": "Dozvole za izmjene i pisanje zahtijevaće odobrenje", + "toast.permissions.autoaccept.on.title": "Automatsko prihvatanje dozvola", + "toast.permissions.autoaccept.on.description": "Zahtjevi za dozvole će biti automatski odobreni", + "toast.permissions.autoaccept.off.title": "Zaustavljeno automatsko prihvatanje dozvola", + "toast.permissions.autoaccept.off.description": "Zahtjevi za dozvole će zahtijevati odobrenje", "toast.model.none.title": "Nije odabran model", "toast.model.none.description": "Poveži provajdera da sažmeš ovu sesiju", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index fabefcab756..3df23bd433a 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -71,8 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Skift til næste indsatsniveau", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Accepter ændringer automatisk", - "command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer", + "command.permissions.autoaccept.enable": "Accepter tilladelser automatisk", + "command.permissions.autoaccept.disable": "Stop med at acceptere tilladelser automatisk", "command.workspace.toggle": "Skift arbejdsområder", "command.workspace.toggle.description": "Aktiver eller deaktiver flere arbejdsområder i sidebjælken", "command.session.undo": "Fortryd", @@ -99,8 +99,10 @@ export const dict = { "dialog.provider.group.other": "Andre", "dialog.provider.tag.recommended": "Anbefalet", "dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere", + "dialog.provider.opencode.tagline": "Pålidelige optimerede modeller", + "dialog.provider.opencodeGo.tagline": "Billigt abonnement for alle", "dialog.provider.anthropic.note": "Direkte adgang til Claude-modeller, inklusive Pro og Max", - "dialog.provider.copilot.note": "Claude-modeller til kodningsassistance", + "dialog.provider.copilot.note": "AI-modeller til kodningsassistance via GitHub Copilot", "dialog.provider.openai.note": "GPT-modeller til hurtige, kompetente generelle AI-opgaver", "dialog.provider.google.note": "Gemini-modeller til hurtige, strukturerede svar", "dialog.provider.openrouter.note": "Få adgang til alle understøttede modeller fra én udbyder", @@ -396,10 +398,10 @@ export const dict = { "toast.theme.title": "Tema skiftet", "toast.scheme.title": "Farveskema", - "toast.permissions.autoaccept.on.title": "Accepterer ændringer automatisk", - "toast.permissions.autoaccept.on.description": "Redigerings- og skrivetilladelser vil automatisk blive godkendt", - "toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer", - "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse", + "toast.permissions.autoaccept.on.title": "Accepterer tilladelser automatisk", + "toast.permissions.autoaccept.on.description": "Anmodninger om tilladelse godkendes automatisk", + "toast.permissions.autoaccept.off.title": "Stoppet med at acceptere tilladelser automatisk", + "toast.permissions.autoaccept.off.description": "Anmodninger om tilladelse vil kræve godkendelse", "toast.workspace.enabled.title": "Arbejdsområder aktiveret", "toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 3a7bbe92772..ce48a195347 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -69,8 +69,8 @@ export const dict = { "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren", - "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen", + "command.permissions.autoaccept.enable": "Berechtigungen automatisch akzeptieren", + "command.permissions.autoaccept.disable": "Automatische Akzeptanz von Berechtigungen stoppen", "command.workspace.toggle": "Arbeitsbereiche umschalten", "command.workspace.toggle.description": "Mehrere Arbeitsbereiche in der Seitenleiste aktivieren oder deaktivieren", "command.session.undo": "Rückgängig", @@ -95,6 +95,8 @@ export const dict = { "dialog.provider.group.other": "Andere", "dialog.provider.tag.recommended": "Empfohlen", "dialog.provider.opencode.note": "Kuratierte Modelle inklusive Claude, GPT, Gemini und mehr", + "dialog.provider.opencode.tagline": "Zuverlässige, optimierte Modelle", + "dialog.provider.opencodeGo.tagline": "Kostengünstiges Abo für alle", "dialog.provider.anthropic.note": "Mit Claude Pro/Max oder API-Schlüssel verbinden", "dialog.provider.copilot.note": "Mit Copilot oder API-Schlüssel verbinden", "dialog.provider.openai.note": "Mit ChatGPT Pro/Plus oder API-Schlüssel verbinden", @@ -372,10 +374,10 @@ export const dict = { "toast.workspace.enabled.description": "Mehrere Worktrees werden jetzt in der Seitenleiste angezeigt", "toast.workspace.disabled.title": "Arbeitsbereiche deaktiviert", "toast.workspace.disabled.description": "Nur der Haupt-Worktree wird in der Seitenleiste angezeigt", - "toast.permissions.autoaccept.on.title": "Änderungen werden automatisch akzeptiert", - "toast.permissions.autoaccept.on.description": "Bearbeitungs- und Schreibrechte werden automatisch genehmigt", - "toast.permissions.autoaccept.off.title": "Automatische Annahme von Änderungen gestoppt", - "toast.permissions.autoaccept.off.description": "Bearbeitungs- und Schreibrechte erfordern Genehmigung", + "toast.permissions.autoaccept.on.title": "Berechtigungen werden automatisch akzeptiert", + "toast.permissions.autoaccept.on.description": "Berechtigungsanfragen werden automatisch genehmigt", + "toast.permissions.autoaccept.off.title": "Automatische Akzeptanz von Berechtigungen gestoppt", + "toast.permissions.autoaccept.off.description": "Berechtigungsanfragen erfordern eine Genehmigung", "toast.model.none.title": "Kein Modell ausgewählt", "toast.model.none.description": "Verbinden Sie einen Anbieter, um diese Sitzung zusammenzufassen", "toast.file.loadFailed.title": "Datei konnte nicht geladen werden", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 992509fcfa4..0b7a2e2808b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -71,8 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Switch to the next effort level", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Auto-accept edits", - "command.permissions.autoaccept.disable": "Stop auto-accepting edits", + "command.permissions.autoaccept.enable": "Auto-accept permissions", + "command.permissions.autoaccept.disable": "Stop auto-accepting permissions", "command.workspace.toggle": "Toggle workspaces", "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar", "command.session.undo": "Undo", @@ -99,8 +99,10 @@ export const dict = { "dialog.provider.group.other": "Other", "dialog.provider.tag.recommended": "Recommended", "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more", + "dialog.provider.opencode.tagline": "Reliable optimized models", + "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone", "dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max", - "dialog.provider.copilot.note": "Claude models for coding assistance", + "dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot", "dialog.provider.openai.note": "GPT models for fast, capable general AI tasks", "dialog.provider.google.note": "Gemini models for fast, structured responses", "dialog.provider.openrouter.note": "Access all supported models from one provider", @@ -402,10 +404,10 @@ export const dict = { "toast.workspace.disabled.title": "Workspaces disabled", "toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar", - "toast.permissions.autoaccept.on.title": "Auto-accepting edits", - "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved", - "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits", - "toast.permissions.autoaccept.off.description": "Edit and write permissions will require approval", + "toast.permissions.autoaccept.on.title": "Auto-accepting permissions", + "toast.permissions.autoaccept.on.description": "Permission requests will be automatically approved", + "toast.permissions.autoaccept.off.title": "Stopped auto-accepting permissions", + "toast.permissions.autoaccept.off.description": "Permission requests will require approval", "toast.model.none.title": "No model selected", "toast.model.none.description": "Connect a provider to summarize this session", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index b55d54c0ca5..de490cbe904 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -71,8 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente", - "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente", + "command.permissions.autoaccept.enable": "Aceptar permisos automáticamente", + "command.permissions.autoaccept.disable": "Dejar de aceptar permisos automáticamente", "command.workspace.toggle": "Alternar espacios de trabajo", "command.workspace.toggle.description": "Habilitar o deshabilitar múltiples espacios de trabajo en la barra lateral", "command.session.undo": "Deshacer", @@ -99,8 +99,10 @@ export const dict = { "dialog.provider.group.other": "Otro", "dialog.provider.tag.recommended": "Recomendado", "dialog.provider.opencode.note": "Modelos seleccionados incluyendo Claude, GPT, Gemini y más", + "dialog.provider.opencode.tagline": "Modelos optimizados y fiables", + "dialog.provider.opencodeGo.tagline": "Suscripción económica para todos", "dialog.provider.anthropic.note": "Acceso directo a modelos Claude, incluyendo Pro y Max", - "dialog.provider.copilot.note": "Modelos Claude para asistencia de codificación", + "dialog.provider.copilot.note": "Modelos de IA para asistencia de codificación a través de GitHub Copilot", "dialog.provider.openai.note": "Modelos GPT para tareas de IA generales rápidas y capaces", "dialog.provider.google.note": "Modelos Gemini para respuestas rápidas y estructuradas", "dialog.provider.openrouter.note": "Accede a todos los modelos soportados desde un solo proveedor", @@ -403,10 +405,10 @@ export const dict = { "toast.workspace.disabled.title": "Espacios de trabajo deshabilitados", "toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral", - "toast.permissions.autoaccept.on.title": "Aceptando ediciones automáticamente", - "toast.permissions.autoaccept.on.description": "Los permisos de edición y escritura serán aprobados automáticamente", - "toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente", - "toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación", + "toast.permissions.autoaccept.on.title": "Aceptando permisos automáticamente", + "toast.permissions.autoaccept.on.description": "Las solicitudes de permisos se aprobarán automáticamente", + "toast.permissions.autoaccept.off.title": "Se dejó de aceptar permisos automáticamente", + "toast.permissions.autoaccept.off.description": "Las solicitudes de permisos requerirán aprobación", "toast.model.none.title": "Ningún modelo seleccionado", "toast.model.none.description": "Conecta un proveedor para resumir esta sesión", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index c961f060e1f..5e197b4fb41 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -65,8 +65,8 @@ export const dict = { "command.model.variant.cycle.description": "Passer au niveau d'effort suivant", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications", - "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications", + "command.permissions.autoaccept.enable": "Accepter automatiquement les permissions", + "command.permissions.autoaccept.disable": "Arrêter d'accepter automatiquement les permissions", "command.workspace.toggle": "Basculer les espaces de travail", "command.workspace.toggle.description": "Activer ou désactiver plusieurs espaces de travail dans la barre latérale", "command.session.undo": "Annuler", @@ -91,6 +91,8 @@ export const dict = { "dialog.provider.group.other": "Autre", "dialog.provider.tag.recommended": "Recommandé", "dialog.provider.opencode.note": "Modèles sélectionnés incluant Claude, GPT, Gemini et plus", + "dialog.provider.opencode.tagline": "Modèles optimisés et fiables", + "dialog.provider.opencodeGo.tagline": "Abonnement abordable pour tous", "dialog.provider.anthropic.note": "Connectez-vous avec Claude Pro/Max ou une clé API", "dialog.provider.copilot.note": "Connectez-vous avec Copilot ou une clé API", "dialog.provider.openai.note": "Connectez-vous avec ChatGPT Pro/Plus ou une clé API", @@ -366,12 +368,10 @@ export const dict = { "toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale", "toast.workspace.disabled.title": "Espaces de travail désactivés", "toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale", - "toast.permissions.autoaccept.on.title": "Acceptation auto des modifications", - "toast.permissions.autoaccept.on.description": - "Les permissions de modification et d'écriture seront automatiquement approuvées", - "toast.permissions.autoaccept.off.title": "Arrêt acceptation auto des modifications", - "toast.permissions.autoaccept.off.description": - "Les permissions de modification et d'écriture nécessiteront une approbation", + "toast.permissions.autoaccept.on.title": "Acceptation automatique des permissions", + "toast.permissions.autoaccept.on.description": "Les demandes de permission seront approuvées automatiquement", + "toast.permissions.autoaccept.off.title": "Acceptation automatique des permissions arrêtée", + "toast.permissions.autoaccept.off.description": "Les demandes de permission nécessiteront une approbation", "toast.model.none.title": "Aucun modèle sélectionné", "toast.model.none.description": "Connectez un fournisseur pour résumer cette session", "toast.file.loadFailed.title": "Échec du chargement du fichier", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 7a62c9de271..30f27c197d6 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -65,8 +65,8 @@ export const dict = { "command.model.variant.cycle.description": "次の思考レベルに切り替え", "command.prompt.mode.shell": "シェル", "command.prompt.mode.normal": "プロンプト", - "command.permissions.autoaccept.enable": "編集を自動承認", - "command.permissions.autoaccept.disable": "編集の自動承認を停止", + "command.permissions.autoaccept.enable": "権限を自動承認する", + "command.permissions.autoaccept.disable": "権限の自動承認を停止する", "command.workspace.toggle": "ワークスペースを切り替え", "command.workspace.toggle.description": "サイドバーでの複数のワークスペースの有効化・無効化", "command.session.undo": "元に戻す", @@ -91,6 +91,8 @@ export const dict = { "dialog.provider.group.other": "その他", "dialog.provider.tag.recommended": "推奨", "dialog.provider.opencode.note": "Claude, GPT, Geminiなどを含む厳選されたモデル", + "dialog.provider.opencode.tagline": "信頼性の高い最適化モデル", + "dialog.provider.opencodeGo.tagline": "すべての人に低価格のサブスクリプション", "dialog.provider.anthropic.note": "Claude Pro/MaxまたはAPIキーで接続", "dialog.provider.copilot.note": "CopilotまたはAPIキーで接続", "dialog.provider.openai.note": "ChatGPT Pro/PlusまたはAPIキーで接続", @@ -364,10 +366,10 @@ export const dict = { "toast.workspace.enabled.description": "サイドバーに複数のワークツリーが表示されます", "toast.workspace.disabled.title": "ワークスペースが無効になりました", "toast.workspace.disabled.description": "サイドバーにはメインのワークツリーのみが表示されます", - "toast.permissions.autoaccept.on.title": "編集を自動承認中", - "toast.permissions.autoaccept.on.description": "編集と書き込みの権限は自動的に承認されます", - "toast.permissions.autoaccept.off.title": "編集の自動承認を停止しました", - "toast.permissions.autoaccept.off.description": "編集と書き込みの権限には承認が必要です", + "toast.permissions.autoaccept.on.title": "権限を自動承認しています", + "toast.permissions.autoaccept.on.description": "権限の要求は自動的に承認されます", + "toast.permissions.autoaccept.off.title": "権限の自動承認を停止しました", + "toast.permissions.autoaccept.off.description": "権限の要求には承認が必要になります", "toast.model.none.title": "モデルが選択されていません", "toast.model.none.description": "このセッションを要約するにはプロバイダーを接続してください", "toast.file.loadFailed.title": "ファイルの読み込みに失敗しました", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 8967c71cff7..da6cde9eabf 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -69,8 +69,8 @@ export const dict = { "command.model.variant.cycle.description": "다음 생각 수준으로 전환", "command.prompt.mode.shell": "셸", "command.prompt.mode.normal": "프롬프트", - "command.permissions.autoaccept.enable": "편집 자동 수락", - "command.permissions.autoaccept.disable": "편집 자동 수락 중지", + "command.permissions.autoaccept.enable": "권한 자동 수락", + "command.permissions.autoaccept.disable": "권한 자동 수락 중지", "command.workspace.toggle": "작업 공간 전환", "command.workspace.toggle.description": "사이드바에서 다중 작업 공간 활성화 또는 비활성화", "command.session.undo": "실행 취소", @@ -95,6 +95,8 @@ export const dict = { "dialog.provider.group.other": "기타", "dialog.provider.tag.recommended": "추천", "dialog.provider.opencode.note": "Claude, GPT, Gemini 등을 포함한 엄선된 모델", + "dialog.provider.opencode.tagline": "신뢰할 수 있는 최적화 모델", + "dialog.provider.opencodeGo.tagline": "모두를 위한 저렴한 구독", "dialog.provider.anthropic.note": "Claude Pro/Max 또는 API 키로 연결", "dialog.provider.copilot.note": "Copilot 또는 API 키로 연결", "dialog.provider.openai.note": "ChatGPT Pro/Plus 또는 API 키로 연결", @@ -367,10 +369,10 @@ export const dict = { "toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다", "toast.workspace.disabled.title": "작업 공간 비활성화됨", "toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다", - "toast.permissions.autoaccept.on.title": "편집 자동 수락 중", - "toast.permissions.autoaccept.on.description": "편집 및 쓰기 권한이 자동으로 승인됩니다", - "toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨", - "toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다", + "toast.permissions.autoaccept.on.title": "권한 자동 수락 중", + "toast.permissions.autoaccept.on.description": "권한 요청이 자동으로 승인됩니다", + "toast.permissions.autoaccept.off.title": "권한 자동 수락 중지됨", + "toast.permissions.autoaccept.off.description": "권한 요청에 승인이 필요합니다", "toast.model.none.title": "선택된 모델 없음", "toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요", "toast.file.loadFailed.title": "파일 로드 실패", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 8e1b1ce629d..bc04695d30b 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -74,8 +74,8 @@ export const dict = { "command.model.variant.cycle.description": "Bytt til neste innsatsnivå", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Godta endringer automatisk", - "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk", + "command.permissions.autoaccept.enable": "Aksepter tillatelser automatisk", + "command.permissions.autoaccept.disable": "Stopp automatisk akseptering av tillatelser", "command.workspace.toggle": "Veksle arbeidsområder", "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar", "command.session.undo": "Angre", @@ -102,8 +102,10 @@ export const dict = { "dialog.provider.group.other": "Andre", "dialog.provider.tag.recommended": "Anbefalt", "dialog.provider.opencode.note": "Utvalgte modeller inkludert Claude, GPT, Gemini og mer", + "dialog.provider.opencode.tagline": "Pålitelige, optimaliserte modeller", + "dialog.provider.opencodeGo.tagline": "Rimelig abonnement for alle", "dialog.provider.anthropic.note": "Direkte tilgang til Claude-modeller, inkludert Pro og Max", - "dialog.provider.copilot.note": "Claude-modeller for kodeassistanse", + "dialog.provider.copilot.note": "AI-modeller for kodeassistanse via GitHub Copilot", "dialog.provider.openai.note": "GPT-modeller for raske, dyktige generelle AI-oppgaver", "dialog.provider.google.note": "Gemini-modeller for raske, strukturerte svar", "dialog.provider.openrouter.note": "Tilgang til alle støttede modeller fra én leverandør", @@ -404,10 +406,10 @@ export const dict = { "toast.workspace.disabled.title": "Arbeidsområder deaktivert", "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet", - "toast.permissions.autoaccept.on.title": "Godtar endringer automatisk", - "toast.permissions.autoaccept.on.description": "Redigerings- og skrivetillatelser vil bli godkjent automatisk", - "toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk", - "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning", + "toast.permissions.autoaccept.on.title": "Aksepterer tillatelser automatisk", + "toast.permissions.autoaccept.on.description": "Forespørsler om tillatelse vil bli godkjent automatisk", + "toast.permissions.autoaccept.off.title": "Stoppet automatisk akseptering av tillatelser", + "toast.permissions.autoaccept.off.description": "Forespørsler om tillatelse vil kreve godkjenning", "toast.model.none.title": "Ingen modell valgt", "toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 9b924fd642e..0be46f095c9 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -65,8 +65,8 @@ export const dict = { "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku", "command.prompt.mode.shell": "Terminal", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji", - "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji", + "command.permissions.autoaccept.enable": "Automatycznie akceptuj uprawnienia", + "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie uprawnień", "command.workspace.toggle": "Przełącz przestrzenie robocze", "command.workspace.toggle.description": "Włącz lub wyłącz wiele przestrzeni roboczych na pasku bocznym", "command.session.undo": "Cofnij", @@ -91,8 +91,10 @@ export const dict = { "dialog.provider.group.other": "Inne", "dialog.provider.tag.recommended": "Zalecane", "dialog.provider.opencode.note": "Wyselekcjonowane modele, w tym Claude, GPT, Gemini i inne", + "dialog.provider.opencode.tagline": "Niezawodne, zoptymalizowane modele", + "dialog.provider.opencodeGo.tagline": "Tania subskrypcja dla każdego", "dialog.provider.anthropic.note": "Bezpośredni dostęp do modeli Claude, w tym Pro i Max", - "dialog.provider.copilot.note": "Modele Claude do pomocy w kodowaniu", + "dialog.provider.copilot.note": "Modele AI do pomocy w kodowaniu przez GitHub Copilot", "dialog.provider.openai.note": "Modele GPT do szybkich i wszechstronnych zadań AI", "dialog.provider.google.note": "Modele Gemini do szybkich i ustrukturyzowanych odpowiedzi", "dialog.provider.openrouter.note": "Dostęp do wszystkich obsługiwanych modeli od jednego dostawcy", @@ -365,10 +367,10 @@ export const dict = { "toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym", "toast.workspace.disabled.title": "Przestrzenie robocze wyłączone", "toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym", - "toast.permissions.autoaccept.on.title": "Automatyczne akceptowanie edycji", - "toast.permissions.autoaccept.on.description": "Uprawnienia do edycji i zapisu będą automatycznie zatwierdzane", - "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji", - "toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia", + "toast.permissions.autoaccept.on.title": "Automatyczne akceptowanie uprawnień", + "toast.permissions.autoaccept.on.description": "Żądania uprawnień będą automatycznie zatwierdzane", + "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie uprawnień", + "toast.permissions.autoaccept.off.description": "Żądania uprawnień będą wymagały zatwierdzenia", "toast.model.none.title": "Nie wybrano modelu", "toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję", "toast.file.loadFailed.title": "Nie udało się załadować pliku", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index cf02285821e..cbb916a5946 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -71,8 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Переключиться к следующему уровню усилий", "command.prompt.mode.shell": "Оболочка", "command.prompt.mode.normal": "Промпт", - "command.permissions.autoaccept.enable": "Авто-принятие изменений", - "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений", + "command.permissions.autoaccept.enable": "Автоматически принимать разрешения", + "command.permissions.autoaccept.disable": "Остановить автоматическое принятие разрешений", "command.workspace.toggle": "Переключить рабочие пространства", "command.workspace.toggle.description": "Включить или отключить несколько рабочих пространств в боковой панели", "command.session.undo": "Отменить", @@ -99,8 +99,10 @@ export const dict = { "dialog.provider.group.other": "Другие", "dialog.provider.tag.recommended": "Рекомендуемые", "dialog.provider.opencode.note": "Отобранные модели, включая Claude, GPT, Gemini и другие", + "dialog.provider.opencode.tagline": "Надежные оптимизированные модели", + "dialog.provider.opencodeGo.tagline": "Доступная подписка для всех", "dialog.provider.anthropic.note": "Прямой доступ к моделям Claude, включая Pro и Max", - "dialog.provider.copilot.note": "Модели Claude для помощи в кодировании", + "dialog.provider.copilot.note": "ИИ-модели для помощи в кодировании через GitHub Copilot", "dialog.provider.openai.note": "Модели GPT для быстрых и мощных задач общего ИИ", "dialog.provider.google.note": "Модели Gemini для быстрых и структурированных ответов", "dialog.provider.openrouter.note": "Доступ ко всем поддерживаемым моделям через одного провайдера", @@ -398,10 +400,10 @@ export const dict = { "toast.theme.title": "Тема переключена", "toast.scheme.title": "Цветовая схема", - "toast.permissions.autoaccept.on.title": "Авто-принятие изменений", - "toast.permissions.autoaccept.on.description": "Разрешения на редактирование и запись будут автоматически одобрены", - "toast.permissions.autoaccept.off.title": "Авто-принятие остановлено", - "toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения", + "toast.permissions.autoaccept.on.title": "Разрешения принимаются автоматически", + "toast.permissions.autoaccept.on.description": "Запросы на разрешения будут одобряться автоматически", + "toast.permissions.autoaccept.off.title": "Автоматическое принятие разрешений остановлено", + "toast.permissions.autoaccept.off.description": "Запросы на разрешения будут требовать одобрения", "toast.workspace.enabled.title": "Рабочие пространства включены", "toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 1b8abe953b7..c6a33dc676a 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -71,8 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป", "command.prompt.mode.shell": "เชลล์", "command.prompt.mode.normal": "พรอมต์", - "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ", - "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", + "command.permissions.autoaccept.enable": "ยอมรับสิทธิ์โดยอัตโนมัติ", + "command.permissions.autoaccept.disable": "หยุดยอมรับสิทธิ์โดยอัตโนมัติ", "command.workspace.toggle": "สลับพื้นที่ทำงาน", "command.workspace.toggle.description": "เปิดหรือปิดใช้งานพื้นที่ทำงานหลายรายการในแถบด้านข้าง", "command.session.undo": "ยกเลิก", @@ -99,8 +99,10 @@ export const dict = { "dialog.provider.group.other": "อื่น ๆ", "dialog.provider.tag.recommended": "แนะนำ", "dialog.provider.opencode.note": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ", + "dialog.provider.opencode.tagline": "โมเดลที่เชื่อถือได้และปรับให้เหมาะสม", + "dialog.provider.opencodeGo.tagline": "การสมัครสมาชิกราคาประหยัดสำหรับทุกคน", "dialog.provider.anthropic.note": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max", - "dialog.provider.copilot.note": "โมเดล Claude สำหรับการช่วยเหลือในการเขียนโค้ด", + "dialog.provider.copilot.note": "โมเดล AI สำหรับการช่วยเหลือในการเขียนโค้ดผ่าน GitHub Copilot", "dialog.provider.openai.note": "โมเดล GPT สำหรับงาน AI ทั่วไปที่รวดเร็วและมีความสามารถ", "dialog.provider.google.note": "โมเดล Gemini สำหรับการตอบสนองที่รวดเร็วและมีโครงสร้าง", "dialog.provider.openrouter.note": "เข้าถึงโมเดลที่รองรับทั้งหมดจากผู้ให้บริการเดียว", @@ -401,10 +403,10 @@ export const dict = { "toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว", "toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง", - "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ", - "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ", - "toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", - "toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ", + "toast.permissions.autoaccept.on.title": "กำลังยอมรับสิทธิ์โดยอัตโนมัติ", + "toast.permissions.autoaccept.on.description": "คำขอสิทธิ์จะได้รับการอนุมัติโดยอัตโนมัติ", + "toast.permissions.autoaccept.off.title": "หยุดยอมรับสิทธิ์โดยอัตโนมัติแล้ว", + "toast.permissions.autoaccept.off.description": "คำขอสิทธิ์จะต้องได้รับการอนุมัติ", "toast.model.none.title": "ไม่ได้เลือกโมเดล", "toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 62c7bb9ff23..0b573908369 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -96,8 +96,8 @@ export const dict = { "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "自动接受编辑", - "command.permissions.autoaccept.disable": "停止自动接受编辑", + "command.permissions.autoaccept.enable": "自动接受权限", + "command.permissions.autoaccept.disable": "停止自动接受权限", "command.workspace.toggle": "切换工作区", "command.workspace.toggle.description": "在侧边栏启用或禁用多个工作区", @@ -126,6 +126,8 @@ export const dict = { "dialog.provider.group.other": "其他", "dialog.provider.tag.recommended": "推荐", "dialog.provider.opencode.note": "使用 OpenCode Zen 或 API 密钥连接", + "dialog.provider.opencode.tagline": "可靠的优化模型", + "dialog.provider.opencodeGo.tagline": "适合所有人的低成本订阅", "dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 密钥连接", "dialog.provider.copilot.note": "使用 Copilot 或 API 密钥连接", "dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 密钥连接", @@ -413,10 +415,10 @@ export const dict = { "toast.workspace.enabled.description": "侧边栏现在显示多个工作树", "toast.workspace.disabled.title": "工作区已禁用", "toast.workspace.disabled.description": "侧边栏只显示主工作树", - "toast.permissions.autoaccept.on.title": "自动接受编辑", - "toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批", - "toast.permissions.autoaccept.off.title": "已停止自动接受编辑", - "toast.permissions.autoaccept.off.description": "编辑和写入权限将需要手动批准", + "toast.permissions.autoaccept.on.title": "正在自动接受权限", + "toast.permissions.autoaccept.on.description": "权限请求将被自动批准", + "toast.permissions.autoaccept.off.title": "已停止自动接受权限", + "toast.permissions.autoaccept.off.description": "权限请求将需要批准", "toast.model.none.title": "未选择模型", "toast.model.none.description": "请先连接提供商以总结此会话", "toast.file.loadFailed.title": "加载文件失败", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index cb8f068f63b..4e4509e201c 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -75,8 +75,8 @@ export const dict = { "command.model.variant.cycle.description": "切換到下一個強度等級", "command.prompt.mode.shell": "Shell", "command.prompt.mode.normal": "Prompt", - "command.permissions.autoaccept.enable": "自動接受編輯", - "command.permissions.autoaccept.disable": "停止自動接受編輯", + "command.permissions.autoaccept.enable": "自動接受權限", + "command.permissions.autoaccept.disable": "停止自動接受權限", "command.workspace.toggle": "切換工作區", "command.workspace.toggle.description": "在側邊欄啟用或停用多個工作區", "command.session.undo": "復原", @@ -103,6 +103,8 @@ export const dict = { "dialog.provider.group.other": "其他", "dialog.provider.tag.recommended": "推薦", "dialog.provider.opencode.note": "精選模型,包含 Claude、GPT、Gemini 等等", + "dialog.provider.opencode.tagline": "可靠的優化模型", + "dialog.provider.opencodeGo.tagline": "適合所有人的低成本訂閱", "dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 金鑰連線", "dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 金鑰連線", "dialog.provider.copilot.note": "使用 Copilot 或 API 金鑰連線", @@ -400,10 +402,10 @@ export const dict = { "toast.workspace.disabled.title": "工作區已停用", "toast.workspace.disabled.description": "側邊欄只顯示主工作樹", - "toast.permissions.autoaccept.on.title": "自動接受編輯", - "toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准", - "toast.permissions.autoaccept.off.title": "已停止自動接受編輯", - "toast.permissions.autoaccept.off.description": "編輯和寫入權限將需要手動批准", + "toast.permissions.autoaccept.on.title": "正在自動接受權限", + "toast.permissions.autoaccept.on.description": "權限請求將被自動批准", + "toast.permissions.autoaccept.off.title": "已停止自動接受權限", + "toast.permissions.autoaccept.off.description": "權限請求將需要批准", "toast.model.none.title": "未選擇模型", "toast.model.none.description": "請先連線提供者以總結此工作階段", diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 4f1d93ab282..71b52180f2e 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,12 +1,11 @@ import { createEffect, createMemo, Show, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" -import { SDKProvider, useSDK } from "@/context/sdk" +import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" import { DataProvider } from "@opencode-ai/ui/context" -import type { QuestionAnswer } from "@opencode-ai/sdk/v2" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" @@ -15,19 +14,11 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const params = useParams() const navigate = useNavigate() const sync = useSync() - const sdk = useSDK() return ( sdk.client.permission.respond(input)} - onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)} - onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} > diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 62094a6e428..cb194052d1e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -61,6 +61,7 @@ import { displayName, errorMessage, getDraggableId, + latestRootSession, sortedRootSessions, syncWorkspaceOrder, workspaceKey, @@ -1093,14 +1094,51 @@ export default function Layout(props: ParentProps) { return meta?.worktree ?? directory } - function navigateToProject(directory: string | undefined) { + async function navigateToProject(directory: string | undefined) { if (!directory) return const root = projectRoot(directory) server.projects.touch(root) + const project = layout.projects.list().find((item) => item.worktree === root) + const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])])) + const openSession = async (target: { directory: string; id: string }) => { + const resolved = await globalSDK.client.session + .get({ sessionID: target.id }) + .then((x) => x.data) + .catch(() => undefined) + const next = resolved?.directory ? resolved : target + setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() }) + navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`) + } const projectSession = store.lastProjectSession[root] if (projectSession?.id) { - navigateWithSidebarReset(`/${base64Encode(projectSession.directory)}/session/${projectSession.id}`) + await openSession(projectSession) + return + } + + const latest = latestRootSession( + dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]), + Date.now(), + ) + if (latest) { + await openSession(latest) + return + } + + const fetched = latestRootSession( + await Promise.all( + dirs.map(async (item) => ({ + path: { directory: item }, + session: await globalSDK.client.session + .list({ directory: item }) + .then((x) => x.data ?? []) + .catch(() => []), + })), + ), + Date.now(), + ) + if (fetched) { + await openSession(fetched) return } diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 83d8f4748ab..7627d9ba17c 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -1,6 +1,25 @@ import { describe, expect, test } from "bun:test" +import { type Session } from "@opencode-ai/sdk/v2/client" import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links" -import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" +import { + displayName, + errorMessage, + getDraggableId, + latestRootSession, + syncWorkspaceOrder, + workspaceKey, +} from "./helpers" + +const session = (input: Partial & Pick) => + ({ + title: "", + version: "v2", + parentID: undefined, + messageCount: 0, + permissions: { session: {}, share: {} }, + time: { created: 0, updated: 0, archived: undefined }, + ...input, + }) as Session describe("layout deep links", () => { test("parses open-project deep links", () => { @@ -73,6 +92,61 @@ describe("layout workspace helpers", () => { expect(result).toEqual(["/root", "/c", "/b"]) }) + test("finds the latest root session across workspaces", () => { + const result = latestRootSession( + [ + { + path: { directory: "/root" }, + session: [session({ id: "root", directory: "/root", time: { created: 1, updated: 1, archived: undefined } })], + }, + { + path: { directory: "/workspace" }, + session: [ + session({ + id: "workspace", + directory: "/workspace", + time: { created: 2, updated: 2, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("workspace") + }) + + test("ignores archived and child sessions when finding latest root session", () => { + const result = latestRootSession( + [ + { + path: { directory: "/workspace" }, + session: [ + session({ + id: "archived", + directory: "/workspace", + time: { created: 10, updated: 10, archived: 10 }, + }), + session({ + id: "child", + directory: "/workspace", + parentID: "parent", + time: { created: 20, updated: 20, archived: undefined }, + }), + session({ + id: "root", + directory: "/workspace", + time: { created: 30, updated: 30, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("root") + }) + test("extracts draggable id safely", () => { expect(getDraggableId({ draggable: { id: "x" } })).toBe("x") expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined() diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 6a1e7c0123d..be4297fbe91 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -28,6 +28,11 @@ export const isRootVisibleSession = (session: Session, directory: string) => export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) +export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => + stores + .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) + .sort(sortSessions(now))[0] + export const childMapByParent = (sessions: Session[]) => { const map = new Map() for (const session of sessions) { diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index e19e6f430f0..3c3652e38f3 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -1,4 +1,5 @@ -import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js" +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createStore } from "solid-js/store" import { base64Encode } from "@opencode-ai/util/encode" import { Button } from "@opencode-ai/ui/button" import { ContextMenu } from "@opencode-ai/ui/context-menu" @@ -7,7 +8,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { createSortable } from "@thisbeyond/solid-dnd" -import { type LocalProject } from "@/context/layout" +import { useLayout, type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" @@ -60,6 +61,7 @@ const ProjectTile = (props: { selected: Accessor active: Accessor overlay: Accessor + suppressHover: Accessor dirs: Accessor onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseLeave: (worktree: string) => void @@ -71,9 +73,11 @@ const ProjectTile = (props: { closeProject: (directory: string) => void setMenu: (value: boolean) => void setOpen: (value: boolean) => void + setSuppressHover: (value: boolean) => void language: ReturnType }): JSX.Element => { const notification = useNotification() + const layout = useLayout() const unseenCount = createMemo(() => props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), ) @@ -107,17 +111,28 @@ const ProjectTile = (props: { }} onMouseEnter={(event: MouseEvent) => { if (!props.overlay()) return + if (props.suppressHover()) return props.onProjectMouseEnter(props.project.worktree, event) }} onMouseLeave={() => { + if (props.suppressHover()) props.setSuppressHover(false) if (!props.overlay()) return props.onProjectMouseLeave(props.project.worktree) }} onFocus={() => { if (!props.overlay()) return + if (props.suppressHover()) return props.onProjectFocus(props.project.worktree) }} - onClick={() => props.navigateToProject(props.project.worktree)} + onClick={() => { + if (props.selected()) { + props.setSuppressHover(true) + layout.sidebar.toggle() + return + } + props.setSuppressHover(false) + props.navigateToProject(props.project.worktree) + }} onBlur={() => props.setOpen(false)} > @@ -278,16 +293,19 @@ export const SortableProject = (props: { const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) - const [open, setOpen] = createSignal(false) - const [menu, setMenu] = createSignal(false) + const [state, setState] = createStore({ + open: false, + menu: false, + suppressHover: false, + }) const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) const active = createMemo(() => projectTileActive({ - menu: menu(), + menu: state.menu, preview: preview(), - open: open(), + open: state.open, overlay: overlay(), hoverProject: props.ctx.hoverProject(), worktree: props.project.worktree, @@ -296,8 +314,14 @@ export const SortableProject = (props: { createEffect(() => { if (preview()) return - if (!open()) return - setOpen(false) + if (!state.open) return + setState("open", false) + }) + + createEffect(() => { + if (!selected()) return + if (!state.open) return + setState("open", false) }) const label = (directory: string) => { @@ -328,6 +352,7 @@ export const SortableProject = (props: { selected={selected} active={active} overlay={overlay} + suppressHover={() => state.suppressHover} dirs={dirs} onProjectMouseEnter={props.ctx.onProjectMouseEnter} onProjectMouseLeave={props.ctx.onProjectMouseLeave} @@ -337,8 +362,9 @@ export const SortableProject = (props: { toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces} workspacesEnabled={props.ctx.workspacesEnabled} closeProject={props.ctx.closeProject} - setMenu={setMenu} - setOpen={setOpen} + setMenu={(value) => setState("menu", value)} + setOpen={(value) => setState("open", value)} + setSuppressHover={(value) => setState("suppressHover", value)} language={language} /> ) @@ -346,17 +372,18 @@ export const SortableProject = (props: { return ( // @ts-ignore
- + { - if (menu()) return - setOpen(value) + if (state.menu) return + if (value && state.suppressHover) return + setState("open", value) if (value) props.ctx.setHoverSession(undefined) }} > @@ -371,7 +398,7 @@ export const SortableProject = (props: { projectChildren={projectChildren} workspaceSessions={workspaceSessions} workspaceChildren={workspaceChildren} - setOpen={setOpen} + setOpen={(value) => setState("open", value)} ctx={props.ctx} language={language} /> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e0ef92682d9..6751f4186f7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -254,12 +254,13 @@ export default function Page() { const msgs = visibleUserMessages() if (msgs.length === 0) return - const current = activeMessage() - const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1 - const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset - if (targetIndex < 0 || targetIndex >= msgs.length) return + const current = store.messageId + const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length + const currentIndex = base === -1 ? msgs.length : base + const targetIndex = currentIndex + offset + if (targetIndex < 0 || targetIndex > msgs.length) return - if (targetIndex === msgs.length - 1) { + if (targetIndex === msgs.length) { resumeScroll() return } @@ -415,7 +416,7 @@ export default function Page() { ) const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened()) + const reviewTab = createMemo(() => isDesktop()) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -699,33 +700,12 @@ export default function Page() { const active = tabs().active() const tab = active === "review" || (!active && hasReview()) ? "changes" : "all" layout.fileTree.setTab(tab) - return } - - if (fileTreeTab() !== "changes") return - tabs().setActive("review") }, { defer: true }, ), ) - createEffect(() => { - if (!isDesktop()) return - if (!layout.fileTree.opened()) return - if (fileTreeTab() !== "all") return - - const active = tabs().active() - if (active && active !== "review") return - - const first = openedTabs()[0] - if (first) { - tabs().setActive(first) - return - } - - if (contextOpen()) tabs().setActive("context") - }) - createEffect(() => { const id = params.id if (!id) return diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts new file mode 100644 index 00000000000..7b6029eb31b --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" + +const session = (input: { id: string; parentID?: string }) => + ({ + id: input.id, + parentID: input.parentID, + }) as Session + +const permission = (id: string, sessionID: string) => + ({ + id, + sessionID, + }) as PermissionRequest + +const question = (id: string, sessionID: string) => + ({ + id, + sessionID, + questions: [], + }) as QuestionRequest + +describe("sessionPermissionRequest", () => { + test("prefers the current session permission", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const permissions = { + root: [permission("perm-root", "root")], + child: [permission("perm-child", "child")], + } + + expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root") + }) + + test("returns a nested child permission", () => { + const sessions = [ + session({ id: "root" }), + session({ id: "child", parentID: "root" }), + session({ id: "grand", parentID: "child" }), + session({ id: "other" }), + ] + const permissions = { + grand: [permission("perm-grand", "grand")], + other: [permission("perm-other", "other")], + } + + expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand") + }) + + test("returns undefined without a matching tree permission", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const permissions = { + other: [permission("perm-other", "other")], + } + + expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined() + }) +}) + +describe("sessionQuestionRequest", () => { + test("prefers the current session question", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const questions = { + root: [question("q-root", "root")], + child: [question("q-child", "child")], + } + + expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root") + }) + + test("returns a nested child question", () => { + const sessions = [ + session({ id: "root" }), + session({ id: "child", parentID: "root" }), + session({ id: "grand", parentID: "child" }), + ] + const questions = { + grand: [question("q-grand", "grand")], + } + + expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") + }) +}) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 04c6f7e692a..ed65867ef00 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -7,14 +7,20 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" export function createSessionComposerBlocked() { const params = useParams() const sync = useSync() + const permissionRequest = createMemo(() => + sessionPermissionRequest(sync.data.session, sync.data.permission, params.id), + ) + const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id)) + return createMemo(() => { const id = params.id if (!id) return false - return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0] + return !!permissionRequest() || !!questionRequest() }) } @@ -26,18 +32,18 @@ export function createSessionComposerState() { const language = useLanguage() const questionRequest = createMemo((): QuestionRequest | undefined => { - const id = params.id - if (!id) return - return sync.data.question[id]?.[0] + return sessionQuestionRequest(sync.data.session, sync.data.question, params.id) }) const permissionRequest = createMemo((): PermissionRequest | undefined => { - const id = params.id - if (!id) return - return sync.data.permission[id]?.[0] + return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id) }) - const blocked = createSessionComposerBlocked() + const blocked = createMemo(() => { + const id = params.id + if (!id) return false + return !!permissionRequest() || !!questionRequest() + }) const todos = createMemo((): Todo[] => { const id = params.id diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts new file mode 100644 index 00000000000..f9673e25494 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-request-tree.ts @@ -0,0 +1,45 @@ +import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" + +function sessionTreeRequest(session: Session[], request: Record, sessionID?: string) { + if (!sessionID) return + + const map = session.reduce((acc, item) => { + if (!item.parentID) return acc + const list = acc.get(item.parentID) + if (list) list.push(item.id) + if (!list) acc.set(item.parentID, [item.id]) + return acc + }, new Map()) + + const seen = new Set([sessionID]) + const ids = [sessionID] + for (const id of ids) { + const list = map.get(id) + if (!list) continue + for (const child of list) { + if (seen.has(child)) continue + seen.add(child) + ids.push(child) + } + } + + const id = ids.find((id) => !!request[id]?.[0]) + if (!id) return + return request[id]?.[0] +} + +export function sessionPermissionRequest( + session: Session[], + request: Record, + sessionID?: string, +) { + return sessionTreeRequest(session, request, sessionID) +} + +export function sessionQuestionRequest( + session: Session[], + request: Record, + sessionID?: string, +) { + return sessionTreeRequest(session, request, sessionID) +} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 032756cabd8..4b30915d865 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -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) }} diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 7d357e6572c..aaa5b932fe9 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -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"]) }) }) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 995f6eb191d..20f1d99a8be 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -24,13 +24,15 @@ export const createOpenReviewFile = (input: { showAllFiles: () => void tabForPath: (path: string) => string openTab: (tab: string) => void - loadFile: (path: string) => void + loadFile: (path: string) => any | Promise }) => { 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() }) } } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 615d1a0bea4..b8410903550 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -376,6 +376,7 @@ export function MessageTimeline(props: { >
{ - const node = button - if (!node) return - - const scroll = node.parentElement - if (!scroll) return - - const handler = () => { - const rect = node.getBoundingClientRect() - const scrollRect = scroll.getBoundingClientRect() - setState("stuck", rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) - } - - scroll.addEventListener("scroll", handler, { passive: true }) - const observer = new ResizeObserver(handler) - observer.observe(scroll) - handler() - onCleanup(() => { - scroll.removeEventListener("scroll", handler) - observer.disconnect() - }) - }) - return ( -
+
{props.children}
) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 07b18f3146d..5c8efff3812 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -47,7 +47,7 @@ export function SessionSidePanel(props: { const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened())) - const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened()) + const reviewTab = createMemo(() => isDesktop()) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) @@ -202,133 +202,123 @@ export function SessionSidePanel(props: { >
- - - - -
- { - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - - -
-
{language.t("session.tab.review")}
- -
- {reviewCount()} -
-
-
-
-
- - - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
- - {(tab) => } - - - + + + + +
+ { + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} + > + + +
+
{language.t("session.tab.review")}
+ +
{reviewCount()}
+
+
+
+
+ + - dialog.show(() => ) - } - aria-label={language.t("command.file.open")} + class="h-5 w-5" + onClick={() => tabs().close("context")} + aria-label={language.t("common.closeTab")} /> - - -
-
- - - - {props.reviewPanel()} - + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
- - - -
-
- -
- {language.t("session.files.selectToOpen")} -
-
+ + {(tab) => } + + + + dialog.show(() => )} + aria-label={language.t("command.file.open")} + /> + + + +
+ + + + {props.reviewPanel()} + + + + + +
+
+ +
+ {language.t("session.files.selectToOpen")}
- - +
+
+
+
- - - -
- -
-
-
+ + + +
+ +
+
+
- - {(tab) => } - -
- - - {(tab) => { - const path = createMemo(() => file.pathFromTab(tab)) - return ( -
- {(p) => } -
- ) - }} -
-
-
- } - > - {props.reviewPanel()} - + + {(tab) => } + + + + + {(tab) => { + const path = createMemo(() => file.pathFromTab(tab)) + return ( +
+ {(p) => } +
+ ) + }} +
+
+
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 555761ad1ec..b704e460bc0 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -45,7 +45,9 @@ export const useSessionHashScroll = (input: { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const top = a.top - b.top + root.scrollTop + const sticky = root.querySelector("[data-session-title]") + const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 + const top = Math.max(0, a.top - b.top + root.scrollTop - inset) root.scrollTo({ top, behavior }) return true } diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts new file mode 100644 index 00000000000..1969d1afc27 --- /dev/null +++ b/packages/app/src/utils/server-errors.test.ts @@ -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") + }) +}) diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts new file mode 100644 index 00000000000..4b9727e61d8 --- /dev/null +++ b/packages/app/src/utils/server-errors.ts @@ -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 + 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") +} diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 904aeadd8e0..ad5813ced67 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,13 +1,13 @@ { "name": "@opencode-ai/console-app", - "version": "1.2.10", + "version": "1.2.15", "type": "module", "license": "MIT", "scripts": { "typecheck": "tsgo --noEmit", "dev": "vite dev --host 0.0.0.0", "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev", - "build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json", + "build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json", "start": "vite start" }, "dependencies": { diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 105546f6370..cda1e2a3637 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -243,6 +243,7 @@ export const dict = { "black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم", "black.hero.subtitle": "بما في ذلك Claude، GPT، Gemini والمزيد", "black.title": "OpenCode Black | الأسعار", + "black.paused": "التسجيل في خطة Black متوقف مؤقتًا.", "black.plan.icon20": "خطة Black 20", "black.plan.icon100": "خطة Black 100", "black.plan.icon200": "خطة Black 200", @@ -343,7 +344,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "كتابة الكاش", "workspace.usage.breakdown.output": "الخرج", "workspace.usage.breakdown.reasoning": "المنطق", - "workspace.usage.subscription": "الاشتراك (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "التكلفة", "workspace.cost.subtitle": "تكاليف الاستخدام مقسمة حسب النموذج.", @@ -352,6 +355,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(محذوف)", "workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.", "workspace.cost.subscriptionShort": "اشتراك", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "مفاتيح API", "workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.", @@ -479,6 +483,36 @@ export const dict = { "workspace.black.waitlist.enrolled": "مسجل", "workspace.black.waitlist.enrollNote": 'عند النقر فوق "تسجيل"، يبدأ اشتراكك على الفور وسيتم خصم الرسوم من بطاقتك.', + "workspace.lite.loading": "جارٍ التحميل...", + "workspace.lite.time.day": "يوم", + "workspace.lite.time.days": "أيام", + "workspace.lite.time.hour": "ساعة", + "workspace.lite.time.hours": "ساعات", + "workspace.lite.time.minute": "دقيقة", + "workspace.lite.time.minutes": "دقائق", + "workspace.lite.time.fewSeconds": "بضع ثوان", + "workspace.lite.subscription.title": "اشتراك Go", + "workspace.lite.subscription.message": "أنت مشترك في OpenCode Go.", + "workspace.lite.subscription.manage": "إدارة الاشتراك", + "workspace.lite.subscription.rollingUsage": "الاستخدام المتجدد", + "workspace.lite.subscription.weeklyUsage": "الاستخدام الأسبوعي", + "workspace.lite.subscription.monthlyUsage": "الاستخدام الشهري", + "workspace.lite.subscription.resetsIn": "إعادة تعيين في", + "workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام", + "workspace.lite.subscription.selectProvider": + 'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.', + "workspace.lite.other.title": "اشتراك Go", + "workspace.lite.other.message": + "عضو آخر في مساحة العمل هذه مشترك بالفعل في OpenCode Go. يمكن لعضو واحد فقط لكل مساحة عمل الاشتراك.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go هو اشتراك بسعر $10 شهريًا يوفر وصولاً موثوقًا إلى نماذج البرمجة المفتوحة الشائعة مع حدود استخدام سخية.", + "workspace.lite.promo.modelsTitle": "ما يتضمنه", + "workspace.lite.promo.footer": + "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.", + "workspace.lite.promo.subscribe": "الاشتراك في Go", + "workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...", + "download.title": "OpenCode | تنزيل", "download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux", "download.hero.title": "تنزيل OpenCode", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 8f94a1f6b81..ddeb0c10a94 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -247,6 +247,7 @@ export const dict = { "black.hero.title": "Acesse os melhores modelos de codificação do mundo", "black.hero.subtitle": "Incluindo Claude, GPT, Gemini e mais", "black.title": "OpenCode Black | Preços", + "black.paused": "A inscrição no plano Black está temporariamente pausada.", "black.plan.icon20": "Plano Black 20", "black.plan.icon100": "Plano Black 100", "black.plan.icon200": "Plano Black 200", @@ -348,7 +349,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Escrita em Cache", "workspace.usage.breakdown.output": "Saída", "workspace.usage.breakdown.reasoning": "Raciocínio", - "workspace.usage.subscription": "assinatura (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Custo", "workspace.cost.subtitle": "Custos de uso discriminados por modelo.", @@ -357,6 +360,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(excluído)", "workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.", "workspace.cost.subscriptionShort": "ass", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "Chaves de API", "workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.", @@ -485,6 +489,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Ao clicar em Inscrever-se, sua assinatura começará imediatamente e seu cartão será cobrado.", + "workspace.lite.loading": "Carregando...", + "workspace.lite.time.day": "dia", + "workspace.lite.time.days": "dias", + "workspace.lite.time.hour": "hora", + "workspace.lite.time.hours": "horas", + "workspace.lite.time.minute": "minuto", + "workspace.lite.time.minutes": "minutos", + "workspace.lite.time.fewSeconds": "alguns segundos", + "workspace.lite.subscription.title": "Assinatura Go", + "workspace.lite.subscription.message": "Você assina o OpenCode Go.", + "workspace.lite.subscription.manage": "Gerenciar Assinatura", + "workspace.lite.subscription.rollingUsage": "Uso Contínuo", + "workspace.lite.subscription.weeklyUsage": "Uso Semanal", + "workspace.lite.subscription.monthlyUsage": "Uso Mensal", + "workspace.lite.subscription.resetsIn": "Reinicia em", + "workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso", + "workspace.lite.subscription.selectProvider": + 'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.', + "workspace.lite.other.title": "Assinatura Go", + "workspace.lite.other.message": + "Outro membro neste workspace já assina o OpenCode Go. Apenas um membro por workspace pode assinar.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "O OpenCode Go é uma assinatura de $10 por mês que fornece acesso confiável a modelos abertos de codificação populares com limites de uso generosos.", + "workspace.lite.promo.modelsTitle": "O que está incluído", + "workspace.lite.promo.footer": + "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.", + "workspace.lite.promo.subscribe": "Assinar Go", + "workspace.lite.promo.subscribing": "Redirecionando...", + "download.title": "OpenCode | Baixar", "download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux", "download.hero.title": "Baixar OpenCode", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index cd887bf27b9..18b2b89ff95 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -245,6 +245,7 @@ export const dict = { "black.hero.title": "Få adgang til verdens bedste kodningsmodeller", "black.hero.subtitle": "Inklusive Claude, GPT, Gemini og mere", "black.title": "OpenCode Black | Priser", + "black.paused": "Black-plantilmelding er midlertidigt sat på pause.", "black.plan.icon20": "Black 20-plan", "black.plan.icon100": "Black 100-plan", "black.plan.icon200": "Black 200-plan", @@ -346,7 +347,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Cache skriv", "workspace.usage.breakdown.output": "Output", "workspace.usage.breakdown.reasoning": "Ræsonnement", - "workspace.usage.subscription": "abonnement (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Omkostninger", "workspace.cost.subtitle": "Brugsomkostninger opdelt efter model.", @@ -355,6 +358,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(slettet)", "workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.", "workspace.cost.subscriptionShort": "sub", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API-nøgler", "workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.", @@ -483,6 +487,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Når du klikker på Tilmeld, starter dit abonnement med det samme, og dit kort vil blive debiteret.", + "workspace.lite.loading": "Indlæser...", + "workspace.lite.time.day": "dag", + "workspace.lite.time.days": "dage", + "workspace.lite.time.hour": "time", + "workspace.lite.time.hours": "timer", + "workspace.lite.time.minute": "minut", + "workspace.lite.time.minutes": "minutter", + "workspace.lite.time.fewSeconds": "et par sekunder", + "workspace.lite.subscription.title": "Go-abonnement", + "workspace.lite.subscription.message": "Du abonnerer på OpenCode Go.", + "workspace.lite.subscription.manage": "Administrer abonnement", + "workspace.lite.subscription.rollingUsage": "Løbende forbrug", + "workspace.lite.subscription.weeklyUsage": "Ugentligt forbrug", + "workspace.lite.subscription.monthlyUsage": "Månedligt forbrug", + "workspace.lite.subscription.resetsIn": "Nulstiller i", + "workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne", + "workspace.lite.subscription.selectProvider": + 'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.', + "workspace.lite.other.title": "Go-abonnement", + "workspace.lite.other.message": + "Et andet medlem i dette workspace abonnerer allerede på OpenCode Go. Kun ét medlem pr. workspace kan abonnere.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go er et abonnement til $10 om måneden, der giver pålidelig adgang til populære åbne kodningsmodeller med generøse forbrugsgrænser.", + "workspace.lite.promo.modelsTitle": "Hvad er inkluderet", + "workspace.lite.promo.footer": + "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.", + "workspace.lite.promo.subscribe": "Abonner på Go", + "workspace.lite.promo.subscribing": "Omdirigerer...", + "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode til macOS, Windows og Linux", "download.hero.title": "Download OpenCode", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index bd711cd023b..5bee74aed39 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -247,6 +247,7 @@ export const dict = { "black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle", "black.hero.subtitle": "Einschließlich Claude, GPT, Gemini und mehr", "black.title": "OpenCode Black | Preise", + "black.paused": "Die Anmeldung zum Black-Plan ist vorübergehend pausiert.", "black.plan.icon20": "Black 20 Plan", "black.plan.icon100": "Black 100 Plan", "black.plan.icon200": "Black 200 Plan", @@ -348,7 +349,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Cache Write", "workspace.usage.breakdown.output": "Output", "workspace.usage.breakdown.reasoning": "Reasoning", - "workspace.usage.subscription": "Abonnement (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Kosten", "workspace.cost.subtitle": "Nutzungskosten aufgeschlüsselt nach Modell.", @@ -357,6 +360,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(gelöscht)", "workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.", "workspace.cost.subscriptionShort": "Abo", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Keys", "workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.", @@ -485,6 +489,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Wenn du auf Einschreiben klickst, startet dein Abo sofort und deine Karte wird belastet.", + "workspace.lite.loading": "Lade...", + "workspace.lite.time.day": "Tag", + "workspace.lite.time.days": "Tage", + "workspace.lite.time.hour": "Stunde", + "workspace.lite.time.hours": "Stunden", + "workspace.lite.time.minute": "Minute", + "workspace.lite.time.minutes": "Minuten", + "workspace.lite.time.fewSeconds": "einige Sekunden", + "workspace.lite.subscription.title": "Go-Abonnement", + "workspace.lite.subscription.message": "Du hast OpenCode Go abonniert.", + "workspace.lite.subscription.manage": "Abo verwalten", + "workspace.lite.subscription.rollingUsage": "Fortlaufende Nutzung", + "workspace.lite.subscription.weeklyUsage": "Wöchentliche Nutzung", + "workspace.lite.subscription.monthlyUsage": "Monatliche Nutzung", + "workspace.lite.subscription.resetsIn": "Setzt zurück in", + "workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind", + "workspace.lite.subscription.selectProvider": + 'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.', + "workspace.lite.other.title": "Go-Abonnement", + "workspace.lite.other.message": + "Ein anderes Mitglied in diesem Workspace hat OpenCode Go bereits abonniert. Nur ein Mitglied pro Workspace kann abonnieren.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go ist ein Abonnement für $10 pro Monat, das zuverlässigen Zugriff auf beliebte offene Coding-Modelle mit großzügigen Nutzungslimits bietet.", + "workspace.lite.promo.modelsTitle": "Was enthalten ist", + "workspace.lite.promo.footer": + "Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.", + "workspace.lite.promo.subscribe": "Go abonnieren", + "workspace.lite.promo.subscribing": "Leite weiter...", + "download.title": "OpenCode | Download", "download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter", "download.hero.title": "OpenCode herunterladen", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 55551de6455..d6db2e7f8af 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -239,6 +239,7 @@ export const dict = { "black.hero.title": "Access all the world's best coding models", "black.hero.subtitle": "Including Claude, GPT, Gemini and more", "black.title": "OpenCode Black | Pricing", + "black.paused": "Black plan enrollment is temporarily paused.", "black.plan.icon20": "Black 20 plan", "black.plan.icon100": "Black 100 plan", "black.plan.icon200": "Black 200 plan", @@ -340,7 +341,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Cache Write", "workspace.usage.breakdown.output": "Output", "workspace.usage.breakdown.reasoning": "Reasoning", - "workspace.usage.subscription": "subscription (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Cost", "workspace.cost.subtitle": "Usage costs broken down by model.", @@ -349,6 +352,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(deleted)", "workspace.cost.empty": "No usage data available for the selected period.", "workspace.cost.subscriptionShort": "sub", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Keys", "workspace.keys.subtitle": "Manage your API keys for accessing opencode services.", @@ -477,6 +481,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "When you click Enroll, your subscription starts immediately and your card will be charged.", + "workspace.lite.loading": "Loading...", + "workspace.lite.time.day": "day", + "workspace.lite.time.days": "days", + "workspace.lite.time.hour": "hour", + "workspace.lite.time.hours": "hours", + "workspace.lite.time.minute": "minute", + "workspace.lite.time.minutes": "minutes", + "workspace.lite.time.fewSeconds": "a few seconds", + "workspace.lite.subscription.title": "Go Subscription", + "workspace.lite.subscription.message": "You are subscribed to OpenCode Go.", + "workspace.lite.subscription.manage": "Manage Subscription", + "workspace.lite.subscription.rollingUsage": "Rolling Usage", + "workspace.lite.subscription.weeklyUsage": "Weekly Usage", + "workspace.lite.subscription.monthlyUsage": "Monthly Usage", + "workspace.lite.subscription.resetsIn": "Resets in", + "workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits", + "workspace.lite.subscription.selectProvider": + 'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.', + "workspace.lite.other.title": "Go Subscription", + "workspace.lite.other.message": + "Another member in this workspace is already subscribed to OpenCode Go. Only one member per workspace can subscribe.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models with generous usage limits.", + "workspace.lite.promo.modelsTitle": "What's Included", + "workspace.lite.promo.footer": + "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.", + "workspace.lite.promo.subscribe": "Subscribe to Go", + "workspace.lite.promo.subscribing": "Redirecting...", + "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode for macOS, Windows, and Linux", "download.hero.title": "Download OpenCode", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index aafe8aa00c1..c4676fe6e9a 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -248,6 +248,7 @@ export const dict = { "black.hero.title": "Accede a los mejores modelos de codificación del mundo", "black.hero.subtitle": "Incluyendo Claude, GPT, Gemini y más", "black.title": "OpenCode Black | Precios", + "black.paused": "La inscripción al plan Black está temporalmente pausada.", "black.plan.icon20": "Plan Black 20", "black.plan.icon100": "Plan Black 100", "black.plan.icon200": "Plan Black 200", @@ -349,7 +350,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Escritura de Caché", "workspace.usage.breakdown.output": "Salida", "workspace.usage.breakdown.reasoning": "Razonamiento", - "workspace.usage.subscription": "suscripción (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Costo", "workspace.cost.subtitle": "Costos de uso desglosados por modelo.", @@ -358,6 +361,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(eliminado)", "workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.", "workspace.cost.subscriptionShort": "sub", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "Claves API", "workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.", @@ -486,6 +490,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Cuando haces clic en Inscribirse, tu suscripción comienza inmediatamente y se cargará a tu tarjeta.", + "workspace.lite.loading": "Cargando...", + "workspace.lite.time.day": "día", + "workspace.lite.time.days": "días", + "workspace.lite.time.hour": "hora", + "workspace.lite.time.hours": "horas", + "workspace.lite.time.minute": "minuto", + "workspace.lite.time.minutes": "minutos", + "workspace.lite.time.fewSeconds": "unos pocos segundos", + "workspace.lite.subscription.title": "Suscripción Go", + "workspace.lite.subscription.message": "Estás suscrito a OpenCode Go.", + "workspace.lite.subscription.manage": "Gestionar Suscripción", + "workspace.lite.subscription.rollingUsage": "Uso Continuo", + "workspace.lite.subscription.weeklyUsage": "Uso Semanal", + "workspace.lite.subscription.monthlyUsage": "Uso Mensual", + "workspace.lite.subscription.resetsIn": "Se reinicia en", + "workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso", + "workspace.lite.subscription.selectProvider": + 'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.', + "workspace.lite.other.title": "Suscripción Go", + "workspace.lite.other.message": + "Otro miembro de este espacio de trabajo ya está suscrito a OpenCode Go. Solo un miembro por espacio de trabajo puede suscribirse.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go es una suscripción de $10 al mes que proporciona acceso confiable a modelos de codificación abiertos populares con generosos límites de uso.", + "workspace.lite.promo.modelsTitle": "Qué incluye", + "workspace.lite.promo.footer": + "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.", + "workspace.lite.promo.subscribe": "Suscribirse a Go", + "workspace.lite.promo.subscribing": "Redirigiendo...", + "download.title": "OpenCode | Descargar", "download.meta.description": "Descarga OpenCode para macOS, Windows y Linux", "download.hero.title": "Descargar OpenCode", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index a915fde3b29..7b2306bc2f6 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -251,6 +251,7 @@ export const dict = { "black.hero.title": "Accédez aux meilleurs modèles de code au monde", "black.hero.subtitle": "Y compris Claude, GPT, Gemini et plus", "black.title": "OpenCode Black | Tarification", + "black.paused": "L'inscription au plan Black est temporairement suspendue.", "black.plan.icon20": "Forfait Black 20", "black.plan.icon100": "Forfait Black 100", "black.plan.icon200": "Forfait Black 200", @@ -354,7 +355,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Écriture cache", "workspace.usage.breakdown.output": "Sortie", "workspace.usage.breakdown.reasoning": "Raisonnement", - "workspace.usage.subscription": "abonnement ({{amount}} $)", + "workspace.usage.subscription": "Black ({{amount}} $)", + "workspace.usage.lite": "Go ({{amount}} $)", + "workspace.usage.byok": "BYOK ({{amount}} $)", "workspace.cost.title": "Coût", "workspace.cost.subtitle": "Coûts d'utilisation répartis par modèle.", @@ -363,6 +366,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(supprimé)", "workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.", "workspace.cost.subscriptionShort": "abo", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "Clés API", "workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.", @@ -494,6 +498,37 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Lorsque vous cliquez sur S'inscrire, votre abonnement démarre immédiatement et votre carte sera débitée.", + "workspace.lite.loading": "Chargement...", + "workspace.lite.time.day": "jour", + "workspace.lite.time.days": "jours", + "workspace.lite.time.hour": "heure", + "workspace.lite.time.hours": "heures", + "workspace.lite.time.minute": "minute", + "workspace.lite.time.minutes": "minutes", + "workspace.lite.time.fewSeconds": "quelques secondes", + "workspace.lite.subscription.title": "Abonnement Go", + "workspace.lite.subscription.message": "Vous êtes abonné à OpenCode Go.", + "workspace.lite.subscription.manage": "Gérer l'abonnement", + "workspace.lite.subscription.rollingUsage": "Utilisation glissante", + "workspace.lite.subscription.weeklyUsage": "Utilisation hebdomadaire", + "workspace.lite.subscription.monthlyUsage": "Utilisation mensuelle", + "workspace.lite.subscription.resetsIn": "Réinitialisation dans", + "workspace.lite.subscription.useBalance": + "Utilisez votre solde disponible après avoir atteint les limites d'utilisation", + "workspace.lite.subscription.selectProvider": + 'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.', + "workspace.lite.other.title": "Abonnement Go", + "workspace.lite.other.message": + "Un autre membre de cet espace de travail est déjà abonné à OpenCode Go. Un seul membre par espace de travail peut s'abonner.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go est un abonnement à 10 $ par mois qui offre un accès fiable aux modèles de codage ouverts populaires avec des limites d'utilisation généreuses.", + "workspace.lite.promo.modelsTitle": "Ce qui est inclus", + "workspace.lite.promo.footer": + "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.", + "workspace.lite.promo.subscribe": "S'abonner à Go", + "workspace.lite.promo.subscribing": "Redirection...", + "download.title": "OpenCode | Téléchargement", "download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux", "download.hero.title": "Télécharger OpenCode", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 4ffc728c861..81dd293057f 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -246,6 +246,7 @@ export const dict = { "black.hero.title": "Accedi ai migliori modelli di coding al mondo", "black.hero.subtitle": "Inclusi Claude, GPT, Gemini e altri", "black.title": "OpenCode Black | Prezzi", + "black.paused": "L'iscrizione al piano Black è temporaneamente sospesa.", "black.plan.icon20": "Piano Black 20", "black.plan.icon100": "Piano Black 100", "black.plan.icon200": "Piano Black 200", @@ -348,7 +349,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Scrittura Cache", "workspace.usage.breakdown.output": "Output", "workspace.usage.breakdown.reasoning": "Reasoning", - "workspace.usage.subscription": "abbonamento (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Costo", "workspace.cost.subtitle": "Costi di utilizzo suddivisi per modello.", @@ -357,6 +360,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(eliminato)", "workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.", "workspace.cost.subscriptionShort": "sub", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "Chiavi API", "workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.", @@ -485,6 +489,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Quando clicchi su Iscriviti, il tuo abbonamento inizia immediatamente e la tua carta verrà addebitata.", + "workspace.lite.loading": "Caricamento...", + "workspace.lite.time.day": "giorno", + "workspace.lite.time.days": "giorni", + "workspace.lite.time.hour": "ora", + "workspace.lite.time.hours": "ore", + "workspace.lite.time.minute": "minuto", + "workspace.lite.time.minutes": "minuti", + "workspace.lite.time.fewSeconds": "pochi secondi", + "workspace.lite.subscription.title": "Abbonamento Go", + "workspace.lite.subscription.message": "Sei abbonato a OpenCode Go.", + "workspace.lite.subscription.manage": "Gestisci Abbonamento", + "workspace.lite.subscription.rollingUsage": "Utilizzo Continuativo", + "workspace.lite.subscription.weeklyUsage": "Utilizzo Settimanale", + "workspace.lite.subscription.monthlyUsage": "Utilizzo Mensile", + "workspace.lite.subscription.resetsIn": "Si resetta tra", + "workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo", + "workspace.lite.subscription.selectProvider": + 'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.', + "workspace.lite.other.title": "Abbonamento Go", + "workspace.lite.other.message": + "Un altro membro in questo workspace è già abbonato a OpenCode Go. Solo un membro per workspace può abbonarsi.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go è un abbonamento a $10 al mese che fornisce un accesso affidabile a popolari modelli di coding aperti con generosi limiti di utilizzo.", + "workspace.lite.promo.modelsTitle": "Cosa è incluso", + "workspace.lite.promo.footer": + "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.", + "workspace.lite.promo.subscribe": "Abbonati a Go", + "workspace.lite.promo.subscribing": "Reindirizzamento...", + "download.title": "OpenCode | Download", "download.meta.description": "Scarica OpenCode per macOS, Windows e Linux", "download.hero.title": "Scarica OpenCode", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 8ecc5d58b7f..afcf6aeb9b3 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -244,6 +244,7 @@ export const dict = { "black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス", "black.hero.subtitle": "Claude、GPT、Gemini などを含む", "black.title": "OpenCode Black | 料金", + "black.paused": "Blackプランの登録は一時的に停止しています。", "black.plan.icon20": "Black 20 プラン", "black.plan.icon100": "Black 100 プラン", "black.plan.icon200": "Black 200 プラン", @@ -345,7 +346,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "キャッシュ書き込み", "workspace.usage.breakdown.output": "出力", "workspace.usage.breakdown.reasoning": "推論", - "workspace.usage.subscription": "サブスクリプション (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "コスト", "workspace.cost.subtitle": "モデルごとの使用料金の内訳。", @@ -354,6 +357,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(削除済み)", "workspace.cost.empty": "選択した期間の使用状況データはありません。", "workspace.cost.subscriptionShort": "サブ", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "APIキー", "workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。", @@ -483,6 +487,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "「登録する」をクリックすると、サブスクリプションがすぐに開始され、カードに請求されます。", + "workspace.lite.loading": "読み込み中...", + "workspace.lite.time.day": "日", + "workspace.lite.time.days": "日", + "workspace.lite.time.hour": "時間", + "workspace.lite.time.hours": "時間", + "workspace.lite.time.minute": "分", + "workspace.lite.time.minutes": "分", + "workspace.lite.time.fewSeconds": "数秒", + "workspace.lite.subscription.title": "Goサブスクリプション", + "workspace.lite.subscription.message": "あなたは OpenCode Go を購読しています。", + "workspace.lite.subscription.manage": "サブスクリプションの管理", + "workspace.lite.subscription.rollingUsage": "ローリング利用量", + "workspace.lite.subscription.weeklyUsage": "週間利用量", + "workspace.lite.subscription.monthlyUsage": "月間利用量", + "workspace.lite.subscription.resetsIn": "リセットまで", + "workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する", + "workspace.lite.subscription.selectProvider": + "Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。", + "workspace.lite.other.title": "Goサブスクリプション", + "workspace.lite.other.message": + "このワークスペースの別のメンバーが既に OpenCode Go を購読しています。ワークスペースにつき1人のメンバーのみが購読できます。", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Goは月額$10のサブスクリプションプランで、人気のオープンコーディングモデルへの安定したアクセスを十分な利用枠で提供します。", + "workspace.lite.promo.modelsTitle": "含まれるもの", + "workspace.lite.promo.footer": + "このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。", + "workspace.lite.promo.subscribe": "Goを購読する", + "workspace.lite.promo.subscribing": "リダイレクト中...", + "download.title": "OpenCode | ダウンロード", "download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード", "download.hero.title": "OpenCode をダウンロード", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 9692ef47a30..b2375c3f7d8 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -241,6 +241,7 @@ export const dict = { "black.hero.title": "세계 최고의 코딩 모델에 액세스하세요", "black.hero.subtitle": "Claude, GPT, Gemini 등 포함", "black.title": "OpenCode Black | 가격", + "black.paused": "Black 플랜 등록이 일시적으로 중단되었습니다.", "black.plan.icon20": "Black 20 플랜", "black.plan.icon100": "Black 100 플랜", "black.plan.icon200": "Black 200 플랜", @@ -342,7 +343,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "캐시 쓰기", "workspace.usage.breakdown.output": "출력", "workspace.usage.breakdown.reasoning": "추론", - "workspace.usage.subscription": "구독 (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "비용", "workspace.cost.subtitle": "모델별 사용 비용 내역.", @@ -351,6 +354,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(삭제됨)", "workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.", "workspace.cost.subscriptionShort": "구독", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API 키", "workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.", @@ -478,6 +482,36 @@ export const dict = { "workspace.black.waitlist.enrolled": "등록됨", "workspace.black.waitlist.enrollNote": "등록을 클릭하면 구독이 즉시 시작되며 카드에 요금이 청구됩니다.", + "workspace.lite.loading": "로드 중...", + "workspace.lite.time.day": "일", + "workspace.lite.time.days": "일", + "workspace.lite.time.hour": "시간", + "workspace.lite.time.hours": "시간", + "workspace.lite.time.minute": "분", + "workspace.lite.time.minutes": "분", + "workspace.lite.time.fewSeconds": "몇 초", + "workspace.lite.subscription.title": "Go 구독", + "workspace.lite.subscription.message": "현재 OpenCode Go를 구독 중입니다.", + "workspace.lite.subscription.manage": "구독 관리", + "workspace.lite.subscription.rollingUsage": "롤링 사용량", + "workspace.lite.subscription.weeklyUsage": "주간 사용량", + "workspace.lite.subscription.monthlyUsage": "월간 사용량", + "workspace.lite.subscription.resetsIn": "초기화까지 남은 시간:", + "workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용", + "workspace.lite.subscription.selectProvider": + 'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.', + "workspace.lite.other.title": "Go 구독", + "workspace.lite.other.message": + "이 워크스페이스의 다른 멤버가 이미 OpenCode Go를 구독 중입니다. 워크스페이스당 한 명의 멤버만 구독할 수 있습니다.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go는 넉넉한 사용 한도와 함께 인기 있는 오픈 코딩 모델에 대한 안정적인 액세스를 제공하는 월 $10의 구독입니다.", + "workspace.lite.promo.modelsTitle": "포함 내역", + "workspace.lite.promo.footer": + "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.", + "workspace.lite.promo.subscribe": "Go 구독하기", + "workspace.lite.promo.subscribing": "리디렉션 중...", + "download.title": "OpenCode | 다운로드", "download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드", "download.hero.title": "OpenCode 다운로드", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index c1729b83c0a..41bacfb053f 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -245,6 +245,7 @@ export const dict = { "black.hero.title": "Få tilgang til verdens beste kodemodeller", "black.hero.subtitle": "Inkludert Claude, GPT, Gemini og mer", "black.title": "OpenCode Black | Priser", + "black.paused": "Black-planregistrering er midlertidig satt på pause.", "black.plan.icon20": "Black 20-plan", "black.plan.icon100": "Black 100-plan", "black.plan.icon200": "Black 200-plan", @@ -346,7 +347,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Cache Skrevet", "workspace.usage.breakdown.output": "Output", "workspace.usage.breakdown.reasoning": "Resonnering", - "workspace.usage.subscription": "abonnement (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Kostnad", "workspace.cost.subtitle": "Brukskostnader fordelt på modell.", @@ -355,6 +358,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(slettet)", "workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.", "workspace.cost.subscriptionShort": "sub", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API-nøkler", "workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.", @@ -483,6 +487,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Når du klikker på Meld på, starter abonnementet umiddelbart og kortet ditt belastes.", + "workspace.lite.loading": "Laster...", + "workspace.lite.time.day": "dag", + "workspace.lite.time.days": "dager", + "workspace.lite.time.hour": "time", + "workspace.lite.time.hours": "timer", + "workspace.lite.time.minute": "minutt", + "workspace.lite.time.minutes": "minutter", + "workspace.lite.time.fewSeconds": "noen få sekunder", + "workspace.lite.subscription.title": "Go-abonnement", + "workspace.lite.subscription.message": "Du abonnerer på OpenCode Go.", + "workspace.lite.subscription.manage": "Administrer abonnement", + "workspace.lite.subscription.rollingUsage": "Løpende bruk", + "workspace.lite.subscription.weeklyUsage": "Ukentlig bruk", + "workspace.lite.subscription.monthlyUsage": "Månedlig bruk", + "workspace.lite.subscription.resetsIn": "Nullstilles om", + "workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene", + "workspace.lite.subscription.selectProvider": + 'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.', + "workspace.lite.other.title": "Go-abonnement", + "workspace.lite.other.message": + "Et annet medlem i dette arbeidsområdet abonnerer allerede på OpenCode Go. Kun ett medlem per arbeidsområde kan abonnere.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go er et abonnement til $10 per måned som gir pålitelig tilgang til populære åpne kodemodeller med rause bruksgrenser.", + "workspace.lite.promo.modelsTitle": "Hva som er inkludert", + "workspace.lite.promo.footer": + "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.", + "workspace.lite.promo.subscribe": "Abonner på Go", + "workspace.lite.promo.subscribing": "Omdirigerer...", + "download.title": "OpenCode | Last ned", "download.meta.description": "Last ned OpenCode for macOS, Windows og Linux", "download.hero.title": "Last ned OpenCode", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 0bcc7e735da..dc3c59196d5 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -246,6 +246,7 @@ export const dict = { "black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących", "black.hero.subtitle": "W tym Claude, GPT, Gemini i inne", "black.title": "OpenCode Black | Cennik", + "black.paused": "Rejestracja planu Black jest tymczasowo wstrzymana.", "black.plan.icon20": "Plan Black 20", "black.plan.icon100": "Plan Black 100", "black.plan.icon200": "Plan Black 200", @@ -347,7 +348,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Zapis Cache", "workspace.usage.breakdown.output": "Wyjście", "workspace.usage.breakdown.reasoning": "Rozumowanie", - "workspace.usage.subscription": "subskrypcja (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Koszt", "workspace.cost.subtitle": "Koszty użycia w podziale na modele.", @@ -356,6 +359,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(usunięte)", "workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.", "workspace.cost.subscriptionShort": "sub", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "Klucze API", "workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.", @@ -484,6 +488,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Po kliknięciu Zapisz się, Twoja subskrypcja rozpocznie się natychmiast, a karta zostanie obciążona.", + "workspace.lite.loading": "Ładowanie...", + "workspace.lite.time.day": "dzień", + "workspace.lite.time.days": "dni", + "workspace.lite.time.hour": "godzina", + "workspace.lite.time.hours": "godzin(y)", + "workspace.lite.time.minute": "minuta", + "workspace.lite.time.minutes": "minut(y)", + "workspace.lite.time.fewSeconds": "kilka sekund", + "workspace.lite.subscription.title": "Subskrypcja Go", + "workspace.lite.subscription.message": "Subskrybujesz OpenCode Go.", + "workspace.lite.subscription.manage": "Zarządzaj subskrypcją", + "workspace.lite.subscription.rollingUsage": "Użycie kroczące", + "workspace.lite.subscription.weeklyUsage": "Użycie tygodniowe", + "workspace.lite.subscription.monthlyUsage": "Użycie miesięczne", + "workspace.lite.subscription.resetsIn": "Resetuje się za", + "workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia", + "workspace.lite.subscription.selectProvider": + 'Wybierz "OpenCode Go" jako dostawcę w konfiguracji opencode, aby używać modeli Go.', + "workspace.lite.other.title": "Subskrypcja Go", + "workspace.lite.other.message": + "Inny członek tego obszaru roboczego już subskrybuje OpenCode Go. Tylko jeden członek na obszar roboczy może subskrybować.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go to subskrypcja za $10 miesięcznie, która zapewnia niezawodny dostęp do popularnych otwartych modeli do kodowania z hojnymi limitami użycia.", + "workspace.lite.promo.modelsTitle": "Co zawiera", + "workspace.lite.promo.footer": + "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.", + "workspace.lite.promo.subscribe": "Subskrybuj Go", + "workspace.lite.promo.subscribing": "Przekierowywanie...", + "download.title": "OpenCode | Pobierz", "download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux", "download.hero.title": "Pobierz OpenCode", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 489125a5833..21b89dc94e0 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -249,6 +249,7 @@ export const dict = { "black.hero.title": "Доступ к лучшим моделям для кодинга в мире", "black.hero.subtitle": "Включая Claude, GPT, Gemini и другие", "black.title": "OpenCode Black | Цены", + "black.paused": "Регистрация на план Black временно приостановлена.", "black.plan.icon20": "План Black 20", "black.plan.icon100": "План Black 100", "black.plan.icon200": "План Black 200", @@ -352,7 +353,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Запись кэша", "workspace.usage.breakdown.output": "Выход", "workspace.usage.breakdown.reasoning": "Reasoning (рассуждения)", - "workspace.usage.subscription": "подписка (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Расходы", "workspace.cost.subtitle": "Расходы на использование с разбивкой по моделям.", @@ -361,6 +364,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(удалено)", "workspace.cost.empty": "Нет данных об использовании за выбранный период.", "workspace.cost.subscriptionShort": "подписка", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Ключи", "workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.", @@ -489,6 +493,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Когда вы нажмете Подключиться, ваша подписка начнется немедленно, и с карты будет списана оплата.", + "workspace.lite.loading": "Загрузка...", + "workspace.lite.time.day": "день", + "workspace.lite.time.days": "дней", + "workspace.lite.time.hour": "час", + "workspace.lite.time.hours": "часов", + "workspace.lite.time.minute": "минута", + "workspace.lite.time.minutes": "минут", + "workspace.lite.time.fewSeconds": "несколько секунд", + "workspace.lite.subscription.title": "Подписка Go", + "workspace.lite.subscription.message": "Вы подписаны на OpenCode Go.", + "workspace.lite.subscription.manage": "Управление подпиской", + "workspace.lite.subscription.rollingUsage": "Скользящее использование", + "workspace.lite.subscription.weeklyUsage": "Недельное использование", + "workspace.lite.subscription.monthlyUsage": "Ежемесячное использование", + "workspace.lite.subscription.resetsIn": "Сброс через", + "workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов", + "workspace.lite.subscription.selectProvider": + 'Выберите "OpenCode Go" в качестве провайдера в настройках opencode для использования моделей Go.', + "workspace.lite.other.title": "Подписка Go", + "workspace.lite.other.message": + "Другой участник в этом рабочем пространстве уже подписан на OpenCode Go. Только один участник в рабочем пространстве может оформить подписку.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go — это подписка за $10 в месяц, которая предоставляет надежный доступ к популярным открытым моделям для кодинга с щедрыми лимитами использования.", + "workspace.lite.promo.modelsTitle": "Что включено", + "workspace.lite.promo.footer": + "План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.", + "workspace.lite.promo.subscribe": "Подписаться на Go", + "workspace.lite.promo.subscribing": "Перенаправление...", + "download.title": "OpenCode | Скачать", "download.meta.description": "Скачать OpenCode для macOS, Windows и Linux", "download.hero.title": "Скачать OpenCode", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index cbd534c96f9..0646483544f 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -244,6 +244,7 @@ export const dict = { "black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก", "black.hero.subtitle": "รวมถึง Claude, GPT, Gemini และอื่นๆ อีกมากมาย", "black.title": "OpenCode Black | ราคา", + "black.paused": "การสมัครแผน Black หยุดชั่วคราว", "black.plan.icon20": "แผน Black 20", "black.plan.icon100": "แผน Black 100", "black.plan.icon200": "แผน Black 200", @@ -345,7 +346,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Cache Write", "workspace.usage.breakdown.output": "Output", "workspace.usage.breakdown.reasoning": "Reasoning", - "workspace.usage.subscription": "สมัครสมาชิก (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "ค่าใช้จ่าย", "workspace.cost.subtitle": "ต้นทุนการใช้งานแยกตามโมเดล", @@ -354,6 +357,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(ลบแล้ว)", "workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก", "workspace.cost.subscriptionShort": "sub", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Keys", "workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode", @@ -482,6 +486,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "เมื่อคุณคลิกลงทะเบียน การสมัครสมาชิกของคุณจะเริ่มต้นทันทีและบัตรของคุณจะถูกเรียกเก็บเงิน", + "workspace.lite.loading": "กำลังโหลด...", + "workspace.lite.time.day": "วัน", + "workspace.lite.time.days": "วัน", + "workspace.lite.time.hour": "ชั่วโมง", + "workspace.lite.time.hours": "ชั่วโมง", + "workspace.lite.time.minute": "นาที", + "workspace.lite.time.minutes": "นาที", + "workspace.lite.time.fewSeconds": "ไม่กี่วินาที", + "workspace.lite.subscription.title": "การสมัครสมาชิก Go", + "workspace.lite.subscription.message": "คุณได้สมัครสมาชิก OpenCode Go แล้ว", + "workspace.lite.subscription.manage": "จัดการการสมัครสมาชิก", + "workspace.lite.subscription.rollingUsage": "การใช้งานแบบหมุนเวียน", + "workspace.lite.subscription.weeklyUsage": "การใช้งานรายสัปดาห์", + "workspace.lite.subscription.monthlyUsage": "การใช้งานรายเดือน", + "workspace.lite.subscription.resetsIn": "รีเซ็ตใน", + "workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน", + "workspace.lite.subscription.selectProvider": + 'เลือก "OpenCode Go" เป็นผู้ให้บริการในการตั้งค่า opencode ของคุณเพื่อใช้โมเดล Go', + "workspace.lite.other.title": "การสมัครสมาชิก Go", + "workspace.lite.other.message": + "สมาชิกคนอื่นใน Workspace นี้ได้สมัคร OpenCode Go แล้ว สามารถสมัครได้เพียงหนึ่งคนต่อหนึ่ง Workspace เท่านั้น", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go เป็นการสมัครสมาชิกราคา 10 ดอลลาร์ต่อเดือน ที่ให้การเข้าถึงโมเดลโอเพนโค้ดดิงยอดนิยมได้อย่างเสถียร ด้วยขีดจำกัดการใช้งานที่ครอบคลุม", + "workspace.lite.promo.modelsTitle": "สิ่งที่รวมอยู่ด้วย", + "workspace.lite.promo.footer": + "แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ", + "workspace.lite.promo.subscribe": "สมัครสมาชิก Go", + "workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...", + "download.title": "OpenCode | ดาวน์โหลด", "download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux", "download.hero.title": "ดาวน์โหลด OpenCode", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 4a333ccda8a..d94dd15d0df 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -247,6 +247,7 @@ export const dict = { "black.hero.title": "Dünyanın en iyi kodlama modellerine erişin", "black.hero.subtitle": "Claude, GPT, Gemini ve daha fazlası dahil", "black.title": "OpenCode Black | Fiyatlandırma", + "black.paused": "Black plan kaydı geçici olarak duraklatıldı.", "black.plan.icon20": "Black 20 planı", "black.plan.icon100": "Black 100 planı", "black.plan.icon200": "Black 200 planı", @@ -348,7 +349,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "Önbellek Yazma", "workspace.usage.breakdown.output": "Çıkış", "workspace.usage.breakdown.reasoning": "Muhakeme", - "workspace.usage.subscription": "abonelik (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "Maliyet", "workspace.cost.subtitle": "Modele göre ayrılmış kullanım maliyetleri.", @@ -357,6 +360,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(silindi)", "workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.", "workspace.cost.subscriptionShort": "abonelik", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Anahtarları", "workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.", @@ -485,6 +489,36 @@ export const dict = { "workspace.black.waitlist.enrollNote": "Kayıt Ol'a tıkladığınızda aboneliğiniz hemen başlar ve kartınızdan çekim yapılır.", + "workspace.lite.loading": "Yükleniyor...", + "workspace.lite.time.day": "gün", + "workspace.lite.time.days": "gün", + "workspace.lite.time.hour": "saat", + "workspace.lite.time.hours": "saat", + "workspace.lite.time.minute": "dakika", + "workspace.lite.time.minutes": "dakika", + "workspace.lite.time.fewSeconds": "birkaç saniye", + "workspace.lite.subscription.title": "Go Aboneliği", + "workspace.lite.subscription.message": "OpenCode Go abonesisiniz.", + "workspace.lite.subscription.manage": "Aboneliği Yönet", + "workspace.lite.subscription.rollingUsage": "Devam Eden Kullanım", + "workspace.lite.subscription.weeklyUsage": "Haftalık Kullanım", + "workspace.lite.subscription.monthlyUsage": "Aylık Kullanım", + "workspace.lite.subscription.resetsIn": "Sıfırlama süresi", + "workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın", + "workspace.lite.subscription.selectProvider": + 'Go modellerini kullanmak için opencode yapılandırmanızda "OpenCode Go"\'yu sağlayıcı olarak seçin.', + "workspace.lite.other.title": "Go Aboneliği", + "workspace.lite.other.message": + "Bu çalışma alanındaki başka bir üye zaten OpenCode Go abonesi. Çalışma alanı başına yalnızca bir üye abone olabilir.", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go, cömert kullanım limitleriyle popüler açık kodlama modellerine güvenilir erişim sağlayan aylık 10$'lık bir aboneliktir.", + "workspace.lite.promo.modelsTitle": "Neler Dahil", + "workspace.lite.promo.footer": + "Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.", + "workspace.lite.promo.subscribe": "Go'ya Abone Ol", + "workspace.lite.promo.subscribing": "Yönlendiriliyor...", + "download.title": "OpenCode | İndir", "download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin", "download.hero.title": "OpenCode'u İndir", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 4628b997733..bf21073ce1f 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -234,6 +234,7 @@ export const dict = { "black.hero.title": "访问全球顶尖编程模型", "black.hero.subtitle": "包括 Claude, GPT, Gemini 等", "black.title": "OpenCode Black | 定价", + "black.paused": "Black 订阅已暂时暂停注册。", "black.plan.icon20": "Black 20 计划", "black.plan.icon100": "Black 100 计划", "black.plan.icon200": "Black 200 计划", @@ -333,7 +334,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "缓存写入", "workspace.usage.breakdown.output": "输出", "workspace.usage.breakdown.reasoning": "推理", - "workspace.usage.subscription": "订阅 (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "成本", "workspace.cost.subtitle": "按模型细分的使用成本。", @@ -342,6 +345,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(已删除)", "workspace.cost.empty": "所选期间无可用使用数据。", "workspace.cost.subscriptionShort": "订阅", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API 密钥", "workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。", @@ -469,6 +473,35 @@ export const dict = { "workspace.black.waitlist.enrolled": "已加入", "workspace.black.waitlist.enrollNote": "点击加入后,您的订阅将立即开始,并将从您的卡中扣费。", + "workspace.lite.loading": "加载中...", + "workspace.lite.time.day": "天", + "workspace.lite.time.days": "天", + "workspace.lite.time.hour": "小时", + "workspace.lite.time.hours": "小时", + "workspace.lite.time.minute": "分钟", + "workspace.lite.time.minutes": "分钟", + "workspace.lite.time.fewSeconds": "几秒钟", + "workspace.lite.subscription.title": "Go 订阅", + "workspace.lite.subscription.message": "您已订阅 OpenCode Go。", + "workspace.lite.subscription.manage": "管理订阅", + "workspace.lite.subscription.rollingUsage": "滚动用量", + "workspace.lite.subscription.weeklyUsage": "每周用量", + "workspace.lite.subscription.monthlyUsage": "每月用量", + "workspace.lite.subscription.resetsIn": "重置于", + "workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额", + "workspace.lite.subscription.selectProvider": + "在你的 opencode 配置中选择「OpenCode Go」作为提供商,即可使用 Go 模型。", + "workspace.lite.other.title": "Go 订阅", + "workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Go。每个工作区只有一名成员可以订阅。", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go 是一个每月 $10 的订阅计划,提供对主流开源编码模型的稳定访问,并配备充足的使用额度。", + "workspace.lite.promo.modelsTitle": "包含模型", + "workspace.lite.promo.footer": + "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。", + "workspace.lite.promo.subscribe": "订阅 Go", + "workspace.lite.promo.subscribing": "正在重定向...", + "download.title": "OpenCode | 下载", "download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode", "download.hero.title": "下载 OpenCode", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index b7edf1b7926..8ac69596cb7 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -234,6 +234,7 @@ export const dict = { "black.hero.title": "存取全球最佳編碼模型", "black.hero.subtitle": "包括 Claude、GPT、Gemini 等", "black.title": "OpenCode Black | 定價", + "black.paused": "Black 訂閱暫時暫停註冊。", "black.plan.icon20": "Black 20 方案", "black.plan.icon100": "Black 100 方案", "black.plan.icon200": "Black 200 方案", @@ -333,7 +334,9 @@ export const dict = { "workspace.usage.breakdown.cacheWrite": "快取寫入", "workspace.usage.breakdown.output": "輸出", "workspace.usage.breakdown.reasoning": "推理", - "workspace.usage.subscription": "訂閱 (${{amount}})", + "workspace.usage.subscription": "Black (${{amount}})", + "workspace.usage.lite": "Go (${{amount}})", + "workspace.usage.byok": "BYOK (${{amount}})", "workspace.cost.title": "成本", "workspace.cost.subtitle": "按模型細分的使用成本。", @@ -342,6 +345,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(已刪除)", "workspace.cost.empty": "所選期間沒有可用的使用資料。", "workspace.cost.subscriptionShort": "訂", + "workspace.cost.liteShort": "lite", "workspace.keys.title": "API 金鑰", "workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。", @@ -469,6 +473,35 @@ export const dict = { "workspace.black.waitlist.enrolled": "已加入", "workspace.black.waitlist.enrollNote": "當你點選「加入」後,你的訂閱將立即開始,並且將從你的卡片中扣款。", + "workspace.lite.loading": "載入中...", + "workspace.lite.time.day": "天", + "workspace.lite.time.days": "天", + "workspace.lite.time.hour": "小時", + "workspace.lite.time.hours": "小時", + "workspace.lite.time.minute": "分鐘", + "workspace.lite.time.minutes": "分鐘", + "workspace.lite.time.fewSeconds": "幾秒", + "workspace.lite.subscription.title": "Go 訂閱", + "workspace.lite.subscription.message": "您已訂閱 OpenCode Go。", + "workspace.lite.subscription.manage": "管理訂閱", + "workspace.lite.subscription.rollingUsage": "滾動使用量", + "workspace.lite.subscription.weeklyUsage": "每週使用量", + "workspace.lite.subscription.monthlyUsage": "每月使用量", + "workspace.lite.subscription.resetsIn": "重置時間:", + "workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額", + "workspace.lite.subscription.selectProvider": + "在您的 opencode 設定中選擇「OpenCode Go」作為提供商,即可使用 Go 模型。", + "workspace.lite.other.title": "Go 訂閱", + "workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Go。每個工作區只能有一位成員訂閱。", + "workspace.lite.promo.title": "OpenCode Go", + "workspace.lite.promo.description": + "OpenCode Go 是一個每月 $10 的訂閱方案,提供對主流開放原始碼編碼模型的穩定存取,並配備充足的使用額度。", + "workspace.lite.promo.modelsTitle": "包含模型", + "workspace.lite.promo.footer": + "該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。", + "workspace.lite.promo.subscribe": "訂閱 Go", + "workspace.lite.promo.subscribing": "重新導向中...", + "download.title": "OpenCode | 下載", "download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode", "download.hero.title": "下載 OpenCode", diff --git a/packages/console/app/src/routes/black.css b/packages/console/app/src/routes/black.css index 66bffea5995..4031a78fc33 100644 --- a/packages/console/app/src/routes/black.css +++ b/packages/console/app/src/routes/black.css @@ -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; diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx index 72b196f5714..8bce3cd464f 100644 --- a/packages/console/app/src/routes/black/index.tsx +++ b/packages/console/app/src/routes/black/index.tsx @@ -1,14 +1,21 @@ -import { A, useSearchParams } from "@solidjs/router" +import { A, createAsync, query, useSearchParams } from "@solidjs/router" import { Title } from "@solidjs/meta" import { createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js" import { PlanIcon, plans } from "./common" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" +import { Resource } from "@opencode-ai/console-resource" + +const getPaused = query(async () => { + "use server" + return Resource.App.stage === "production" +}, "black.paused") export default function Black() { const [params] = useSearchParams() const i18n = useI18n() const language = useLanguage() + const paused = createAsync(() => getPaused()) const [selected, setSelected] = createSignal((params.plan as string) || null) const [mounted, setMounted] = createSignal(false) const selectedPlan = createMemo(() => plans.find((p) => p.id === selected())) @@ -42,72 +49,76 @@ export default function Black() { <> {i18n.t("black.title")}
- - -
- - {(plan) => ( - + )} + +
+
+ + {(plan) => ( +
+
- +

- ${plan.id}{" "} - {i18n.t("black.price.perMonth")} - + ${plan().id}{" "} + {i18n.t("black.price.perPersonBilledMonthly")} + {(multiplier) => {i18n.t(multiplier())}}

- - )} - -
- - - {(plan) => ( -
-
-
- -
-

- ${plan().id}{" "} - {i18n.t("black.price.perPersonBilledMonthly")} - - {(multiplier) => {i18n.t(multiplier())}} - -

-
    -
  • {i18n.t("black.terms.1")}
  • -
  • {i18n.t("black.terms.2")}
  • -
  • {i18n.t("black.terms.3")}
  • -
  • {i18n.t("black.terms.4")}
  • -
  • {i18n.t("black.terms.5")}
  • -
  • {i18n.t("black.terms.6")}
  • -
  • {i18n.t("black.terms.7")}
  • -
-
- - - {i18n.t("black.action.continue")} - +
    +
  • {i18n.t("black.terms.1")}
  • +
  • {i18n.t("black.terms.2")}
  • +
  • {i18n.t("black.terms.3")}
  • +
  • {i18n.t("black.terms.4")}
  • +
  • {i18n.t("black.terms.5")}
  • +
  • {i18n.t("black.terms.6")}
  • +
  • {i18n.t("black.terms.7")}
  • +
+
+ + + {i18n.t("black.action.continue")} + +
-
- )} -
- -

- {i18n.t("black.finePrint.beforeTerms")} ·{" "} - {i18n.t("black.finePrint.terms")} -

+ )} + + + + +

+ {i18n.t("black.finePrint.beforeTerms")} ·{" "} + {i18n.t("black.finePrint.terms")} +

+
) diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx index 644d87d9b32..19b56eabe67 100644 --- a/packages/console/app/src/routes/black/subscribe/[plan].tsx +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -17,6 +17,12 @@ import { Billing } from "@opencode-ai/console-core/billing.js" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { formError } from "~/lib/form-error" +import { Resource } from "@opencode-ai/console-resource" + +const getEnabled = query(async () => { + "use server" + return Resource.App.stage !== "production" +}, "black.subscribe.enabled") const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) @@ -269,6 +275,7 @@ export default function BlackSubscribe() { const params = useParams() const i18n = useI18n() const language = useLanguage() + const enabled = createAsync(() => getEnabled()) const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"] const plan = planData.id @@ -359,7 +366,7 @@ export default function BlackSubscribe() { } return ( - <> + {i18n.t("black.subscribe.title")}
@@ -472,6 +479,6 @@ export default function BlackSubscribe() { {i18n.t("black.finePrint.terms")}

- +
) } diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 828eb4c711c..47ca442ecbe 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -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 (!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") - - // 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 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 (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 - // 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") + 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") - // 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) - } + // 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") - // set customer metadata - if (!billing?.customerID) { - await Billing.stripe().customers.update(customerID, { - metadata: { - workspaceID, - }, - }) - } + // 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") - await Database.transaction(async (tx) => { - await tx - .update(BillingTable) - .set({ - customerID, - subscriptionID, - subscription: { - status: "subscribed", - coupon: couponID, - seats: 1, - plan: "200", + 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") + + // 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, }, }), diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx index 5326306e288..b8f089864da 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx @@ -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] }, diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx index a252a02344e..e039a09ef8b 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx @@ -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 (
- + + + + diff --git a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css new file mode 100644 index 00000000000..76d9bcfb099 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.module.css @@ -0,0 +1,190 @@ +.root { + [data-slot="title-row"] { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + } + + [data-slot="usage"] { + display: flex; + gap: var(--space-6); + margin-top: var(--space-4); + + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-4); + } + } + + [data-slot="usage-item"] { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="usage-header"] { + display: flex; + justify-content: space-between; + align-items: baseline; + } + + [data-slot="usage-label"] { + font-size: var(--font-size-md); + font-weight: 500; + color: var(--color-text); + } + + [data-slot="usage-value"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + [data-slot="progress"] { + height: 8px; + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + overflow: hidden; + } + + [data-slot="progress-bar"] { + height: 100%; + background-color: var(--color-accent); + border-radius: var(--border-radius-sm); + transition: width 0.3s ease; + } + + [data-slot="reset-time"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + [data-slot="setting-row"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-top: var(--space-4); + + p { + font-size: var(--font-size-sm); + line-height: 1.5; + color: var(--color-text-secondary); + margin: 0; + } + } + + [data-slot="toggle-label"] { + position: relative; + display: inline-block; + width: 2.5rem; + height: 1.5rem; + cursor: pointer; + flex-shrink: 0; + + input { + opacity: 0; + width: 0; + height: 0; + } + + span { + position: absolute; + inset: 0; + background-color: #ccc; + border: 1px solid #bbb; + border-radius: 1.5rem; + transition: all 0.3s ease; + cursor: pointer; + + &::before { + content: ""; + position: absolute; + top: 50%; + left: 0.125rem; + width: 1.25rem; + height: 1.25rem; + background-color: white; + border: 1px solid #ddd; + border-radius: 50%; + transform: translateY(-50%); + transition: all 0.3s ease; + } + } + + input:checked + span { + background-color: #21ad0e; + border-color: #148605; + + &::before { + transform: translateX(1rem) translateY(-50%); + } + } + + &:hover span { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); + } + + input:checked:hover + span { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3); + } + + &:has(input:disabled) { + cursor: not-allowed; + } + + input:disabled + span { + opacity: 0.5; + cursor: not-allowed; + } + } + + [data-slot="beta-notice"] { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg-surface); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + line-height: 1.5; + margin-top: var(--space-3); + + a { + color: var(--color-accent); + text-decoration: none; + } + } + + [data-slot="other-message"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + line-height: 1.5; + } + + [data-slot="promo-description"] { + font-size: var(--font-size-md); + color: var(--color-text-secondary); + line-height: 1.5; + margin-top: var(--space-2); + } + + [data-slot="promo-models-title"] { + font-size: var(--font-size-md); + font-weight: 600; + margin-top: var(--space-4); + } + + [data-slot="promo-models"] { + margin: var(--space-2) 0 0 var(--space-4); + padding: 0; + font-size: var(--font-size-md); + color: var(--color-text-secondary); + line-height: 1.4; + } + + [data-slot="subscribe-button"] { + align-self: flex-start; + margin-top: var(--space-4); + } +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx new file mode 100644 index 00000000000..395d008e1d5 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx @@ -0,0 +1,285 @@ +import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" +import { createStore } from "solid-js/store" +import { Show } from "solid-js" +import { Billing } from "@opencode-ai/console-core/billing.js" +import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { Subscription } from "@opencode-ai/console-core/subscription.js" +import { LiteData } from "@opencode-ai/console-core/lite.js" +import { withActor } from "~/context/auth.withActor" +import { queryBillingInfo } from "../../common" +import styles from "./lite-section.module.css" +import { useI18n } from "~/context/i18n" +import { useLanguage } from "~/context/language" + +const queryLiteSubscription = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + const row = await Database.use((tx) => + tx + .select({ + userID: LiteTable.userID, + rollingUsage: LiteTable.rollingUsage, + weeklyUsage: LiteTable.weeklyUsage, + monthlyUsage: LiteTable.monthlyUsage, + timeRollingUpdated: LiteTable.timeRollingUpdated, + timeWeeklyUpdated: LiteTable.timeWeeklyUpdated, + timeMonthlyUpdated: LiteTable.timeMonthlyUpdated, + timeCreated: LiteTable.timeCreated, + lite: BillingTable.lite, + }) + .from(BillingTable) + .innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID)) + .where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted))) + .then((r) => r[0]), + ) + if (!row) return null + + const limits = LiteData.getLimits() + const mine = row.userID === Actor.userID() + + return { + mine, + useBalance: row.lite?.useBalance ?? false, + rollingUsage: Subscription.analyzeRollingUsage({ + limit: limits.rollingLimit, + window: limits.rollingWindow, + usage: row.rollingUsage ?? 0, + timeUpdated: row.timeRollingUpdated ?? new Date(), + }), + weeklyUsage: Subscription.analyzeWeeklyUsage({ + limit: limits.weeklyLimit, + usage: row.weeklyUsage ?? 0, + timeUpdated: row.timeWeeklyUpdated ?? new Date(), + }), + monthlyUsage: Subscription.analyzeMonthlyUsage({ + limit: limits.monthlyLimit, + usage: row.monthlyUsage ?? 0, + timeUpdated: row.timeMonthlyUpdated ?? new Date(), + timeSubscribed: row.timeCreated, + }), + } + }, workspaceID) +}, "lite.subscription.get") + +function formatResetTime(seconds: number, i18n: ReturnType) { + const days = Math.floor(seconds / 86400) + if (days >= 1) { + const hours = Math.floor((seconds % 86400) / 3600) + return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}` + } + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours >= 1) + return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}` + if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds") + return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}` +} + +const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => { + "use server" + return json( + await withActor( + () => + Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ + error: e.message as string, + data: undefined, + })), + workspaceID, + ), + { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, + ) +}, "liteCheckoutUrl") + +const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { + "use server" + return json( + await withActor( + () => + Billing.generateSessionUrl({ returnUrl }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ + error: e.message as string, + data: undefined, + })), + workspaceID, + ), + { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, + ) +}, "liteSessionUrl") + +const setLiteUseBalance = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + const useBalance = form.get("useBalance")?.toString() === "true" + + return json( + await withActor(async () => { + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + lite: useBalance ? { useBalance: true } : {}, + }) + .where(eq(BillingTable.workspaceID, workspaceID)), + ) + return { error: undefined } + }, workspaceID).catch((e) => ({ error: e.message as string })), + { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, + ) +}, "setLiteUseBalance") + +export function LiteSection() { + const params = useParams() + const i18n = useI18n() + const language = useLanguage() + const lite = createAsync(() => queryLiteSubscription(params.id!)) + const sessionAction = useAction(createSessionUrl) + const sessionSubmission = useSubmission(createSessionUrl) + const checkoutAction = useAction(createLiteCheckoutUrl) + const checkoutSubmission = useSubmission(createLiteCheckoutUrl) + const useBalanceSubmission = useSubmission(setLiteUseBalance) + const [store, setStore] = createStore({ + redirecting: false, + }) + + async function onClickSession() { + const result = await sessionAction(params.id!, window.location.href) + if (result.data) { + setStore("redirecting", true) + window.location.href = result.data + } + } + + async function onClickSubscribe() { + const result = await checkoutAction(params.id!, window.location.href, window.location.href) + if (result.data) { + setStore("redirecting", true) + window.location.href = result.data + } + } + + return ( + <> + + {(sub) => ( +
+
+

{i18n.t("workspace.lite.subscription.title")}

+
+

{i18n.t("workspace.lite.subscription.message")}

+ +
+
+
+ {i18n.t("workspace.lite.subscription.selectProvider")}{" "} + + {i18n.t("common.learnMore")} + + . +
+
+
+
+ {i18n.t("workspace.lite.subscription.rollingUsage")} + {sub().rollingUsage.usagePercent}% +
+
+
+
+ + {i18n.t("workspace.lite.subscription.resetsIn")}{" "} + {formatResetTime(sub().rollingUsage.resetInSec, i18n)} + +
+
+
+ {i18n.t("workspace.lite.subscription.weeklyUsage")} + {sub().weeklyUsage.usagePercent}% +
+
+
+
+ + {i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)} + +
+
+
+ {i18n.t("workspace.lite.subscription.monthlyUsage")} + {sub().monthlyUsage.usagePercent}% +
+
+
+
+ + {i18n.t("workspace.lite.subscription.resetsIn")}{" "} + {formatResetTime(sub().monthlyUsage.resetInSec, i18n)} + +
+
+
+

{i18n.t("workspace.lite.subscription.useBalance")}

+ + + +
+
+ )} +
+ +
+
+

{i18n.t("workspace.lite.other.title")}

+
+

{i18n.t("workspace.lite.other.message")}

+
+
+ +
+
+

{i18n.t("workspace.lite.promo.title")}

+
+

{i18n.t("workspace.lite.promo.description")}

+

{i18n.t("workspace.lite.promo.modelsTitle")}

+
    +
  • Kimi K2.5
  • +
  • GLM-5
  • +
  • MiniMax M2.5
  • +
+

{i18n.t("workspace.lite.promo.footer")}

+ +
+
+ + ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx index f26c7291d8a..56a31cdd060 100644 --- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx @@ -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`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`, + plan: sql`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>() const dailyDataSub = new Map>() - const dailyDataNonSub = new Map>() + const dailyDataLite = new Map>() 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 }) diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx index 7b030f4afb8..a20a5bf0d1b 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx @@ -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() {
- ${((usage.cost ?? 0) / 100000000).toFixed(4)}} - > - {i18n.t("workspace.usage.subscription", { - amount: ((usage.cost ?? 0) / 100000000).toFixed(4), - })} - + ${((usage.cost ?? 0) / 100000000).toFixed(4)}}> + + {i18n.t("workspace.usage.subscription", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + + + {i18n.t("workspace.usage.lite", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + + + {i18n.t("workspace.usage.byok", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + + {usage.sessionID?.slice(-8) ?? "-"} diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index 5cbd6718351..d41793dd92b 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -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") diff --git a/packages/console/app/src/routes/zen/lite/v1/chat/completions.ts b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts similarity index 100% rename from packages/console/app/src/routes/zen/lite/v1/chat/completions.ts rename to packages/console/app/src/routes/zen/go/v1/chat/completions.ts diff --git a/packages/console/app/src/routes/zen/go/v1/messages.ts b/packages/console/app/src/routes/zen/go/v1/messages.ts new file mode 100644 index 00000000000..ee401e6aa2c --- /dev/null +++ b/packages/console/app/src/routes/zen/go/v1/messages.ts @@ -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, + }) +} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index dc10e1bf935..a6aee5368e3 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -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> 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 ] @@ -90,6 +92,7 @@ export async function handler( const stickyProvider = await stickyTracker?.get() const authInfo = await authenticate(modelInfo) const billingSource = validateBilling(authInfo, modelInfo) + logger.metric({ source: billingSource }) const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const providerInfo = selectProvider( @@ -454,6 +457,7 @@ export async function handler( reloadTrigger: BillingTable.reloadTrigger, timeReloadLockedTill: BillingTable.timeReloadLockedTill, subscription: BillingTable.subscription, + lite: BillingTable.lite, }, user: { id: UserTable.id, @@ -461,13 +465,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 +509,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 +552,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,21 +566,21 @@ export async function handler( if (authInfo.isFree) return "free" if (modelInfo.allowAnonymous) return "free" - // Validate subscription billing - if (authInfo.billing.subscription && authInfo.subscription) { + 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.subscription + const sub = authInfo.black 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` - } - // Check weekly limit if (sub.fixedUsage && sub.timeFixedUpdated) { const blackData = BlackData.getLimits({ plan }) @@ -577,6 +618,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 +837,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))), + ] + })(), ]), ) diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index f9c14ededdc..d2592d20b07 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -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", diff --git a/packages/console/core/migrations/20260224043338_nifty_starjammers/migration.sql b/packages/console/core/migrations/20260224043338_nifty_starjammers/migration.sql new file mode 100644 index 00000000000..1c97afbd987 --- /dev/null +++ b/packages/console/core/migrations/20260224043338_nifty_starjammers/migration.sql @@ -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; \ No newline at end of file diff --git a/packages/console/core/migrations/20260224043338_nifty_starjammers/snapshot.json b/packages/console/core/migrations/20260224043338_nifty_starjammers/snapshot.json new file mode 100644 index 00000000000..bc20ee2b964 --- /dev/null +++ b/packages/console/core/migrations/20260224043338_nifty_starjammers/snapshot.json @@ -0,0 +1,2463 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "5e506dec-61e7-4726-81d1-afa4ffbc61ed", + "prevIds": ["4bf45b3f-3edd-4db7-94d5-097aa55ca5f7"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/package.json b/packages/console/core/package.json index aac79d66900..8f65e0c4578 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.2.10", + "version": "1.2.15", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/core/script/black-select-workspaces.ts b/packages/console/core/script/black-select-workspaces.ts index f22478e1b3d..63bfab88750 100644 --- a/packages/console/core/script/black-select-workspaces.ts +++ b/packages/console/core/script/black-select-workspaces.ts @@ -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 ") process.exit(1) } diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index 6367fd89a4a..0dfda24116d 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -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 diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 2c1cdb0687b..fcf238a3538 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -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)) + }) + }, + ) } diff --git a/packages/console/core/src/black.ts b/packages/console/core/src/black.ts index b4cc2706463..a18c5258d04 100644 --- a/packages/console/core/src/black.ts +++ b/packages/console/core/src/black.ts @@ -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 diff --git a/packages/console/core/src/identifier.ts b/packages/console/core/src/identifier.ts index b10bf32f6f1..8aa324ba07f 100644 --- a/packages/console/core/src/identifier.ts +++ b/packages/console/core/src/identifier.ts @@ -8,6 +8,7 @@ export namespace Identifier { benchmark: "ben", billing: "bil", key: "key", + lite: "lit", model: "mod", payment: "pay", provider: "prv", diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts index d6679208d85..49d23e59ec0 100644 --- a/packages/console/core/src/lite.ts +++ b/packages/console/core/src/lite.ts @@ -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") } diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index 6d96fc7eb89..a5c70c21154 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -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", { diff --git a/packages/console/core/src/subscription.ts b/packages/console/core/src/subscription.ts index ca3b1704224..879f940e0eb 100644 --- a/packages/console/core/src/subscription.ts +++ b/packages/console/core/src/subscription.ts @@ -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, + } + }, + ) } diff --git a/packages/console/core/src/util/date.test.ts b/packages/console/core/src/util/date.test.ts deleted file mode 100644 index 074df8a2fad..00000000000 --- a/packages/console/core/src/util/date.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/console/core/src/util/date.ts b/packages/console/core/src/util/date.ts index 9c1ab12d2c9..dea9c390e06 100644 --- a/packages/console/core/src/util/date.ts +++ b/packages/console/core/src/util/date.ts @@ -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 } +} diff --git a/packages/console/core/test/date.test.ts b/packages/console/core/test/date.test.ts new file mode 100644 index 00000000000..e5a0a90e551 --- /dev/null +++ b/packages/console/core/test/date.test.ts @@ -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") + }) +}) diff --git a/packages/console/core/test/subscription.test.ts b/packages/console/core/test/subscription.test.ts new file mode 100644 index 00000000000..57e63f94c41 --- /dev/null +++ b/packages/console/core/test/subscription.test.ts @@ -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) + }) +}) diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 386ee19df23..6cdf752432c 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.2.10", + "version": "1.2.15", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index 327fc930b72..f8b2cf5270b 100644 --- a/packages/console/function/src/log-processor.ts +++ b/packages/console/function/src/log-processor.ts @@ -13,7 +13,11 @@ export default { url.pathname !== "/zen/v1/chat/completions" && url.pathname !== "/zen/v1/messages" && url.pathname !== "/zen/v1/responses" && - !url.pathname.startsWith("/zen/v1/models/") + !url.pathname.startsWith("/zen/v1/models/") && + url.pathname !== "/zen/go/v1/chat/completions" && + url.pathname !== "/zen/go/v1/messages" && + url.pathname !== "/zen/go/v1/responses" && + !url.pathname.startsWith("/zen/go/v1/models/") ) return diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 7a08244bb62..09344f7fa23 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.2.10", + "version": "1.2.15", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/README.md b/packages/desktop/README.md index ebaf4882231..358b7d24d51 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -2,6 +2,10 @@ Native OpenCode desktop app, built with Tauri v2. +## Prerequisites + +Building the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. + ## Development From the repo root: @@ -11,22 +15,18 @@ bun install bun run --cwd packages/desktop tauri dev ``` -This starts the Vite dev server on http://localhost:1420 and opens the native window. - -If you only want the web dev server (no native shell): +## Build ```bash -bun run --cwd packages/desktop dev +bun run --cwd packages/desktop tauri build ``` -## Build +## Troubleshooting + +### Rust compiler not found -To create a production `dist/` and build the native app bundle: +If you see errors about Rust not being found, install it via [rustup](https://rustup.rs/): ```bash -bun run --cwd packages/desktop tauri build +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` - -## Prerequisites - -Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. diff --git a/packages/desktop/package.json b/packages/desktop/package.json index dc25cb02037..4fe999e700a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.2.10", + "version": "1.2.15", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index f9516350e13..55f0d5f3603 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -1988,7 +1988,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3136,7 +3136,8 @@ dependencies = [ "tracing-subscriber", "uuid", "webkit2gtk", - "windows 0.62.2", + "windows-core 0.62.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index e98b8965c16..b228c7b6162 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -55,7 +55,8 @@ tokio-stream = { version = "0.1.18", features = ["sync"] } process-wrap = { version = "9.0.3", features = ["tokio1"] } [target.'cfg(windows)'.dependencies] -windows = { version = "0.62", features = ["Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_Registry"] } +windows-core = "0.62" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index acab0fa7034..97fdba144f4 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -4,10 +4,13 @@ use process_wrap::tokio::CommandWrap; use process_wrap::tokio::ProcessGroup; #[cfg(windows)] use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop}; +use std::collections::HashMap; #[cfg(unix)] use std::os::unix::process::ExitStatusExt; +use std::path::Path; +use std::process::Stdio; use std::sync::Arc; -use std::{process::Stdio, time::Duration}; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri_specta::Event; use tokio::{ @@ -19,7 +22,7 @@ use tokio::{ use tokio_stream::wrappers::ReceiverStream; use tracing::Instrument; #[cfg(windows)] -use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED}; +use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED}; use crate::server::get_wsl_config; @@ -32,13 +35,14 @@ struct WinCreationFlags; #[cfg(windows)] impl CommandWrapper for WinCreationFlags { fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> { - command.creation_flags((CREATE_NO_WINDOW | CREATE_SUSPENDED).0); + command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED); Ok(()) } } const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_BINARY_NAME: &str = "opencode"; +const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5); #[derive(serde::Deserialize, Debug)] pub struct ServerConfig { @@ -232,6 +236,133 @@ fn shell_escape(input: &str) -> String { escaped } +fn parse_shell_env(stdout: &[u8]) -> HashMap { + String::from_utf8_lossy(stdout) + .split('\0') + .filter_map(|line| { + if line.is_empty() { + return None; + } + + let (key, value) = line.split_once('=')?; + if key.is_empty() { + return None; + } + + Some((key.to_string(), value.to_string())) + }) + .collect() +} + +fn command_output_with_timeout( + mut cmd: std::process::Command, + timeout: Duration, +) -> std::io::Result> { + let mut child = cmd.spawn()?; + let start = Instant::now(); + + loop { + if child.try_wait()?.is_some() { + return child.wait_with_output().map(Some); + } + + if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Ok(None); + } + + std::thread::sleep(Duration::from_millis(25)); + } +} + +enum ShellEnvProbe { + Loaded(HashMap), + Timeout, + Unavailable, +} + +fn probe_shell_env(shell: &str, mode: &str) -> ShellEnvProbe { + let mut cmd = std::process::Command::new(shell); + cmd.args([mode, "-c", "env -0"]); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::null()); + let output = match command_output_with_timeout(cmd, SHELL_ENV_TIMEOUT) { + Ok(Some(output)) => output, + Ok(None) => return ShellEnvProbe::Timeout, + Err(error) => { + tracing::debug!(shell, mode, ?error, "Shell env probe failed"); + return ShellEnvProbe::Unavailable; + } + }; + if !output.status.success() { + tracing::debug!(shell, mode, "Shell env probe exited with non-zero status"); + return ShellEnvProbe::Unavailable; + } + let env = parse_shell_env(&output.stdout); + if env.is_empty() { + tracing::debug!(shell, mode, "Shell env probe returned empty env"); + return ShellEnvProbe::Unavailable; + } + + ShellEnvProbe::Loaded(env) +} + +fn is_nushell(shell: &str) -> bool { + let shell_name = Path::new(shell) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(shell) + .to_ascii_lowercase(); + shell_name == "nu" || shell_name == "nu.exe" || shell.to_ascii_lowercase().ends_with("\\nu.exe") +} +fn load_shell_env(shell: &str) -> Option> { + if is_nushell(shell) { + tracing::debug!(shell, "Skipping shell env probe for nushell"); + return None; + } + + match probe_shell_env(shell, "-il") { + ShellEnvProbe::Loaded(env) => { + tracing::info!( + shell, + env_count = env.len(), + "Loaded shell environment with -il" + ); + return Some(env); + } + ShellEnvProbe::Timeout => { + tracing::warn!(shell, "Interactive shell env probe timed out"); + return None; + } + ShellEnvProbe::Unavailable => {} + } + + if let ShellEnvProbe::Loaded(env) = probe_shell_env(shell, "-l") { + tracing::info!( + shell, + env_count = env.len(), + "Loaded shell environment with -l" + ); + return Some(env); + } + tracing::warn!(shell, "Falling back to app environment"); + None +} + +fn merge_shell_env( + shell_env: Option>, + envs: Vec<(String, String)>, +) -> Vec<(String, String)> { + let mut merged = shell_env.unwrap_or_default(); + for (key, value) in envs { + merged.insert(key, value); + } + + merged.into_iter().collect() +} + pub fn spawn_command( app: &tauri::AppHandle, args: &str, @@ -312,6 +443,7 @@ pub fn spawn_command( } else { let sidecar = get_sidecar_path(app); let shell = get_user_shell(); + let envs = merge_shell_env(load_shell_env(&shell), envs); let line = if shell.ends_with("/nu") { format!("^\"{}\" {}", sidecar.display(), args) @@ -320,7 +452,7 @@ pub fn spawn_command( }; let mut cmd = Command::new(shell); - cmd.args(["-il", "-c", &line]); + cmd.args(["-l", "-c", &line]); for (key, value) in envs { cmd.env(key, value); @@ -556,3 +688,54 @@ async fn read_line CommandEvent + Send + Copy + 'static>( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn parse_shell_env_supports_null_delimited_pairs() { + let env = parse_shell_env(b"PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"); + + assert_eq!(env.get("PATH"), Some(&"/usr/bin:/bin".to_string())); + assert_eq!(env.get("FOO"), Some(&"bar=baz".to_string())); + } + + #[test] + fn parse_shell_env_ignores_invalid_entries() { + let env = parse_shell_env(b"INVALID\0=empty\0OK=1\0"); + + assert_eq!(env.len(), 1); + assert_eq!(env.get("OK"), Some(&"1".to_string())); + } + + #[test] + fn merge_shell_env_keeps_explicit_overrides() { + let mut shell_env = HashMap::new(); + shell_env.insert("PATH".to_string(), "/shell/path".to_string()); + shell_env.insert("HOME".to_string(), "/tmp/home".to_string()); + + let merged = merge_shell_env( + Some(shell_env), + vec![ + ("PATH".to_string(), "/desktop/path".to_string()), + ("OPENCODE_CLIENT".to_string(), "desktop".to_string()), + ], + ) + .into_iter() + .collect::>(); + + assert_eq!(merged.get("PATH"), Some(&"/desktop/path".to_string())); + assert_eq!(merged.get("HOME"), Some(&"/tmp/home".to_string())); + assert_eq!(merged.get("OPENCODE_CLIENT"), Some(&"desktop".to_string())); + } + + #[test] + fn is_nushell_handles_path_and_binary_name() { + assert!(is_nushell("nu")); + assert!(is_nushell("/opt/homebrew/bin/nu")); + assert!(is_nushell("C:\\Program Files\\nu.exe")); + assert!(!is_nushell("/bin/zsh")); + } +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 7ea3aaa8a76..87973212147 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod linux_display; pub mod linux_windowing; mod logging; mod markdown; +mod os; mod server; mod window_customizer; mod windows; @@ -42,7 +43,7 @@ struct ServerReadyData { url: String, username: Option, password: Option, - is_sidecar: bool + is_sidecar: bool, } #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)] @@ -148,7 +149,7 @@ async fn await_initialization( fn check_app_exists(app_name: &str) -> bool { #[cfg(target_os = "windows")] { - check_windows_app(app_name) + os::windows::check_windows_app(app_name) } #[cfg(target_os = "macos")] @@ -162,156 +163,12 @@ fn check_app_exists(app_name: &str) -> bool { } } -#[cfg(target_os = "windows")] -fn check_windows_app(_app_name: &str) -> bool { - // Check if command exists in PATH, including .exe - return true; -} - -#[cfg(target_os = "windows")] -fn resolve_windows_app_path(app_name: &str) -> Option { - use std::path::{Path, PathBuf}; - - // Try to find the command using 'where' - let output = Command::new("where").arg(app_name).output().ok()?; - - if !output.status.success() { - return None; - } - - let paths = String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(PathBuf::from) - .collect::>(); - - let has_ext = |path: &Path, ext: &str| { - path.extension() - .and_then(|v| v.to_str()) - .map(|v| v.eq_ignore_ascii_case(ext)) - .unwrap_or(false) - }; - - if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { - return Some(path.to_string_lossy().to_string()); - } - - let resolve_cmd = |path: &Path| -> Option { - let content = std::fs::read_to_string(path).ok()?; - - for token in content.split('"') { - let lower = token.to_ascii_lowercase(); - if !lower.contains(".exe") { - continue; - } - - if let Some(index) = lower.find("%~dp0") { - let base = path.parent()?; - let suffix = &token[index + 5..]; - let mut resolved = PathBuf::from(base); - - for part in suffix.replace('/', "\\").split('\\') { - if part.is_empty() || part == "." { - continue; - } - if part == ".." { - let _ = resolved.pop(); - continue; - } - resolved.push(part); - } - - if resolved.exists() { - return Some(resolved.to_string_lossy().to_string()); - } - } - - let resolved = PathBuf::from(token); - if resolved.exists() { - return Some(resolved.to_string_lossy().to_string()); - } - } - - None - }; - - for path in &paths { - if has_ext(path, "cmd") || has_ext(path, "bat") { - if let Some(resolved) = resolve_cmd(path) { - return Some(resolved); - } - } - - if path.extension().is_none() { - let cmd = path.with_extension("cmd"); - if cmd.exists() { - if let Some(resolved) = resolve_cmd(&cmd) { - return Some(resolved); - } - } - - let bat = path.with_extension("bat"); - if bat.exists() { - if let Some(resolved) = resolve_cmd(&bat) { - return Some(resolved); - } - } - } - } - - let key = app_name - .chars() - .filter(|v| v.is_ascii_alphanumeric()) - .flat_map(|v| v.to_lowercase()) - .collect::(); - - if !key.is_empty() { - for path in &paths { - let dirs = [ - path.parent(), - path.parent().and_then(|dir| dir.parent()), - path.parent() - .and_then(|dir| dir.parent()) - .and_then(|dir| dir.parent()), - ]; - - for dir in dirs.into_iter().flatten() { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let candidate = entry.path(); - if !has_ext(&candidate, "exe") { - continue; - } - - let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else { - continue; - }; - - let name = stem - .chars() - .filter(|v| v.is_ascii_alphanumeric()) - .flat_map(|v| v.to_lowercase()) - .collect::(); - - if name.contains(&key) || key.contains(&name) { - return Some(candidate.to_string_lossy().to_string()); - } - } - } - } - } - } - - paths.first().map(|path| path.to_string_lossy().to_string()) -} - #[tauri::command] #[specta::specta] fn resolve_app_path(app_name: &str) -> Option { #[cfg(target_os = "windows")] { - resolve_windows_app_path(app_name) + os::windows::resolve_windows_app_path(app_name) } #[cfg(not(target_os = "windows"))] @@ -322,6 +179,18 @@ fn resolve_app_path(app_name: &str) -> Option { } } +#[tauri::command] +#[specta::specta] +fn open_in_powershell(path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return os::windows::open_in_powershell(path); + } + + #[cfg(not(target_os = "windows"))] + Err("PowerShell is only supported on Windows".to_string()) +} + #[cfg(target_os = "macos")] fn check_macos_app(app_name: &str) -> bool { // Check common installation locations @@ -516,7 +385,8 @@ fn make_specta_builder() -> tauri_specta::Builder { markdown::parse_markdown_command, check_app_exists, wsl_path, - resolve_app_path + resolve_app_path, + open_in_powershell ]) .events(tauri_specta::collect_events![ LoadingWindowComplete, @@ -634,7 +504,12 @@ async fn initialize(app: AppHandle) { app.state::().set_child(Some(child)); - Ok(ServerReadyData { url, username,password, is_sidecar: true }) + Ok(ServerReadyData { + url, + username, + password, + is_sidecar: true, + }) } .map(move |res| { let _ = server_ready_tx.send(res); diff --git a/packages/desktop/src-tauri/src/os/mod.rs b/packages/desktop/src-tauri/src/os/mod.rs new file mode 100644 index 00000000000..8c36e53f779 --- /dev/null +++ b/packages/desktop/src-tauri/src/os/mod.rs @@ -0,0 +1,2 @@ +#[cfg(windows)] +pub mod windows; diff --git a/packages/desktop/src-tauri/src/os/windows.rs b/packages/desktop/src-tauri/src/os/windows.rs new file mode 100644 index 00000000000..a163c4aa7eb --- /dev/null +++ b/packages/desktop/src-tauri/src/os/windows.rs @@ -0,0 +1,463 @@ +use std::{ + ffi::c_void, + os::windows::process::CommandExt, + path::{Path, PathBuf}, + process::Command, +}; +use windows_sys::Win32::{ + Foundation::ERROR_SUCCESS, + System::{ + Registry::{ + RegGetValueW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, REG_EXPAND_SZ, REG_SZ, + RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, + }, + Threading::{CREATE_NEW_CONSOLE, CREATE_NO_WINDOW}, + }, +}; + +pub fn check_windows_app(app_name: &str) -> bool { + resolve_windows_app_path(app_name).is_some() +} + +pub fn resolve_windows_app_path(app_name: &str) -> Option { + fn expand_env(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut index = 0; + + while let Some(start) = value[index..].find('%') { + let start = index + start; + out.push_str(&value[index..start]); + + let Some(end_rel) = value[start + 1..].find('%') else { + out.push_str(&value[start..]); + return out; + }; + + let end = start + 1 + end_rel; + let key = &value[start + 1..end]; + if key.is_empty() { + out.push('%'); + index = end + 1; + continue; + } + + if let Ok(v) = std::env::var(key) { + out.push_str(&v); + index = end + 1; + continue; + } + + out.push_str(&value[start..=end]); + index = end + 1; + } + + out.push_str(&value[index..]); + out + } + + fn extract_exe(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Some(rest) = value.strip_prefix('"') { + if let Some(end) = rest.find('"') { + let inner = rest[..end].trim(); + if inner.to_ascii_lowercase().contains(".exe") { + return Some(inner.to_string()); + } + } + } + + let lower = value.to_ascii_lowercase(); + let end = lower.find(".exe")?; + Some(value[..end + 4].trim().trim_matches('"').to_string()) + } + + fn candidates(app_name: &str) -> Vec { + let app_name = app_name.trim().trim_matches('"'); + if app_name.is_empty() { + return vec![]; + } + + let mut out = Vec::::new(); + let mut push = |value: String| { + let value = value.trim().trim_matches('"').to_string(); + if value.is_empty() { + return; + } + if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) { + return; + } + out.push(value); + }; + + push(app_name.to_string()); + + let lower = app_name.to_ascii_lowercase(); + if !lower.ends_with(".exe") { + push(format!("{app_name}.exe")); + } + + let snake = { + let mut s = String::new(); + let mut underscore = false; + for c in lower.chars() { + if c.is_ascii_alphanumeric() { + s.push(c); + underscore = false; + continue; + } + if underscore { + continue; + } + s.push('_'); + underscore = true; + } + s.trim_matches('_').to_string() + }; + + if !snake.is_empty() { + push(snake.clone()); + if !snake.ends_with(".exe") { + push(format!("{snake}.exe")); + } + } + + let alnum = lower + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::(); + + if !alnum.is_empty() { + push(alnum.clone()); + push(format!("{alnum}.exe")); + } + + match lower.as_str() { + "sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => { + push("subl".to_string()); + push("subl.exe".to_string()); + push("sublime_text".to_string()); + push("sublime_text.exe".to_string()); + } + _ => {} + } + + out + } + + fn reg_app_path(exe: &str) -> Option { + let exe = exe.trim().trim_matches('"'); + if exe.is_empty() { + return None; + } + + let query = |root: *mut c_void, subkey: &str| -> Option { + let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ; + let mut kind: u32 = 0; + let mut size = 0u32; + + let mut key = subkey.encode_utf16().collect::>(); + key.push(0); + + let status = unsafe { + RegGetValueW( + root, + key.as_ptr(), + std::ptr::null(), + flags, + &mut kind, + std::ptr::null_mut(), + &mut size, + ) + }; + + if status != ERROR_SUCCESS || size == 0 { + return None; + } + + if kind != REG_SZ && kind != REG_EXPAND_SZ { + return None; + } + + let mut data = vec![0u8; size as usize]; + let status = unsafe { + RegGetValueW( + root, + key.as_ptr(), + std::ptr::null(), + flags, + &mut kind, + data.as_mut_ptr() as *mut c_void, + &mut size, + ) + }; + + if status != ERROR_SUCCESS || size < 2 { + return None; + } + + let words = unsafe { + std::slice::from_raw_parts(data.as_ptr().cast::(), (size as usize) / 2) + }; + let len = words.iter().position(|v| *v == 0).unwrap_or(words.len()); + let value = String::from_utf16_lossy(&words[..len]).trim().to_string(); + + if value.is_empty() { + return None; + } + + Some(value) + }; + + let keys = [ + ( + HKEY_CURRENT_USER, + format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"), + ), + ( + HKEY_LOCAL_MACHINE, + format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"), + ), + ( + HKEY_LOCAL_MACHINE, + format!(r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}"), + ), + ]; + + for (root, key) in keys { + let Some(value) = query(root, &key) else { + continue; + }; + + let Some(exe) = extract_exe(&value) else { + continue; + }; + + let exe = expand_env(&exe); + let path = Path::new(exe.trim().trim_matches('"')); + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + + None + } + + let app_name = app_name.trim().trim_matches('"'); + if app_name.is_empty() { + return None; + } + + let direct = Path::new(app_name); + if direct.is_absolute() && direct.exists() { + return Some(direct.to_string_lossy().to_string()); + } + + let key = app_name + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); + + let has_ext = |path: &Path, ext: &str| { + path.extension() + .and_then(|v| v.to_str()) + .map(|v| v.eq_ignore_ascii_case(ext)) + .unwrap_or(false) + }; + + let resolve_cmd = |path: &Path| -> Option { + let bytes = std::fs::read(path).ok()?; + let content = String::from_utf8_lossy(&bytes); + + for token in content.split('"') { + let Some(exe) = extract_exe(token) else { + continue; + }; + + let lower = exe.to_ascii_lowercase(); + if let Some(index) = lower.find("%~dp0") { + let base = path.parent()?; + let suffix = &exe[index + 5..]; + let mut resolved = PathBuf::from(base); + + for part in suffix.replace('/', "\\").split('\\') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + let _ = resolved.pop(); + continue; + } + resolved.push(part); + } + + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + + continue; + } + + let resolved = PathBuf::from(expand_env(&exe)); + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + } + + None + }; + + let resolve_where = |query: &str| -> Option { + let output = Command::new("where") + .creation_flags(CREATE_NO_WINDOW) + .arg(query) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let paths = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect::>(); + + if paths.is_empty() { + return None; + } + + if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { + return Some(path.to_string_lossy().to_string()); + } + + for path in &paths { + if has_ext(path, "cmd") || has_ext(path, "bat") { + if let Some(resolved) = resolve_cmd(path) { + return Some(resolved); + } + } + + if path.extension().is_none() { + let cmd = path.with_extension("cmd"); + if cmd.exists() { + if let Some(resolved) = resolve_cmd(&cmd) { + return Some(resolved); + } + } + + let bat = path.with_extension("bat"); + if bat.exists() { + if let Some(resolved) = resolve_cmd(&bat) { + return Some(resolved); + } + } + } + } + + if !key.is_empty() { + for path in &paths { + let dirs = [ + path.parent(), + path.parent().and_then(|dir| dir.parent()), + path.parent() + .and_then(|dir| dir.parent()) + .and_then(|dir| dir.parent()), + ]; + + for dir in dirs.into_iter().flatten() { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let candidate = entry.path(); + if !has_ext(&candidate, "exe") { + continue; + } + + let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else { + continue; + }; + + let name = stem + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); + + if name.contains(&key) || key.contains(&name) { + return Some(candidate.to_string_lossy().to_string()); + } + } + } + } + } + } + + paths.first().map(|path| path.to_string_lossy().to_string()) + }; + + let list = candidates(app_name); + for query in &list { + if let Some(path) = resolve_where(query) { + return Some(path); + } + } + + let mut exes = Vec::::new(); + for query in &list { + let query = query.trim().trim_matches('"'); + if query.is_empty() { + continue; + } + + let name = Path::new(query) + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or(query); + + let exe = if name.to_ascii_lowercase().ends_with(".exe") { + name.to_string() + } else { + format!("{name}.exe") + }; + + if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) { + continue; + } + + exes.push(exe); + } + + for exe in exes { + if let Some(path) = reg_app_path(&exe) { + return Some(path); + } + } + + None +} + +pub fn open_in_powershell(path: String) -> Result<(), String> { + let path = PathBuf::from(path); + let dir = if path.is_dir() { + path + } else if let Some(parent) = path.parent() { + parent.to_path_buf() + } else { + std::env::current_dir() + .map_err(|e| format!("Failed to determine current directory: {e}"))? + }; + + Command::new("powershell.exe") + .creation_flags(CREATE_NEW_CONSOLE) + .current_dir(dir) + .args(["-NoExit"]) + .spawn() + .map_err(|e| format!("Failed to start PowerShell: {e}"))?; + + Ok(()) +} diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 6d05bfc56e9..8e1b4127a54 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -18,6 +18,7 @@ export const commands = { checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE("wsl_path", { path, mode }), resolveAppPath: (appName: string) => __TAURI_INVOKE("resolve_app_path", { appName }), + openInPowershell: (path: string) => __TAURI_INVOKE("open_in_powershell", { path }), }; /** Events */ diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 983fe394560..188a37eb87d 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -118,7 +118,6 @@ const createPlatform = (): Platform => { async openPath(path: string, app?: string) { const os = ostype() if (os === "windows") { - const resolvedApp = (app && (await commands.resolveAppPath(app))) || app const resolvedPath = await (async () => { if (window.__OPENCODE__?.wsl) { const converted = await commands.wslPath(path, "windows").catch(() => null) @@ -127,6 +126,16 @@ const createPlatform = (): Platform => { return path })() + const resolvedApp = (app && (await commands.resolveAppPath(app))) || app + const isPowershell = (value?: string) => { + if (!value) return false + const name = value.toLowerCase().replaceAll("/", "\\").split("\\").pop() + return name === "powershell" || name === "powershell.exe" + } + if (isPowershell(resolvedApp)) { + await commands.openInPowershell(resolvedPath) + return + } return openerOpenPath(resolvedPath, resolvedApp) } return openerOpenPath(path, app) diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index fae66ab31a8..cc46f7530fb 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.2.10", + "version": "1.2.15", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index a112d793fd7..e9f246af890 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.2.10" +version = "1.2.15" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index c67be670961..63e50b99211 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.2.10", + "version": "1.2.15", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md b/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md new file mode 100644 index 00000000000..6cb21ac8f61 --- /dev/null +++ b/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md @@ -0,0 +1,136 @@ +# Bun shell migration plan + +Practical phased replacement of Bun `$` calls. + +## Goal + +Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`. + +Keep behavior stable while improving safety, testability, and observability. + +Current baseline from audit: + +- 143 runtime command invocations across 17 files +- 84 are git commands +- Largest hotspots: + - `src/cli/cmd/github.ts` (33) + - `src/worktree/index.ts` (22) + - `src/lsp/server.ts` (21) + - `src/installation/index.ts` (20) + - `src/snapshot/index.ts` (18) + +## Decisions + +- Extend `src/util/process.ts` (do not create a separate exec module). +- Proceed with phased migration for both git and non-git paths. +- Keep plugin `$` compatibility in 1.x and remove in 2.0. + +## Non-goals + +- Do not remove plugin `$` compatibility in this effort. +- Do not redesign command semantics beyond what is needed to preserve behavior. + +## Constraints + +- Keep migration phased, not big-bang. +- Minimize behavioral drift. +- Keep these explicit shell-only exceptions: + - `src/session/prompt.ts` raw command execution + - worktree start scripts in `src/worktree/index.ts` + +## Process API proposal (`src/util/process.ts`) + +Add higher-level wrappers on top of current spawn support. + +Core methods: + +- `Process.run(cmd, opts)` +- `Process.text(cmd, opts)` +- `Process.lines(cmd, opts)` +- `Process.status(cmd, opts)` +- `Process.shell(command, opts)` for intentional shell execution + +Git helpers: + +- `Process.git(args, opts)` +- `Process.gitText(args, opts)` + +Shared options: + +- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill` +- `allowFailure` / non-throw mode +- optional redaction + trace metadata + +Standard result shape: + +- `code`, `stdout`, `stderr`, `duration_ms`, `cmd` +- helpers like `text()` and `arrayBuffer()` where useful + +## Phased rollout + +### Phase 0: Foundation + +- Implement Process wrappers in `src/util/process.ts`. +- Refactor `src/util/git.ts` to use Process only. +- Add tests for exit handling, timeout, abort, and output capture. + +### Phase 1: High-impact hotspots + +Migrate these first: + +- `src/cli/cmd/github.ts` +- `src/worktree/index.ts` +- `src/lsp/server.ts` +- `src/installation/index.ts` +- `src/snapshot/index.ts` + +Within each file, migrate git paths first where applicable. + +### Phase 2: Remaining git-heavy files + +Migrate git-centric call sites to `Process.git*` helpers: + +- `src/file/index.ts` +- `src/project/vcs.ts` +- `src/file/watcher.ts` +- `src/storage/storage.ts` +- `src/cli/cmd/pr.ts` + +### Phase 3: Remaining non-git files + +Migrate residual non-git usages: + +- `src/cli/cmd/tui/util/clipboard.ts` +- `src/util/archive.ts` +- `src/file/ripgrep.ts` +- `src/tool/bash.ts` +- `src/cli/cmd/uninstall.ts` + +### Phase 4: Stabilize + +- Remove dead wrappers and one-off patterns. +- Keep plugin `$` compatibility isolated and documented as temporary. +- Create linked 2.0 task for plugin `$` removal. + +## Validation strategy + +- Unit tests for new `Process` methods and options. +- Integration tests on hotspot modules. +- Smoke tests for install, snapshot, worktree, and GitHub flows. +- Regression checks for output parsing behavior. + +## Risk mitigation + +- File-by-file PRs with small diffs. +- Preserve behavior first, simplify second. +- Keep shell-only exceptions explicit and documented. +- Add consistent error shaping and logging at Process layer. + +## Definition of done + +- Runtime Bun `$` usage in `packages/opencode/src` is removed except: + - approved shell-only exceptions + - temporary plugin compatibility path (1.x) +- Git paths use `Process.git*` consistently. +- CI and targeted smoke tests pass. +- 2.0 issue exists for plugin `$` removal. diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d19376adf38..9252468153b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.2.10", + "version": "1.2.15", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 585701c9518..61d11ea7c93 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,46 +2,62 @@ import { z } from "zod" import { Config } from "../src/config/config" +import { TuiConfig } from "../src/config/tui" + +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 + + // 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] + } -const file = process.argv[2] -console.log(file) - -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] + schema.description = [schema.description || "", `default: \`${schema.default}\``] + .filter(Boolean) + .join("\n\n") + .trim() } + }, + }) as Record & { + allowComments?: boolean + allowTrailingCommas?: boolean + } + + // used for json lsps since config supports jsonc + result.allowComments = true + result.allowTrailingCommas = true - schema.description = [schema.description || "", `default: \`${schema.default}\``] - .filter(Boolean) - .join("\n\n") - .trim() - } - }, -}) as Record & { - allowComments?: boolean - allowTrailingCommas?: boolean + 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)) +} diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 765c741c0d6..8b338f1b571 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -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() + private toolStarts = new Set() private permissionQueues = new Map>() 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 { const agents = await this.config.sdk.app .agents( diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 79aaae2bcc4..e3bddcc2263 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -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) { + export async function run(cmd: string[], options?: Process.Options) { log.info("running", { cmd: [which(), ...cmd], ...options, }) - const result = Bun.spawn([which(), ...cmd], { + const result = Process.spawn([which(), ...cmd], { ...options, stdout: "pipe", stderr: "pipe", @@ -28,23 +29,15 @@ export namespace BunProc { }, }) const code = await result.exited - const stdout = result.stdout - ? typeof result.stdout === "number" - ? result.stdout - : await readableStreamToText(result.stdout) - : undefined - const stderr = result.stderr - ? typeof result.stderr === "number" - ? result.stderr - : await readableStreamToText(result.stderr) - : undefined + const stdout = result.stdout ? await text(result.stdout) : undefined + const stderr = result.stderr ? await text(result.stderr) : undefined log.info("done", { code, stdout, stderr, }) if (code !== 0) { - throw new Error(`Command failed with exit code ${result.exitCode}`) + throw new Error(`Command failed with exit code ${code}`) } return result } @@ -93,7 +86,7 @@ export namespace BunProc { "--force", "--exact", // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) - ...(proxied() ? ["--no-cache"] : []), + ...(proxied() || process.env.CI ? ["--no-cache"] : []), "--cwd", Global.Path.cache, pkg + "@" + version, diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts index c567668acd7..a85a6c989c8 100644 --- a/packages/opencode/src/bun/registry.ts +++ b/packages/opencode/src/bun/registry.ts @@ -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 { - 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 }) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index e050a0abf80..95635916413 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -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 @@ -263,17 +265,20 @@ 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 + if (!proc.stdout) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) if (exit !== 0) { prompts.log.error("Failed") prompts.outro("Done") return } - const token = await new Response(proc.stdout).text() await Auth.set(args.url, { type: "wellknown", key: wellknown.auth.env, diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 4aa702359d1..7fb5fda97b9 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ab3d0968925..97c910a47d4 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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: { - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index a2559cfce67..e892f9922d1 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -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 ", @@ -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, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index 38dc402758b..be031296e90 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -10,8 +10,7 @@ import { type ParentProps, } from "solid-js" import { useKeyboard } from "@opentui/solid" -import { useKeybind } from "@tui/context/keybind" -import type { KeybindsConfig } from "@opencode-ai/sdk/v2" +import { type KeybindKey, useKeybind } from "@tui/context/keybind" type Context = ReturnType const ctx = createContext() @@ -22,7 +21,7 @@ export type Slash = { } export type CommandOption = DialogSelectOption & { - keybind?: keyof KeybindsConfig + keybind?: KeybindKey suggested?: boolean slash?: Slash hidden?: boolean diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 9682bee4ead..7bf189f0902 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -16,10 +16,11 @@ import { useToast } from "../ui/toast" const PROVIDER_PRIORITY: Record = { opencode: 0, - anthropic: 1, - "github-copilot": 2, - openai: 3, - google: 4, + "opencode-go": 1, + openai: 2, + "github-copilot": 3, + anthropic: 4, + google: 5, } export function createDialogProviderOptions() { @@ -37,6 +38,7 @@ export function createDialogProviderOptions() { opencode: "(Recommended)", anthropic: "(Claude Max or API key)", openai: "(ChatGPT Plus/Pro or API key)", + "opencode-go": "Low cost subscription for everyone", }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", async onSelect() { @@ -214,16 +216,30 @@ function ApiMethod(props: ApiMethodProps) { title={props.title} placeholder="API key" description={ - props.providerID === "opencode" ? ( - - - OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key. - - - Go to https://opencode.ai/zen to get a key - - - ) : undefined + { + opencode: ( + + + OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API + key. + + + Go to https://opencode.ai/zen to get a key + + + ), + "opencode-go": ( + + + OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models + with generous usage limits. + + + Go to https://opencode.ai/zen and enable OpenCode Go + + + ), + }[props.providerID] ?? undefined } onConfirm={async (value) => { if (!value) return diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index d0a7e5b44ec..73d82248adb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -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", diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 0dbbbc6f9ee..566d66ade50 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,20 +1,22 @@ 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" +import type { TuiConfig } from "@/config/tui" 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 type KeybindKey = keyof NonNullable & string export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ name: "Keybind", init: () => { - const sync = useSync() - const keybinds = createMemo(() => { + const config = useTuiConfig() + const keybinds = createMemo>(() => { return pipe( - sync.data.config.keybinds ?? {}, + (config.keybinds ?? {}) as Record, mapValues((value) => Keybind.parse(value)), ) }) @@ -78,7 +80,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex } return Keybind.fromParsedKey(evt, store.leader) }, - match(key: keyof KeybindsConfig, evt: ParsedKey) { + match(key: KeybindKey, evt: ParsedKey) { const keybind = keybinds()[key] if (!keybind) return false const parsed: Keybind.Info = result.parse(evt) @@ -88,7 +90,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex } } }, - print(key: keyof KeybindsConfig) { + print(key: KeybindKey) { const first = keybinds()[key]?.at(0) if (!first) return "" const result = Keybind.toString(first) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 7fa7e05c3d2..104ccb8be62 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -52,10 +52,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const elapsed = Date.now() - last if (timer) return - // If we just flushed recently (within 16ms), batch this with future events + // If we just flushed recently (within 50ms), batch this with future events // Otherwise, process immediately to avoid latency - if (elapsed < 16) { - timer = setTimeout(flush, 16) + if (elapsed < 50) { + timer = setTimeout(flush, 50) return } flush() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 269ed7ae0bd..d0328178c0d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -29,6 +29,135 @@ import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@opencode-ai/sdk" +// Streaming event batcher: batch ALL high-frequency events during streaming +// to reduce store mutations and avoid GC pressure from markdown re-parsing. +// Events are buffered and flushed every BATCH_FLUSH_MS in a single batch() call. +const BATCH_FLUSH_MS = 100 +const pendingDeltas = new Map>>() +const pendingStatus = new Map() +const pendingMessages = new Map() +const pendingParts = new Map() +const pendingTodos = new Map() +const pendingDiffs = new Map() +let batchTimer: Timer | undefined + +function scheduleBatchFlush(setStore: any, store: any) { + if (batchTimer) return + batchTimer = setTimeout(() => { + batchTimer = undefined + // Snapshot and clear all pending state + const deltas = new Map(pendingDeltas) + const statuses = new Map(pendingStatus) + const messages = new Map(pendingMessages) + const parts = new Map(pendingParts) + const todos = new Map(pendingTodos) + const diffs = new Map(pendingDiffs) + pendingDeltas.clear() + pendingStatus.clear() + pendingMessages.clear() + pendingParts.clear() + pendingTodos.clear() + pendingDiffs.clear() + + batch(() => { + // Flush session statuses + for (const [sessionID, status] of statuses) { + setStore("session_status", sessionID, status) + } + + // Flush todos + for (const [sessionID, todoList] of todos) { + setStore("todo", sessionID, todoList) + } + + // Flush diffs + for (const [sessionID, diff] of diffs) { + setStore("session_diff", sessionID, diff) + } + + // Flush message updates + for (const [, info] of messages) { + const existing = store.message[info.sessionID] + if (!existing) { + setStore("message", info.sessionID, [info]) + continue + } + const result = Binary.search(existing, info.id, (m: any) => m.id) + if (result.found) { + setStore("message", info.sessionID, result.index, reconcile(info)) + continue + } + setStore( + "message", + info.sessionID, + produce((draft: any[]) => { + draft.splice(result.index, 0, info) + }), + ) + const updated = store.message[info.sessionID] + if (updated.length > 100) { + const oldest = updated[0] + setStore( + "message", + info.sessionID, + produce((draft: any[]) => { + draft.shift() + }), + ) + setStore( + "part", + produce((draft: any) => { + delete draft[oldest.id] + }), + ) + } + } + + // Flush part updates + for (const [, part] of parts) { + const existing = store.part[part.messageID] + if (!existing) { + setStore("part", part.messageID, [part]) + continue + } + const result = Binary.search(existing, part.id, (p: any) => p.id) + if (result.found) { + setStore("part", part.messageID, result.index, reconcile(part)) + continue + } + setStore( + "part", + part.messageID, + produce((draft: any[]) => { + draft.splice(result.index, 0, part) + }), + ) + } + + // Flush deltas (accumulated text appends) + for (const [msgID, partMap] of deltas) { + const existing = store.part[msgID] + if (!existing) continue + setStore( + "part", + msgID, + produce((draft: any[]) => { + for (const [pID, fields] of partMap) { + const idx = Binary.search(draft, pID, (p: any) => p.id) + if (!idx.found) continue + const p = draft[idx.index] + for (const [f, d] of fields) { + const key = f as keyof typeof p + ;(p[key] as string) = ((p[key] as string | undefined) ?? "") + d + } + } + }), + ) + } + }) + }, BATCH_FLUSH_MS) +} + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -186,11 +315,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "todo.updated": - setStore("todo", event.properties.sessionID, event.properties.todos) + pendingTodos.set(event.properties.sessionID, event.properties.todos) + scheduleBatchFlush(setStore, store) break case "session.diff": - setStore("session_diff", event.properties.sessionID, event.properties.diff) + pendingDiffs.set(event.properties.sessionID, event.properties.diff) + scheduleBatchFlush(setStore, store) break case "session.deleted": { @@ -221,47 +352,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "session.status": { - setStore("session_status", event.properties.sessionID, event.properties.status) + pendingStatus.set(event.properties.sessionID, event.properties.status) + scheduleBatchFlush(setStore, store) break } case "message.updated": { - const messages = store.message[event.properties.info.sessionID] - if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) - break - } - const result = Binary.search(messages, event.properties.info.id, (m) => m.id) - if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) - break - } - setStore( - "message", - event.properties.info.sessionID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) - const updated = store.message[event.properties.info.sessionID] - if (updated.length > 100) { - const oldest = updated[0] - batch(() => { - setStore( - "message", - event.properties.info.sessionID, - produce((draft) => { - draft.shift() - }), - ) - setStore( - "part", - produce((draft) => { - delete draft[oldest.id] - }), - ) - }) - } + pendingMessages.set(event.properties.info.id, event.properties.info) + scheduleBatchFlush(setStore, store) break } case "message.removed": { @@ -279,45 +377,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] - if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) - break - } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) - if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) - break - } - setStore( - "part", - event.properties.part.messageID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.part) - }), - ) + pendingDeltas.get(event.properties.part.messageID)?.delete(event.properties.part.id) + pendingParts.set(event.properties.part.id, event.properties.part) + scheduleBatchFlush(setStore, store) break } case "message.part.delta": { - const parts = store.part[event.properties.messageID] - if (!parts) break - const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (!result.found) break - setStore( - "part", - event.properties.messageID, - produce((draft) => { - const part = draft[result.index] - const field = event.properties.field as keyof typeof part - const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + event.properties.delta - }), - ) + const { messageID, partID, field, delta } = event.properties + let byMessage = pendingDeltas.get(messageID) + if (!byMessage) { + byMessage = new Map() + pendingDeltas.set(messageID, byMessage) + } + let byPart = byMessage.get(partID) + if (!byPart) { + byPart = new Map() + byMessage.set(partID, byPart) + } + byPart.set(field, (byPart.get(field) ?? "") + delta) + scheduleBatchFlush(setStore, store) break } case "message.part.removed": { + pendingDeltas.get(event.properties.messageID)?.delete(event.properties.partID) const parts = store.part[event.properties.messageID] const result = Binary.search(parts, event.properties.partID, (p) => p.id) if (result.found) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 465ed805ea1..2320c08ccc6 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -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) }) diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx new file mode 100644 index 00000000000..62dbf1ebd1b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -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 + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f5a7f6f6ca4..f20267e0820 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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 + tui: ReturnType }>() 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() } @@ -988,6 +991,7 @@ export function Session() { showGenericToolOutput, diffWrapMode, sync, + tui: tuiConfig, }} > @@ -1762,11 +1766,6 @@ function Write(props: ToolProps) { return props.input.content }) - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - return props.metadata.diagnostics?.[filePath] ?? [] - }) - return ( @@ -1780,15 +1779,7 @@ function Write(props: ToolProps) { content={code()} /> - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} - - )} - - + @@ -1962,7 +1953,7 @@ function Edit(props: ToolProps) { 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" @@ -1972,12 +1963,6 @@ function Edit(props: ToolProps) { const diffContent = createMemo(() => props.metadata.diff) - const diagnostics = createMemo(() => { - const filePath = Filesystem.normalizePath(props.input.filePath ?? "") - const arr = props.metadata.diagnostics?.[filePath] ?? [] - return arr.filter((x) => x.severity === 1).slice(0, 3) - }) - return ( @@ -2003,18 +1988,7 @@ function Edit(props: ToolProps) { removedLineNumberBg={theme.diffRemovedLineNumberBg} /> - - - - {(diagnostic) => ( - - Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "} - {diagnostic.message} - - )} - - - + @@ -2033,7 +2007,7 @@ function ApplyPatch(props: ToolProps) { 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" }) @@ -2086,6 +2060,7 @@ function ApplyPatch(props: ToolProps) { } > + )} @@ -2163,6 +2138,29 @@ function Skill(props: ToolProps) { ) } +function Diagnostics(props: { diagnostics?: Record[]>; filePath: string }) { + const { theme } = useTheme() + const errors = createMemo(() => { + const normalized = Filesystem.normalizePath(props.filePath) + const arr = props.diagnostics?.[normalized] ?? [] + return arr.filter((x) => x.severity === 1).slice(0, 3) + }) + + return ( + + + + {(diagnostic) => ( + + Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message} + + )} + + + + ) +} + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 389fc2418cc..a50cd96fc84 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -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" }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 50f63c3dfbd..750347d9d63 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -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: { diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 7d1aad3a86e..1a8197bf4e8 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -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(() => {}) diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index cb7c691bbde..6d32c63c001 100644 --- a/packages/opencode/src/cli/cmd/tui/util/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -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 { @@ -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", diff --git a/packages/opencode/src/cli/cmd/workspace-serve.ts b/packages/opencode/src/cli/cmd/workspace-serve.ts new file mode 100644 index 00000000000..9b47defd392 --- /dev/null +++ b/packages/opencode/src/cli/cmd/workspace-serve.ts @@ -0,0 +1,59 @@ +import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { Installation } from "../../installation" + +export const WorkspaceServeCommand = cmd({ + command: "workspace-serve", + builder: (yargs) => withNetworkOptions(yargs), + describe: "starts a remote workspace websocket server", + handler: async (args) => { + const opts = await resolveNetworkOptions(args) + const server = Bun.serve<{ id: string }>({ + hostname: opts.hostname, + port: opts.port, + fetch(req, server) { + const url = new URL(req.url) + if (url.pathname === "/ws") { + const id = Bun.randomUUIDv7() + if (server.upgrade(req, { data: { id } })) return + return new Response("Upgrade failed", { status: 400 }) + } + + if (url.pathname === "/health") { + return new Response("ok", { + status: 200, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + + return new Response( + JSON.stringify({ + service: "workspace-server", + ws: `ws://${server.hostname}:${server.port}/ws`, + }), + { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }, + ) + }, + websocket: { + open(ws) { + ws.send(JSON.stringify({ type: "ready", id: ws.data.id })) + }, + message(ws, msg) { + const text = typeof msg === "string" ? msg : msg.toString() + ws.send(JSON.stringify({ type: "message", id: ws.data.id, text })) + }, + close() {}, + }, + }) + + console.log(`workspace websocket server listening on ws://${server.hostname}:${server.port}/ws`) + await new Promise(() => {}) + }, +}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index aad0fd76c4b..28aea4d6777 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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) @@ -289,7 +268,7 @@ export namespace Config { [ "install", // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) - ...(proxied() ? ["--no-cache"] : []), + ...(proxied() || process.env.CI ? ["--no-cache"] : []), ], { cwd: dir }, ).catch((err) => { @@ -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) } } @@ -929,20 +909,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 +983,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) @@ -1240,86 +1203,37 @@ export namespace Config { return result }) + export const { readFile } = ConfigPaths + async function loadFile(filepath: string): Promise { 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 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 normalized = (() => { + if (!data || typeof data !== "object" || Array.isArray(data)) return data + const copy = { ...(data as Record) } + 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 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 +1246,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 +1266,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 +1277,6 @@ export namespace Config { }), ) - export const InvalidError = NamedError.create( - "ConfigInvalidError", - z.object({ - path: z.string(), - issues: z.custom().optional(), - message: z.string().optional(), - }), - ) - export async function get() { return state().then((x) => x.config) } diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 5b4ccf04771..3c9709b5b3b 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -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) { diff --git a/packages/opencode/src/config/migrate-tui-config.ts b/packages/opencode/src/config/migrate-tui-config.ts new file mode 100644 index 00000000000..b426e4fbd10 --- /dev/null +++ b/packages/opencode/src/config/migrate-tui-config.ts @@ -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 = { + $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) { + 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) +} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts new file mode 100644 index 00000000000..396417e9a5b --- /dev/null +++ b/packages/opencode/src/config/paths.ts @@ -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().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 + } +} diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts new file mode 100644 index 00000000000..f9068e3f01d --- /dev/null +++ b/packages/opencode/src/config/tui-schema.ts @@ -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 + >, + ) + .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() diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts new file mode 100644 index 00000000000..f0964f63b35 --- /dev/null +++ b/packages/opencode/src/config/tui.ts @@ -0,0 +1,118 @@ +import { existsSync } from "fs" +import z from "zod" +import { mergeDeep, unique } from "remeda" +import { Config } from "./config" +import { ConfigPaths } from "./paths" +import { migrateTuiConfig } from "./migrate-tui-config" +import { TuiInfo } from "./tui-schema" +import { Instance } from "@/project/instance" +import { Flag } from "@/flag/flag" +import { Log } from "@/util/log" +import { Global } from "@/global" + +export namespace TuiConfig { + const log = Log.create({ service: "tui.config" }) + + export const Info = TuiInfo + + export type Info = z.output + + function mergeInfo(target: Info, source: Info): Info { + return mergeDeep(target, source) + } + + function customPath() { + return Flag.OPENCODE_TUI_CONFIG + } + + const state = Instance.state(async () => { + let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree) + const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree) + const custom = customPath() + const managed = Config.managedConfigDir() + await migrateTuiConfig({ directories, custom, managed }) + // Re-compute after migration since migrateTuiConfig may have created new tui.json files + projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree) + + let result: Info = {} + + for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { + result = mergeInfo(result, await loadFile(file)) + } + + if (custom) { + result = mergeInfo(result, await loadFile(custom)) + log.debug("loaded custom tui config", { path: custom }) + } + + for (const file of projectFiles) { + result = mergeInfo(result, await loadFile(file)) + } + + for (const dir of unique(directories)) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue + for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { + result = mergeInfo(result, await loadFile(file)) + } + } + + if (existsSync(managed)) { + for (const file of ConfigPaths.fileInDirectory(managed, "tui")) { + result = mergeInfo(result, await loadFile(file)) + } + } + + result.keybinds = Config.Keybinds.parse(result.keybinds ?? {}) + + return { + config: result, + } + }) + + export async function get() { + return state().then((x) => x.config) + } + + async function loadFile(filepath: string): Promise { + const text = await ConfigPaths.readFile(filepath) + if (!text) return {} + return load(text, filepath).catch((error) => { + log.warn("failed to load tui config", { path: filepath, error }) + return {} + }) + } + + async function load(text: string, configFilepath: string): Promise { + const data = await ConfigPaths.parseText(text, configFilepath, "empty") + if (!data || typeof data !== "object" || Array.isArray(data)) return {} + + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const normalized = (() => { + const copy = { ...(data as Record) } + if (!("tui" in copy)) return copy + if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) { + delete copy.tui + return copy + } + const tui = copy.tui as Record + delete copy.tui + return { + ...tui, + ...copy, + } + })() + + const parsed = Info.safeParse(normalized) + if (!parsed.success) { + log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) + return {} + } + + return parsed.data + } +} diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 94ffaf5ce04..b9731040c7d 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -67,7 +67,7 @@ export namespace FileIgnore { if (Glob.match(pattern, filepath)) return false } - const parts = filepath.split(sep) + const parts = filepath.split(/[/\\]/) for (let i = 0; i < parts.length; i++) { if (FOLDERS.has(parts[i])) return true } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index ca1eadae8e0..9c4e9cf0284 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -7,6 +7,8 @@ import { NamedError } from "@opencode-ai/util/error" import { lazy } from "../util/lazy" import { $ } from "bun" import { Filesystem } from "../util/filesystem" +import { Process } from "../util/process" +import { text } from "node:stream/consumers" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" import { Log } from "@/util/log" @@ -153,17 +155,19 @@ export namespace Ripgrep { if (platformKey.endsWith("-darwin")) args.push("--include=*/rg") if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg") - const proc = Bun.spawn(args, { + const proc = Process.spawn(args, { cwd: Global.Path.bin, stderr: "pipe", stdout: "pipe", }) - await proc.exited - if (proc.exitCode !== 0) + const exit = await proc.exited + if (exit !== 0) { + const stderr = proc.stderr ? await text(proc.stderr) : "" throw new ExtractionFailedError({ filepath, - stderr: await Bun.readableStreamToText(proc.stderr), + stderr, }) + } } if (config.extension === "zip") { const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer]))) @@ -227,8 +231,7 @@ export namespace Ripgrep { } } - // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist. - // See https://github.com/oven-sh/bun/issues/24012 + // Guard against invalid cwd to provide a consistent ENOENT error. if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) { throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { code: "ENOENT", @@ -237,41 +240,35 @@ export namespace Ripgrep { }) } - const proc = Bun.spawn(args, { + const proc = Process.spawn(args, { cwd: input.cwd, stdout: "pipe", stderr: "ignore", - maxBuffer: 1024 * 1024 * 20, - signal: input.signal, + abort: input.signal, }) - const reader = proc.stdout.getReader() - const decoder = new TextDecoder() - let buffer = "" - - try { - while (true) { - input.signal?.throwIfAborted() + if (!proc.stdout) { + throw new Error("Process output not available") + } - const { done, value } = await reader.read() - if (done) break + let buffer = "" + const stream = proc.stdout as AsyncIterable + for await (const chunk of stream) { + input.signal?.throwIfAborted() - buffer += decoder.decode(value, { stream: true }) - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() || "" + buffer += typeof chunk === "string" ? chunk : chunk.toString() + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || "" - for (const line of lines) { - if (line) yield line - } + for (const line of lines) { + if (line) yield line } - - if (buffer) yield buffer - } finally { - reader.releaseLock() - await proc.exited } + if (buffer) yield buffer + await proc.exited + input.signal?.throwIfAborted() } diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index c85781eb411..efb1c437647 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -61,7 +61,8 @@ export namespace FileTime { const time = get(sessionID, filepath) if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) const mtime = Filesystem.stat(filepath)?.mtime - if (mtime && mtime.getTime() > time.getTime()) { + // Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing + if (mtime && mtime.getTime() > time.getTime() + 50) { throw new Error( `File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`, ) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0049d716d09..e02f191c709 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -7,6 +7,7 @@ export namespace Flag { export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] + export declare const OPENCODE_TUI_CONFIG: string | undefined export declare const OPENCODE_CONFIG_DIR: string | undefined export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") @@ -74,6 +75,17 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", { configurable: false, }) +// Dynamic getter for OPENCODE_TUI_CONFIG +// This must be evaluated at access time, not module load time, +// because tests and external tooling may set this env var at runtime +Object.defineProperty(Flag, "OPENCODE_TUI_CONFIG", { + get() { + return process.env["OPENCODE_TUI_CONFIG"] + }, + enumerable: true, + configurable: false, +}) + // Dynamic getter for OPENCODE_CONFIG_DIR // This must be evaluated at access time, not module load time, // because external tooling may set this env var at runtime diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 47b2d6a12d2..19b9e2cbe97 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,7 +1,8 @@ -import { readableStreamToText } from "bun" +import { text } from "node:stream/consumers" import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" +import { Process } from "../util/process" import { Flag } from "@/flag/flag" export interface Info { @@ -213,12 +214,13 @@ export const rlang: Info = { if (airPath == null) return false try { - const proc = Bun.spawn(["air", "--help"], { + const proc = Process.spawn(["air", "--help"], { stdout: "pipe", stderr: "pipe", }) await proc.exited - const output = await readableStreamToText(proc.stdout) + if (!proc.stdout) return false + const output = await text(proc.stdout) // Check for "Air: An R language server and formatter" const firstLine = output.split("\n")[0] @@ -238,7 +240,7 @@ export const uvformat: Info = { async enabled() { if (await ruff.enabled()) return false if (Bun.which("uv") !== null) { - const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) + const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const code = await proc.exited return code === 0 } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index bab758030b9..b849f778ece 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -8,6 +8,7 @@ import * as Formatter from "./formatter" import { Config } from "../config/config" import { mergeDeep } from "remeda" import { Instance } from "../project/instance" +import { Process } from "../util/process" export namespace Format { const log = Log.create({ service: "format" }) @@ -110,13 +111,15 @@ export namespace Format { for (const item of await getFormatter(ext)) { log.info("running", { command: item.command }) try { - const proc = Bun.spawn({ - cmd: item.command.map((x) => x.replace("$FILE", file)), - cwd: Instance.directory, - env: { ...process.env, ...item.environment }, - stdout: "ignore", - stderr: "ignore", - }) + const proc = Process.spawn( + item.command.map((x) => x.replace("$FILE", file)), + { + cwd: Instance.directory, + env: { ...process.env, ...item.environment }, + stdout: "ignore", + stderr: "ignore", + }, + ) const exit = await proc.exited if (exit !== 0) log.error("failed", { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 65515658862..9af79278c06 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -13,6 +13,7 @@ import { Installation } from "./installation" import { NamedError } from "@opencode-ai/util/error" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" +import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve" import { Filesystem } from "./util/filesystem" import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" @@ -45,7 +46,7 @@ process.on("uncaughtException", (e) => { }) }) -const cli = yargs(hideBin(process.argv)) +let cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) .scriptName("opencode") .wrap(100) @@ -141,6 +142,12 @@ const cli = yargs(hideBin(process.argv)) .command(PrCommand) .command(SessionCommand) .command(DbCommand) + +if (Installation.isLocal()) { + cli = cli.command(WorkspaceServeCommand) +} + +cli = cli .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index a4ebeb5a256..afd297a5ed6 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -4,12 +4,14 @@ import os from "os" import { Global } from "../global" import { Log } from "../util/log" import { BunProc } from "../bun" -import { $, readableStreamToText } from "bun" +import { $ } from "bun" +import { text } from "node:stream/consumers" import fs from "fs/promises" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { Archive } from "../util/archive" +import { Process } from "../util/process" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) @@ -133,7 +135,7 @@ export namespace LSPServer { ) if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], { + await Process.spawn([BunProc.which(), "install", "@vue/language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -263,14 +265,16 @@ export namespace LSPServer { } if (lintBin) { - const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" }) + const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" }) await proc.exited - const help = await readableStreamToText(proc.stdout) - if (help.includes("--lsp")) { - return { - process: spawn(lintBin, ["--lsp"], { - cwd: root, - }), + if (proc.stdout) { + const help = await text(proc.stdout) + if (help.includes("--lsp")) { + return { + process: spawn(lintBin, ["--lsp"], { + cwd: root, + }), + } } } } @@ -372,8 +376,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing gopls") - const proc = Bun.spawn({ - cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], + const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { env: { ...process.env, GOBIN: Global.Path.bin }, stdout: "pipe", stderr: "pipe", @@ -414,8 +417,7 @@ export namespace LSPServer { } if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing rubocop") - const proc = Bun.spawn({ - cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin], + const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -513,7 +515,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "pyright"], { + await Process.spawn([BunProc.which(), "install", "pyright"], { cwd: Global.Path.bin, env: { ...process.env, @@ -746,8 +748,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing csharp-ls via dotnet tool") - const proc = Bun.spawn({ - cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], + const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -786,8 +787,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing fsautocomplete via dotnet tool") - const proc = Bun.spawn({ - cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], + const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -1047,7 +1047,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], { + await Process.spawn([BunProc.which(), "install", "svelte-language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1094,7 +1094,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], { + await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1339,7 +1339,7 @@ export namespace LSPServer { const exists = await Filesystem.exists(js) if (!exists) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], { + await Process.spawn([BunProc.which(), "install", "yaml-language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1518,7 +1518,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "intelephense"], { + await Process.spawn([BunProc.which(), "install", "intelephense"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1615,7 +1615,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "bash-language-server"], { + await Process.spawn([BunProc.which(), "install", "bash-language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1827,7 +1827,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { + await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { cwd: Global.Path.bin, env: { ...process.env, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c29fe03d30..f8ba0d78714 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -577,7 +577,9 @@ export namespace MCP { const toolsResults = await Promise.all( connectedClients.map(async ([clientName, client]) => { - const toolsResult = await client.listTools().catch((e) => { + const mcpEntry = config[clientName] + const timeout = (isMcpConfigured(mcpEntry) ? mcpEntry.timeout : undefined) ?? defaultTimeout ?? DEFAULT_TIMEOUT + const toolsResult = await withTimeout(client.listTools(), timeout).catch((e) => { log.error("failed to get tools", { clientName, error: e.message }) const failedStatus = { status: "failed" as const, diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index adbe2b9fb15..a75a0a02e78 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -138,7 +138,7 @@ export namespace Project { id = roots[0] if (id) { - void Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined) + await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined) } } diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 33083485b5f..dee3fbc5429 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -23,60 +23,6 @@ export namespace Pty { close: (code?: number, reason?: string) => void } - type Subscriber = { - id: number - token: unknown - } - - const sockets = new WeakMap() - const owners = new WeakMap() - let socketCounter = 0 - - const tagSocket = (ws: Socket) => { - if (!ws || typeof ws !== "object") return - const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER) - sockets.set(ws, next) - return next - } - - const token = (ws: Socket) => { - const data = ws.data - if (data === undefined) return - if (data === null) return - if (typeof data !== "object") return data - - const id = (data as { connId?: unknown }).connId - if (typeof id === "number" || typeof id === "string") return id - - const href = (data as { href?: unknown }).href - if (typeof href === "string") return href - - const url = (data as { url?: unknown }).url - if (typeof url === "string") return url - if (url && typeof url === "object") { - const href = (url as { href?: unknown }).href - if (typeof href === "string") return href - return url - } - - const events = (data as { events?: unknown }).events - if (typeof events === "number" || typeof events === "string") return events - if (events && typeof events === "object") { - const id = (events as { connId?: unknown }).connId - if (typeof id === "number" || typeof id === "string") return id - - const id2 = (events as { connection?: unknown }).connection - if (typeof id2 === "number" || typeof id2 === "string") return id2 - - const id3 = (events as { id?: unknown }).id - if (typeof id3 === "number" || typeof id3 === "string") return id3 - - return events - } - - return data - } - // WebSocket control frame: 0x00 + UTF-8 JSON. const meta = (cursor: number) => { const json = JSON.stringify({ cursor }) @@ -141,7 +87,7 @@ export namespace Pty { buffer: string bufferCursor: number cursor: number - subscribers: Map + subscribers: Map } const state = Instance.state( @@ -151,9 +97,9 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const ws of session.subscribers.keys()) { + for (const [key, ws] of session.subscribers.entries()) { try { - ws.close() + if (ws.data === key) ws.close() } catch { // ignore } @@ -224,26 +170,21 @@ export namespace Pty { ptyProcess.onData((chunk) => { session.cursor += chunk.length - for (const [ws, sub] of session.subscribers) { + for (const [key, ws] of session.subscribers.entries()) { if (ws.readyState !== 1) { - session.subscribers.delete(ws) - continue - } - - if (typeof ws === "object" && sockets.get(ws) !== sub.id) { - session.subscribers.delete(ws) + session.subscribers.delete(key) continue } - if (token(ws) !== sub.token) { - session.subscribers.delete(ws) + if (ws.data !== key) { + session.subscribers.delete(key) continue } try { ws.send(chunk) } catch { - session.subscribers.delete(ws) + session.subscribers.delete(key) } } @@ -256,9 +197,9 @@ export namespace Pty { ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) session.info.status = "exited" - for (const ws of session.subscribers.keys()) { + for (const [key, ws] of session.subscribers.entries()) { try { - ws.close() + if (ws.data === key) ws.close() } catch { // ignore } @@ -291,9 +232,9 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const ws of session.subscribers.keys()) { + for (const [key, ws] of session.subscribers.entries()) { try { - ws.close() + if (ws.data === key) ws.close() } catch { // ignore } @@ -325,23 +266,16 @@ export namespace Pty { } log.info("client connected to session", { id }) - const socketId = tagSocket(ws) - if (socketId === undefined) { - ws.close() - return - } - - const previous = owners.get(ws) - if (previous && previous !== id) { - state().get(previous)?.subscribers.delete(ws) - } + // Use ws.data as the unique key for this connection lifecycle. + // If ws.data is undefined, fallback to ws object. + const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws - owners.set(ws, id) - session.subscribers.set(ws, { id: socketId, token: token(ws) }) + // Optionally cleanup if the key somehow exists + session.subscribers.delete(connectionKey) + session.subscribers.set(connectionKey, ws) const cleanup = () => { - session.subscribers.delete(ws) - if (owners.get(ws) === id) owners.delete(ws) + session.subscribers.delete(connectionKey) } const start = session.bufferCursor diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 1195529e06a..12938aeaba0 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -618,6 +618,42 @@ export const SessionRoutes = lazy(() => return c.json(message) }, ) + .delete( + "/:sessionID/message/:messageID", + describeRoute({ + summary: "Delete message", + description: + "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", + operationId: "session.deleteMessage", + responses: { + 200: { + description: "Successfully deleted message", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + messageID: z.string().meta({ description: "Message ID" }), + }), + ), + async (c) => { + const params = c.req.valid("param") + SessionPrompt.assertNotBusy(params.sessionID) + await Session.removeMessage({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return c.json(true) + }, + ) .delete( "/:sessionID/message/:messageID/part/:partID", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 8454a9c3e97..22de477f8d1 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -697,7 +697,9 @@ export namespace Session { async (input) => { // CASCADE delete handles parts automatically Database.use((db) => { - db.delete(MessageTable).where(eq(MessageTable.id, input.messageID)).run() + db.delete(MessageTable) + .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) + .run() Database.effect(() => Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, @@ -717,7 +719,9 @@ export namespace Session { }), async (input) => { Database.use((db) => { - db.delete(PartTable).where(eq(PartTable.id, input.partID)).run() + db.delete(PartTable) + .where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID))) + .run() Database.effect(() => Bus.publish(MessageV2.Event.PartRemoved, { sessionID: input.sessionID, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e7532d20073..d154c6c80c0 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -50,6 +50,7 @@ export namespace SessionProcessor { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} + let finished = false const stream = await LLM.stream(streamInput) for await (const value of stream.fullStream) { @@ -337,6 +338,8 @@ export namespace SessionProcessor { break case "finish": + log.info("stream finish event received") + finished = true break default: @@ -345,7 +348,7 @@ export namespace SessionProcessor { }) continue } - if (needsCompaction) break + if (needsCompaction || finished) break } } catch (e: any) { log.error("process", { diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 833999e7615..cf254b4cef7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -64,6 +64,9 @@ export namespace Snapshot { .nothrow() // Configure git to not convert line endings on Windows await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow() + await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow() + await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow() + await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow() log.info("initialized") } await add(git) @@ -86,7 +89,7 @@ export namespace Snapshot { const git = gitdir() await add(git) const result = - await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` .quiet() .cwd(Instance.directory) .nothrow() @@ -113,7 +116,7 @@ export namespace Snapshot { log.info("restore", { commit: snapshot }) const git = gitdir() const result = - await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` .quiet() .cwd(Instance.worktree) .nothrow() @@ -135,14 +138,15 @@ export namespace Snapshot { for (const file of item.files) { if (files.has(file)) continue log.info("reverting", { file, hash: item.hash }) - const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` - .quiet() - .cwd(Instance.worktree) - .nothrow() + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` + .quiet() + .cwd(Instance.worktree) + .nothrow() if (result.exitCode !== 0) { const relativePath = path.relative(Instance.worktree, file) const checkTree = - await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` .quiet() .cwd(Instance.worktree) .nothrow() @@ -164,7 +168,7 @@ export namespace Snapshot { const git = gitdir() await add(git) const result = - await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` .quiet() .cwd(Instance.worktree) .nothrow() @@ -201,7 +205,7 @@ export namespace Snapshot { const status = new Map() const statuses = - await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` .quiet() .cwd(Instance.directory) .nothrow() @@ -215,7 +219,7 @@ export namespace Snapshot { status.set(file, kind) } - for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` + for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) .nothrow() @@ -225,13 +229,13 @@ export namespace Snapshot { const isBinaryFile = additions === "-" && deletions === "-" const before = isBinaryFile ? "" - : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` + : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` .quiet() .nothrow() .text() const after = isBinaryFile ? "" - : await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` + : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` .quiet() .nothrow() .text() @@ -256,7 +260,10 @@ export namespace Snapshot { async function add(git: string) { await syncExclude(git) - await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow() + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .` + .quiet() + .cwd(Instance.directory) + .nothrow() } async function syncExclude(git: string) { diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 6d7bfd72810..b71f67d82c2 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -33,6 +33,10 @@ export namespace Database { type Journal = { sql: string; timestamp: number }[] + const state = { + sqlite: undefined as BunDatabase | undefined, + } + function time(tag: string) { const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) if (!match) return 0 @@ -69,6 +73,7 @@ export namespace Database { log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") }) const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true }) + state.sqlite = sqlite sqlite.run("PRAGMA journal_mode = WAL") sqlite.run("PRAGMA synchronous = NORMAL") @@ -95,6 +100,14 @@ export namespace Database { return db }) + export function close() { + const sqlite = state.sqlite + if (!sqlite) return + sqlite.close() + state.sqlite = undefined + Client.reset() + } + export type TxOrDb = Transaction | Client const ctx = Context.create<{ @@ -109,7 +122,7 @@ export namespace Database { if (err instanceof Context.NotFound) { const effects: (() => void | Promise)[] = [] const result = ctx.provide({ effects, tx: Client() }, () => callback(Client())) - for (const effect of effects) effect() + for (const effect of effects) Promise.resolve(effect()).catch((e) => log.error("effect failed", { error: e })) return result } throw err @@ -133,7 +146,7 @@ export namespace Database { const result = Client().transaction((tx) => { return ctx.provide({ tx, effects }, () => callback(tx)) }) - for (const effect of effects) effect() + for (const effect of effects) Promise.resolve(effect()).catch((e) => log.error("effect failed", { error: e })) return result } throw err diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 00497d4e3fd..82e7ac1667e 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,7 +1,9 @@ import z from "zod" +import { text } from "node:stream/consumers" import { Tool } from "./tool" import { Filesystem } from "../util/filesystem" import { Ripgrep } from "../file/ripgrep" +import { Process } from "../util/process" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" @@ -44,14 +46,18 @@ export const GrepTool = Tool.define("grep", { } args.push(searchPath) - const proc = Bun.spawn([rgPath, ...args], { + const proc = Process.spawn([rgPath, ...args], { stdout: "pipe", stderr: "pipe", - signal: ctx.abort, + abort: ctx.abort, }) - const output = await new Response(proc.stdout).text() - const errorOutput = await new Response(proc.stderr).text() + if (!proc.stdout || !proc.stderr) { + throw new Error("Process output not available") + } + + const output = await text(proc.stdout) + const errorOutput = await text(proc.stderr) const exitCode = await proc.exited // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 6cb7a691c88..ff84dccec44 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -8,7 +8,6 @@ import { Identifier } from "../id/id" import { Provider } from "../provider/provider" import { Instance } from "../project/instance" import EXIT_DESCRIPTION from "./plan-exit.txt" -import ENTER_DESCRIPTION from "./plan-enter.txt" async function getLastModel(sessionID: string) { for await (const item of MessageV2.stream(sessionID)) { @@ -72,6 +71,7 @@ export const PlanExitTool = Tool.define("plan_exit", { }, }) +/* export const PlanEnterTool = Tool.define("plan_enter", { description: ENTER_DESCRIPTION, parameters: z.object({}), @@ -128,3 +128,4 @@ export const PlanEnterTool = Tool.define("plan_enter", { } }, }) +*/ diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ef0e78ffa86..c6d7fbc1e4b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,3 +1,4 @@ +import { PlanExitTool } from "./plan" import { QuestionTool } from "./question" import { BashTool } from "./bash" import { EditTool } from "./edit" @@ -25,9 +26,10 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" -import { PlanExitTool, PlanEnterTool } from "./plan" + import { ApplyPatchTool } from "./apply_patch" import { Glob } from "../util/glob" +import { pathToFileURL } from "url" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -43,7 +45,7 @@ export namespace ToolRegistry { if (matches.length) await Config.waitForDependencies() for (const match of matches) { const namespace = path.basename(match, path.extname(match)) - const mod = await import(match) + const mod = await import(pathToFileURL(match).href) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } @@ -117,7 +119,7 @@ export namespace ToolRegistry { ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), ...custom, ] } diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts index 201def36a8c..731131357f2 100644 --- a/packages/opencode/src/util/git.ts +++ b/packages/opencode/src/util/git.ts @@ -1,64 +1,35 @@ -import { $ } from "bun" -import { Flag } from "../flag/flag" +import { Process } from "./process" export interface GitResult { exitCode: number - text(): string | Promise - stdout: Buffer | ReadableStream - stderr: Buffer | ReadableStream + text(): string + stdout: Buffer + stderr: Buffer } /** * Run a git command. * - * Uses Bun's lightweight `$` shell by default. When the process is running - * as an ACP client, child processes inherit the parent's stdin pipe which - * carries protocol data – on Windows this causes git to deadlock. In that - * case we fall back to `Bun.spawn` with `stdin: "ignore"`. + * Uses Process helpers with stdin ignored to avoid protocol pipe inheritance + * issues in embedded/client environments. */ export async function git(args: string[], opts: { cwd: string; env?: Record }): Promise { - if (Flag.OPENCODE_CLIENT === "acp") { - try { - const proc = Bun.spawn(["git", ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - cwd: opts.cwd, - env: opts.env ? { ...process.env, ...opts.env } : process.env, - }) - // Read output concurrently with exit to avoid pipe buffer deadlock - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).arrayBuffer(), - new Response(proc.stderr).arrayBuffer(), - ]) - const stdoutBuf = Buffer.from(stdout) - const stderrBuf = Buffer.from(stderr) - return { - exitCode, - text: () => stdoutBuf.toString(), - stdout: stdoutBuf, - stderr: stderrBuf, - } - } catch (error) { - const stderr = Buffer.from(error instanceof Error ? error.message : String(error)) - return { - exitCode: 1, - text: () => "", - stdout: Buffer.alloc(0), - stderr, - } - } - } - - const env = opts.env ? { ...process.env, ...opts.env } : undefined - let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd) - if (env) cmd = cmd.env(env) - const result = await cmd - return { - exitCode: result.exitCode, - text: () => result.text(), - stdout: result.stdout, - stderr: result.stderr, - } + return Process.run(["git", ...args], { + cwd: opts.cwd, + env: opts.env, + stdin: "ignore", + nothrow: true, + }) + .then((result) => ({ + exitCode: result.code, + text: () => result.stdout.toString(), + stdout: result.stdout, + stderr: result.stderr, + })) + .catch((error) => ({ + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr: Buffer.from(error instanceof Error ? error.message : String(error)), + })) } diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts new file mode 100644 index 00000000000..71f001a86a1 --- /dev/null +++ b/packages/opencode/src/util/process.ts @@ -0,0 +1,126 @@ +import { spawn as launch, type ChildProcess } from "child_process" +import { buffer } from "node:stream/consumers" + +export namespace Process { + export type Stdio = "inherit" | "pipe" | "ignore" + + export interface Options { + cwd?: string + env?: NodeJS.ProcessEnv | null + stdin?: Stdio + stdout?: Stdio + stderr?: Stdio + abort?: AbortSignal + kill?: NodeJS.Signals | number + timeout?: number + } + + export interface RunOptions extends Omit { + nothrow?: boolean + } + + export interface Result { + code: number + stdout: Buffer + stderr: Buffer + } + + export class RunFailedError extends Error { + readonly cmd: string[] + readonly code: number + readonly stdout: Buffer + readonly stderr: Buffer + + constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) { + const text = stderr.toString().trim() + super( + text + ? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}` + : `Command failed with code ${code}: ${cmd.join(" ")}`, + ) + this.name = "ProcessRunFailedError" + this.cmd = [...cmd] + this.code = code + this.stdout = stdout + this.stderr = stderr + } + } + + export type Child = ChildProcess & { exited: Promise } + + export function spawn(cmd: string[], opts: Options = {}): Child { + if (cmd.length === 0) throw new Error("Command is required") + opts.abort?.throwIfAborted() + + const proc = launch(cmd[0], cmd.slice(1), { + cwd: opts.cwd, + env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, + stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], + }) + + let closed = false + let timer: ReturnType | undefined + + const abort = () => { + if (closed) return + if (proc.exitCode !== null || proc.signalCode !== null) return + closed = true + + proc.kill(opts.kill ?? "SIGTERM") + + const ms = opts.timeout ?? 5_000 + if (ms <= 0) return + timer = setTimeout(() => proc.kill("SIGKILL"), ms) + } + + const exited = new Promise((resolve, reject) => { + const done = () => { + opts.abort?.removeEventListener("abort", abort) + if (timer) clearTimeout(timer) + } + + proc.once("exit", (code, signal) => { + done() + resolve(code ?? (signal ? 1 : 0)) + }) + + proc.once("error", (error) => { + done() + reject(error) + }) + }) + + if (opts.abort) { + opts.abort.addEventListener("abort", abort, { once: true }) + if (opts.abort.aborted) abort() + } + + const child = proc as Child + child.exited = exited + return child + } + + export async function run(cmd: string[], opts: RunOptions = {}): Promise { + const proc = spawn(cmd, { + cwd: opts.cwd, + env: opts.env, + stdin: opts.stdin, + abort: opts.abort, + kill: opts.kill, + timeout: opts.timeout, + stdout: "pipe", + stderr: "pipe", + }) + + if (!proc.stdout || !proc.stderr) throw new Error("Process output not available") + + const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) + const out = { + code, + stdout, + stderr, + } + if (out.code === 0 || opts.nothrow) return out + throw new RunFailedError(cmd, out.code, out.stdout, out.stderr) + } +} diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 1145a1357d2..1abf578281d 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { Event } from "@opencode-ai/sdk/v2" +import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -19,6 +19,61 @@ type EventController = { close: () => void } +function inProgressText(update: SessionUpdateParams["update"]) { + if (update.sessionUpdate !== "tool_call_update") return undefined + if (update.status !== "in_progress") return undefined + if (!update.content || !Array.isArray(update.content)) return undefined + const first = update.content[0] + if (!first || first.type !== "content") return undefined + if (first.content.type !== "text") return undefined + return first.content.text +} + +function isToolCallUpdate( + update: SessionUpdateParams["update"], +): update is Extract { + return update.sessionUpdate === "tool_call_update" +} + +function toolEvent( + sessionId: string, + cwd: string, + opts: { + callID: string + tool: string + input: Record + } & ({ status: "running"; metadata?: Record } | { status: "pending"; raw: string }), +): GlobalEventEnvelope { + const state: ToolStatePending | ToolStateRunning = + opts.status === "running" + ? { + status: "running", + input: opts.input, + ...(opts.metadata && { metadata: opts.metadata }), + time: { start: Date.now() }, + } + : { + status: "pending", + input: opts.input, + raw: opts.raw, + } + const payload: EventMessagePartUpdated = { + type: "message.part.updated", + properties: { + part: { + id: `part_${opts.callID}`, + sessionID: sessionId, + messageID: `msg_${opts.callID}`, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }, + } + return { directory: cwd, payload } +} + function createEventStream() { const queue: GlobalEventEnvelope[] = [] const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] @@ -65,6 +120,7 @@ function createEventStream() { function createFakeAgent() { const updates = new Map() const chunks = new Map() + const sessionUpdates: SessionUpdateParams[] = [] const record = (sessionId: string, type: string) => { const list = updates.get(sessionId) ?? [] list.push(type) @@ -73,6 +129,7 @@ function createFakeAgent() { const connection = { async sessionUpdate(params: SessionUpdateParams) { + sessionUpdates.push(params) const update = params.update const type = update?.sessionUpdate ?? "unknown" record(params.sessionId, type) @@ -197,7 +254,7 @@ function createFakeAgent() { ;(agent as any).eventAbort.abort() } - return { agent, controller, calls, updates, chunks, stop, sdk, connection } + return { agent, controller, calls, updates, chunks, sessionUpdates, stop, sdk, connection } } describe("acp.agent event subscription", () => { @@ -435,4 +492,192 @@ describe("acp.agent event subscription", () => { }, }) }) + + test("streams running bash output snapshots and de-dupes identical snapshots", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const input = { command: "echo hello", description: "run command" } + + for (const output of ["a", "a", "ab"]) { + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_1", + tool: "bash", + status: "running", + input, + metadata: { output }, + }), + ) + } + await new Promise((r) => setTimeout(r, 20)) + + const snapshots = sessionUpdates + .filter((u) => u.sessionId === sessionId) + .filter((u) => isToolCallUpdate(u.update)) + .map((u) => inProgressText(u.update)) + + expect(snapshots).toEqual(["a", undefined, "ab"]) + stop() + }, + }) + }) + + test("emits synthetic pending before first running update for any tool", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_bash", + tool: "bash", + status: "running", + input: { command: "echo hi", description: "run command" }, + metadata: { output: "hi\n" }, + }), + ) + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_read", + tool: "read", + status: "running", + input: { filePath: "/tmp/example.txt" }, + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const types = sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update.sessionUpdate) + .filter((u) => u === "tool_call" || u === "tool_call_update") + expect(types).toEqual(["tool_call", "tool_call_update", "tool_call", "tool_call_update"]) + + const pendings = sessionUpdates.filter( + (u) => u.sessionId === sessionId && u.update.sessionUpdate === "tool_call", + ) + expect(pendings.every((p) => p.update.sessionUpdate === "tool_call" && p.update.status === "pending")).toBe( + true, + ) + stop() + }, + }) + }) + + test("does not emit duplicate synthetic pending after replayed running tool", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const input = { command: "echo hi", description: "run command" } + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "running", + input, + metadata: { output: "hi\n" }, + time: { start: Date.now() }, + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_1", + tool: "bash", + status: "running", + input, + metadata: { output: "hi\nthere\n" }, + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const types = sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter((u) => "toolCallId" in u && u.toolCallId === "call_1") + .map((u) => u.sessionUpdate) + .filter((u) => u === "tool_call" || u === "tool_call_update") + + expect(types).toEqual(["tool_call", "tool_call_update", "tool_call_update"]) + stop() + }, + }) + }) + + test("clears bash snapshot marker on pending state", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const input = { command: "echo hello", description: "run command" } + + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_1", + tool: "bash", + status: "running", + input, + metadata: { output: "a" }, + }), + ) + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_1", + tool: "bash", + status: "pending", + input, + raw: '{"command":"echo hello"}', + }), + ) + controller.push( + toolEvent(sessionId, cwd, { + callID: "call_1", + tool: "bash", + status: "running", + input, + metadata: { output: "a" }, + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const snapshots = sessionUpdates + .filter((u) => u.sessionId === sessionId) + .filter((u) => isToolCallUpdate(u.update)) + .map((u) => inProgressText(u.update)) + + expect(snapshots).toEqual(["a", "a"]) + stop() + }, + }) + }) }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 56773570af5..f245dc3493d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -56,6 +56,28 @@ test("loads JSON config file", async () => { }) }) +test("ignores legacy tui keys in opencode config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "test/model", + theme: "legacy", + tui: { scroll_speed: 4 }, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("test/model") + expect((config as Record).theme).toBeUndefined() + expect((config as Record).tui).toBeUndefined() + }, + }) +}) + test("loads JSONC config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -110,14 +132,14 @@ test("merges multiple config files with correct precedence", async () => { test("handles environment variable substitution", async () => { const originalEnv = process.env["TEST_VAR"] - process.env["TEST_VAR"] = "test_theme" + process.env["TEST_VAR"] = "test-user" try { await using tmp = await tmpdir({ init: async (dir) => { await writeConfig(dir, { $schema: "https://opencode.ai/config.json", - theme: "{env:TEST_VAR}", + username: "{env:TEST_VAR}", }) }, }) @@ -125,7 +147,7 @@ test("handles environment variable substitution", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.theme).toBe("test_theme") + expect(config.username).toBe("test-user") }, }) } finally { @@ -148,7 +170,7 @@ test("preserves env variables when adding $schema to config", async () => { await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ - theme: "{env:PRESERVE_VAR}", + username: "{env:PRESERVE_VAR}", }), ) }, @@ -157,7 +179,7 @@ test("preserves env variables when adding $schema to config", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.theme).toBe("secret_value") + expect(config.username).toBe("secret_value") // Read the file to verify the env variable was preserved const content = await Filesystem.readText(path.join(tmp.path, "opencode.json")) @@ -178,10 +200,10 @@ test("preserves env variables when adding $schema to config", async () => { test("handles file inclusion substitution", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Filesystem.write(path.join(dir, "included.txt"), "test_theme") + await Filesystem.write(path.join(dir, "included.txt"), "test-user") await writeConfig(dir, { $schema: "https://opencode.ai/config.json", - theme: "{file:included.txt}", + username: "{file:included.txt}", }) }, }) @@ -189,7 +211,7 @@ test("handles file inclusion substitution", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.theme).toBe("test_theme") + expect(config.username).toBe("test-user") }, }) }) @@ -200,7 +222,7 @@ test("handles file inclusion with replacement tokens", async () => { await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`") await writeConfig(dir, { $schema: "https://opencode.ai/config.json", - theme: "{file:included.md}", + username: "{file:included.md}", }) }, }) @@ -208,7 +230,7 @@ test("handles file inclusion with replacement tokens", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.theme).toBe("const out = await Bun.$`echo hi`") + expect(config.username).toBe("const out = await Bun.$`echo hi`") }, }) }) @@ -689,7 +711,7 @@ test("resolves scoped npm plugins in config", async () => { const pluginEntries = config.plugin ?? [] const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href - const expected = import.meta.resolve("@scope/plugin", baseUrl) + const expected = pathToFileURL(path.join(tmp.path, "node_modules", "@scope", "plugin", "index.js")).href expect(pluginEntries.includes(expected)).toBe(true) @@ -1043,7 +1065,6 @@ test("managed settings override project settings", async () => { $schema: "https://opencode.ai/config.json", autoupdate: true, disabled_providers: [], - theme: "dark", }) }, }) @@ -1060,7 +1081,6 @@ test("managed settings override project settings", async () => { const config = await Config.get() expect(config.autoupdate).toBe(false) expect(config.disabled_providers).toEqual(["openai"]) - expect(config.theme).toBe("dark") }, }) }) @@ -1809,7 +1829,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { process.env["TEST_CONFIG_VAR"] = "test_api_key_12345" process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ $schema: "https://opencode.ai/config.json", - theme: "{env:TEST_CONFIG_VAR}", + username: "{env:TEST_CONFIG_VAR}", }) try { @@ -1818,7 +1838,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.theme).toBe("test_api_key_12345") + expect(config.username).toBe("test_api_key_12345") }, }) } finally { @@ -1841,10 +1861,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { try { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file") + await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file") process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ $schema: "https://opencode.ai/config.json", - theme: "{file:./api_key.txt}", + username: "{file:./api_key.txt}", }) }, }) @@ -1852,7 +1872,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { directory: tmp.path, fn: async () => { const config = await Config.get() - expect(config.theme).toBe("secret_key_from_file") + expect(config.username).toBe("secret_key_from_file") }, }) } finally { diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts index c6133317e2c..865af210773 100644 --- a/packages/opencode/test/config/markdown.test.ts +++ b/packages/opencode/test/config/markdown.test.ts @@ -197,7 +197,7 @@ describe("ConfigMarkdown: frontmatter parsing w/ Markdown header", async () => { test("should parse and match", () => { expect(result).toBeDefined() expect(result.data).toEqual({}) - expect(result.content.trim()).toBe(`# Response Formatting Requirements + expect(result.content.trim().replace(/\r\n/g, "\n")).toBe(`# Response Formatting Requirements Always structure your responses using clear markdown formatting: diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts new file mode 100644 index 00000000000..f9de5b041b4 --- /dev/null +++ b/packages/opencode/test/config/tui.test.ts @@ -0,0 +1,510 @@ +import { afterEach, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { TuiConfig } from "../../src/config/tui" +import { Global } from "../../src/global" +import { Filesystem } from "../../src/util/filesystem" + +const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! + +afterEach(async () => { + delete process.env.OPENCODE_CONFIG + delete process.env.OPENCODE_TUI_CONFIG + await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) + await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) + await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) +}) + +test("loads tui config with the same precedence order as server config paths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2)) + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2)) + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "tui.json"), + JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("local") + expect(config.diff_style).toBe("stacked") + }, + }) +}) + +test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + theme: "migrated-theme", + tui: { scroll_speed: 5 }, + keybinds: { app_exit: "ctrl+q" }, + }, + null, + 2, + ), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(5) + expect(config.keybinds?.app_exit).toBe("ctrl+q") + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + expect(JSON.parse(text)).toMatchObject({ + theme: "migrated-theme", + scroll_speed: 5, + }) + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBeUndefined() + expect(server.keybinds).toBeUndefined() + expect(server.tui).toBeUndefined() + expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + }, + }) +}) + +test("migrates project legacy tui keys even when global tui.json already exists", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2)) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + theme: "project-migrated", + tui: { scroll_speed: 2 }, + }, + null, + 2, + ), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("project-migrated") + expect(config.scroll_speed).toBe(2) + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBeUndefined() + expect(server.tui).toBeUndefined() + }, + }) +}) + +test("drops unknown legacy tui keys during migration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + theme: "migrated-theme", + tui: { scroll_speed: 2, foo: 1 }, + }, + null, + 2, + ), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(2) + + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + const migrated = JSON.parse(text) + expect(migrated.scroll_speed).toBe(2) + expect(migrated.foo).toBeUndefined() + }, + }) +}) + +test("skips migration when opencode.jsonc is syntactically invalid", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.jsonc"), + `{ + "theme": "broken-theme", + "tui": { "scroll_speed": 2 } + "username": "still-broken" +}`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBeUndefined() + expect(config.scroll_speed).toBeUndefined() + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false) + expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false) + const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) + expect(source).toContain('"theme": "broken-theme"') + expect(source).toContain('"tui": { "scroll_speed": 2 }') + }, + }) +}) + +test("skips migration when tui.json already exists", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2)) + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.diff_style).toBe("stacked") + expect(config.theme).toBeUndefined() + + const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) + expect(server.theme).toBe("legacy") + expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false) + }, + }) +}) + +test("continues loading tui config when legacy source cannot be stripped", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2)) + }, + }) + + const source = path.join(tmp.path, "opencode.json") + await fs.chmod(source, 0o444) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("readonly-theme") + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + + const server = JSON.parse(await Filesystem.readText(source)) + expect(server.theme).toBe("readonly-theme") + }, + }) + } finally { + await fs.chmod(source, 0o644) + } +}) + +test("migration backup preserves JSONC comments", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.jsonc"), + `{ + // top-level comment + "theme": "jsonc-theme", + "tui": { + // nested comment + "scroll_speed": 1.5 + } +}`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await TuiConfig.get() + const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak")) + expect(backup).toContain("// top-level comment") + expect(backup).toContain("// nested comment") + expect(backup).toContain('"theme": "jsonc-theme"') + expect(backup).toContain('"scroll_speed": 1.5') + }, + }) +}) + +test("migrates legacy tui keys across multiple opencode.json levels", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const nested = path.join(dir, "apps", "client") + await fs.mkdir(nested, { recursive: true }) + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2)) + await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2)) + }, + }) + + await Instance.provide({ + directory: path.join(tmp.path, "apps", "client"), + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("nested-theme") + expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true) + }, + }) +}) + +test("flattens nested tui key inside tui.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + theme: "outer", + tui: { scroll_speed: 3, diff_style: "stacked" }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.scroll_speed).toBe(3) + expect(config.diff_style).toBe("stacked") + // top-level keys take precedence over nested tui keys + expect(config.theme).toBe("outer") + }, + }) +}) + +test("top-level keys in tui.json take precedence over nested tui key", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + diff_style: "auto", + tui: { diff_style: "stacked", scroll_speed: 2 }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.diff_style).toBe("auto") + expect(config.scroll_speed).toBe(2) + }, + }) +}) + +test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" })) + const custom = path.join(dir, "custom-tui.json") + await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" })) + process.env.OPENCODE_TUI_CONFIG = custom + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + // project tui.json overrides the custom path, same as server config precedence + expect(config.theme).toBe("project") + // project also set diff_style, so that wins + expect(config.diff_style).toBe("auto") + }, + }) +}) + +test("merges keybind overrides across precedence layers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } })) + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } })) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds?.theme_list).toBe("ctrl+k") + }, + }) +}) + +test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const custom = path.join(dir, "custom-tui.json") + await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" })) + process.env.OPENCODE_TUI_CONFIG = custom + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("from-env") + expect(config.diff_style).toBe("stacked") + }, + }) +}) + +test("does not derive tui path from OPENCODE_CONFIG", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const customDir = path.join(dir, "custom") + await fs.mkdir(customDir, { recursive: true }) + await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" })) + await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" })) + process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBeUndefined() + }, + }) +}) + +test("applies env and file substitutions in tui.json", async () => { + const original = process.env.TUI_THEME_TEST + process.env.TUI_THEME_TEST = "env-theme" + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q") + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + theme: "{env:TUI_THEME_TEST}", + keybinds: { app_exit: "{file:keybind.txt}" }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("env-theme") + expect(config.keybinds?.app_exit).toBe("ctrl+q") + }, + }) + } finally { + if (original === undefined) delete process.env.TUI_THEME_TEST + else process.env.TUI_THEME_TEST = original + } +}) + +test("applies file substitutions when first identical token is in a commented line", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "theme.txt"), "resolved-theme") + await Bun.write( + path.join(dir, "tui.jsonc"), + `{ + // "theme": "{file:theme.txt}", + "theme": "{file:theme.txt}" +}`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("resolved-theme") + }, + }) +}) + +test("loads managed tui config and gives it highest precedence", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2)) + await fs.mkdir(managedConfigDir, { recursive: true }) + await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2)) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("managed-theme") + }, + }) +}) + +test("loads .opencode/tui.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.diff_style).toBe("stacked") + }, + }) +}) + +test("gracefully falls back when tui.json has invalid JSON", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), "{ invalid json }") + await fs.mkdir(managedConfigDir, { recursive: true }) + await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2)) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.theme).toBe("managed-fallback") + expect(config.keybinds).toBeDefined() + }, + }) +}) diff --git a/packages/opencode/test/ide/ide.test.ts b/packages/opencode/test/ide/ide.test.ts index 4d70140197f..e10700e80ff 100644 --- a/packages/opencode/test/ide/ide.test.ts +++ b/packages/opencode/test/ide/ide.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, afterEach } from "bun:test" import { Ide } from "../../src/ide" describe("ide", () => { - const original = structuredClone(process.env) + const original = { ...process.env } afterEach(() => { Object.keys(process.env).forEach((key) => { diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index dee7045707e..41028633e83 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -3,14 +3,29 @@ import os from "os" import path from "path" import fs from "fs/promises" -import fsSync from "fs" import { afterAll } from "bun:test" // Set XDG env vars FIRST, before any src/ imports const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) await fs.mkdir(dir, { recursive: true }) -afterAll(() => { - fsSync.rmSync(dir, { recursive: true, force: true }) +afterAll(async () => { + const { Database } = await import("../src/storage/db") + Database.close() + const busy = (error: unknown) => + typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" + const rm = async (left: number): Promise => { + Bun.gc(true) + await Bun.sleep(100) + return fs.rm(dir, { recursive: true, force: true }).catch((error) => { + if (!busy(error)) throw error + if (left <= 1) throw error + return rm(left - 1) + }) + } + + // Windows can keep SQLite WAL handles alive until GC finalizers run, so we + // force GC and retry teardown to avoid flaky EBUSY in test cleanup. + await rm(30) }) process.env["XDG_DATA_HOME"] = path.join(dir, "share") diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index d1963f697b9..5664fa32b8a 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -77,7 +77,7 @@ describe("Discovery.pull", () => { test("downloads reference files alongside SKILL.md", async () => { const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL) // find a skill dir that should have reference files (e.g. agents-sdk) - const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk")) + const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) expect(agentsSdk).toBeDefined() if (agentsSdk) { const refs = path.join(agentsSdk, "references") diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 9a0622c4a5a..1804ab5c2a2 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,11 +1,17 @@ import { test, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" +import path from "path" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" +// Git always outputs /-separated paths internally. Snapshot.patch() joins them +// with path.join (which produces \ on Windows) then normalizes back to /. +// This helper does the same for expected values so assertions match cross-platform. +const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") + async function bootstrap() { return tmpdir({ git: true, @@ -35,7 +41,7 @@ test("tracks deleted files correctly", async () => { await $`rm ${tmp.path}/a.txt`.quiet() - expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/a.txt`) + expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "a.txt")) }, }) }) @@ -143,7 +149,7 @@ test("binary file handling", async () => { await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/image.png`) + expect(patch.files).toContain(fwd(tmp.path, "image.png")) await Snapshot.revert([patch]) expect( @@ -164,9 +170,9 @@ test("symlink handling", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - await $`ln -s ${tmp.path}/a.txt ${tmp.path}/link.txt`.quiet() + await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file") - expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/link.txt`) + expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "link.txt")) }, }) }) @@ -181,7 +187,7 @@ test("large file handling", async () => { await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) - expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/large.txt`) + expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "large.txt")) }, }) }) @@ -222,9 +228,9 @@ test("special characters in filenames", async () => { await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") const files = (await Snapshot.patch(before!)).files - expect(files).toContain(`${tmp.path}/file with spaces.txt`) - expect(files).toContain(`${tmp.path}/file-with-dashes.txt`) - expect(files).toContain(`${tmp.path}/file_with_underscores.txt`) + expect(files).toContain(fwd(tmp.path, "file with spaces.txt")) + expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt")) + expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt")) }, }) }) @@ -293,10 +299,10 @@ test("unicode filenames", async () => { expect(before).toBeTruthy() const unicodeFiles = [ - { path: `${tmp.path}/文件.txt`, content: "chinese content" }, - { path: `${tmp.path}/🚀rocket.txt`, content: "emoji content" }, - { path: `${tmp.path}/café.txt`, content: "accented content" }, - { path: `${tmp.path}/файл.txt`, content: "cyrillic content" }, + { path: fwd(tmp.path, "文件.txt"), content: "chinese content" }, + { path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" }, + { path: fwd(tmp.path, "café.txt"), content: "accented content" }, + { path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" }, ] for (const file of unicodeFiles) { @@ -329,8 +335,8 @@ test.skip("unicode filenames modification and restore", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const chineseFile = `${tmp.path}/文件.txt` - const cyrillicFile = `${tmp.path}/файл.txt` + const chineseFile = fwd(tmp.path, "文件.txt") + const cyrillicFile = fwd(tmp.path, "файл.txt") await Filesystem.write(chineseFile, "original chinese") await Filesystem.write(cyrillicFile, "original cyrillic") @@ -362,7 +368,7 @@ test("unicode filenames in subdirectories", async () => { expect(before).toBeTruthy() await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet() - const deepFile = `${tmp.path}/目录/подкаталог/文件.txt` + const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt") await Filesystem.write(deepFile, "deep unicode content") const patch = await Snapshot.patch(before!) @@ -388,7 +394,7 @@ test("very long filenames", async () => { expect(before).toBeTruthy() const longName = "a".repeat(200) + ".txt" - const longFile = `${tmp.path}/${longName}` + const longFile = fwd(tmp.path, longName) await Filesystem.write(longFile, "long filename content") @@ -419,9 +425,9 @@ test("hidden files", async () => { await Filesystem.write(`${tmp.path}/.config`, "config content") const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/.hidden`) - expect(patch.files).toContain(`${tmp.path}/.gitignore`) - expect(patch.files).toContain(`${tmp.path}/.config`) + expect(patch.files).toContain(fwd(tmp.path, ".hidden")) + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) + expect(patch.files).toContain(fwd(tmp.path, ".config")) }, }) }) @@ -436,12 +442,12 @@ test("nested symlinks", async () => { await $`mkdir -p ${tmp.path}/sub/dir`.quiet() await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content") - await $`ln -s ${tmp.path}/sub/dir/target.txt ${tmp.path}/sub/dir/link.txt`.quiet() - await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet() + await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file") + await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir") const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/sub/dir/link.txt`) - expect(patch.files).toContain(`${tmp.path}/sub-link`) + expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt")) + expect(patch.files).toContain(fwd(tmp.path, "sub-link")) }, }) }) @@ -476,7 +482,7 @@ test("circular symlinks", async () => { expect(before).toBeTruthy() // Create circular symlink - await $`ln -s ${tmp.path}/circular ${tmp.path}/circular`.quiet().nothrow() + await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {}) const patch = await Snapshot.patch(before!) expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash @@ -499,11 +505,11 @@ test("gitignore changes", async () => { const patch = await Snapshot.patch(before!) // Should track gitignore itself - expect(patch.files).toContain(`${tmp.path}/.gitignore`) + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) // Should track normal files - expect(patch.files).toContain(`${tmp.path}/normal.txt`) + expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) // Should not track ignored files (git won't see them) - expect(patch.files).not.toContain(`${tmp.path}/test.ignored`) + expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored")) }, }) }) @@ -523,8 +529,8 @@ test("git info exclude changes", async () => { await Bun.write(`${tmp.path}/normal.txt`, "normal content") const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/normal.txt`) - expect(patch.files).not.toContain(`${tmp.path}/ignored.txt`) + expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) + expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt")) const after = await Snapshot.track() const diffs = await Snapshot.diffFull(before!, after!) @@ -542,7 +548,7 @@ test("git info exclude keeps global excludes", async () => { const global = `${tmp.path}/global.ignore` const config = `${tmp.path}/global.gitconfig` await Bun.write(global, "global.tmp\n") - await Bun.write(config, `[core]\n\texcludesFile = ${global}\n`) + await Bun.write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`) const prev = process.env.GIT_CONFIG_GLOBAL process.env.GIT_CONFIG_GLOBAL = config @@ -559,9 +565,9 @@ test("git info exclude keeps global excludes", async () => { await Bun.write(`${tmp.path}/normal.txt`, "normal content") const patch = await Snapshot.patch(before!) - expect(patch.files).toContain(`${tmp.path}/normal.txt`) - expect(patch.files).not.toContain(`${tmp.path}/global.tmp`) - expect(patch.files).not.toContain(`${tmp.path}/info.tmp`) + expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) + expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp")) + expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp")) } finally { if (prev) process.env.GIT_CONFIG_GLOBAL = prev else delete process.env.GIT_CONFIG_GLOBAL @@ -610,7 +616,7 @@ test("snapshot state isolation between projects", async () => { const before1 = await Snapshot.track() await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content") const patch1 = await Snapshot.patch(before1!) - expect(patch1.files).toContain(`${tmp1.path}/project1.txt`) + expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt")) }, }) @@ -620,10 +626,10 @@ test("snapshot state isolation between projects", async () => { const before2 = await Snapshot.track() await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content") const patch2 = await Snapshot.patch(before2!) - expect(patch2.files).toContain(`${tmp2.path}/project2.txt`) + expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt")) // Ensure project1 files don't appear in project2 - expect(patch2.files).not.toContain(`${tmp1?.path}/project1.txt`) + expect(patch2.files).not.toContain(fwd(tmp1?.path ?? "", "project1.txt")) }, }) }) @@ -647,7 +653,7 @@ test("patch detects changes in secondary worktree", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - const worktreeFile = `${worktreePath}/worktree.txt` + const worktreeFile = fwd(worktreePath, "worktree.txt") await Filesystem.write(worktreeFile, "worktree content") const patch = await Snapshot.patch(before!) @@ -681,7 +687,7 @@ test("revert only removes files in invoking worktree", async () => { const before = await Snapshot.track() expect(before).toBeTruthy() - const worktreeFile = `${worktreePath}/worktree.txt` + const worktreeFile = fwd(worktreePath, "worktree.txt") await Filesystem.write(worktreeFile, "worktree content") const patch = await Snapshot.patch(before!) @@ -832,7 +838,7 @@ test("revert should not delete files that existed but were deleted in snapshot", await Filesystem.write(`${tmp.path}/a.txt`, "recreated content") const patch = await Snapshot.patch(snapshot2!) - expect(patch.files).toContain(`${tmp.path}/a.txt`) + expect(patch.files).toContain(fwd(tmp.path, "a.txt")) await Snapshot.revert([patch]) @@ -861,8 +867,8 @@ test("revert preserves file that existed in snapshot when deleted then recreated await Filesystem.write(`${tmp.path}/newfile.txt`, "new") const patch = await Snapshot.patch(snapshot!) - expect(patch.files).toContain(`${tmp.path}/existing.txt`) - expect(patch.files).toContain(`${tmp.path}/newfile.txt`) + expect(patch.files).toContain(fwd(tmp.path, "existing.txt")) + expect(patch.files).toContain(fwd(tmp.path, "newfile.txt")) await Snapshot.revert([patch]) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index db05f8f623f..ac93016927a 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import os from "os" import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" @@ -138,14 +139,14 @@ describe("tool.bash permissions", () => { await bash.execute( { command: "ls", - workdir: "/tmp", - description: "List /tmp", + workdir: os.tmpdir(), + description: "List temp dir", }, testCtx, ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain("/tmp/*") + expect(extDirReq!.patterns).toContain(path.join(os.tmpdir(), "*")) }, }) }) @@ -366,7 +367,8 @@ describe("tool.bash truncation", () => { ctx, ) expect((result.metadata as any).truncated).toBe(false) - expect(result.output).toBe("hello\n") + const eol = process.platform === "win32" ? "\r\n" : "\n" + expect(result.output).toBe(`hello${eol}`) }, }) }) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 33c5e2c7397..a75f767b3b6 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -65,7 +65,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside/file.txt" - const expected = path.join(path.dirname(target), "*") + const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/") await Instance.provide({ directory, @@ -91,7 +91,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside" - const expected = path.join(target, "*") + const expected = path.join(target, "*").replaceAll("\\", "/") await Instance.provide({ directory, diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 4f1a7d28e8c..695d48ccbbc 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -293,19 +293,26 @@ describe("tool.write", () => { }) describe("error handling", () => { - test("throws error for paths outside project", async () => { + test("throws error when OS denies write access", async () => { await using tmp = await tmpdir() - const outsidePath = "/etc/passwd" + const readonlyPath = path.join(tmp.path, "readonly.txt") + + // Create a read-only file + await fs.writeFile(readonlyPath, "test", "utf-8") + await fs.chmod(readonlyPath, 0o444) await Instance.provide({ directory: tmp.path, fn: async () => { + const { FileTime } = await import("../../src/file/time") + FileTime.read(ctx.sessionID, readonlyPath) + const write = await WriteTool.init() await expect( write.execute( { - filePath: outsidePath, - content: "test", + filePath: readonlyPath, + content: "new content", }, ctx, ), diff --git a/packages/opencode/test/util/glob.test.ts b/packages/opencode/test/util/glob.test.ts index ae1bcdcf82e..e58d92c85c6 100644 --- a/packages/opencode/test/util/glob.test.ts +++ b/packages/opencode/test/util/glob.test.ts @@ -63,7 +63,7 @@ describe("Glob", () => { const results = await Glob.scan("**/*.txt", { cwd: tmp.path }) - expect(results).toEqual(["nested/deep.txt"]) + expect(results).toEqual([path.join("nested", "deep.txt")]) }) test("returns empty array for no matches", async () => { @@ -82,7 +82,7 @@ describe("Glob", () => { const results = await Glob.scan("**/*.txt", { cwd: tmp.path }) - expect(results).toEqual(["realdir/file.txt"]) + expect(results).toEqual([path.join("realdir", "file.txt")]) }) test("follows symlinks when symlink option is true", async () => { @@ -93,7 +93,7 @@ describe("Glob", () => { const results = await Glob.scan("**/*.txt", { cwd: tmp.path, symlink: true }) - expect(results.sort()).toEqual(["linkdir/file.txt", "realdir/file.txt"]) + expect(results.sort()).toEqual([path.join("linkdir", "file.txt"), path.join("realdir", "file.txt")]) }) test("includes dotfiles when dot option is true", async () => { diff --git a/packages/opencode/test/util/process.test.ts b/packages/opencode/test/util/process.test.ts new file mode 100644 index 00000000000..ce599d6d8f0 --- /dev/null +++ b/packages/opencode/test/util/process.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { Process } from "../../src/util/process" + +function node(script: string) { + return [process.execPath, "-e", script] +} + +describe("util.process", () => { + test("captures stdout and stderr", async () => { + const out = await Process.run(node('process.stdout.write("out");process.stderr.write("err")')) + expect(out.code).toBe(0) + expect(out.stdout.toString()).toBe("out") + expect(out.stderr.toString()).toBe("err") + }) + + test("returns code when nothrow is enabled", async () => { + const out = await Process.run(node("process.exit(7)"), { nothrow: true }) + expect(out.code).toBe(7) + }) + + test("throws RunFailedError on non-zero exit", async () => { + const err = await Process.run(node('process.stderr.write("bad");process.exit(3)')).catch((error) => error) + expect(err).toBeInstanceOf(Process.RunFailedError) + if (!(err instanceof Process.RunFailedError)) throw err + expect(err.code).toBe(3) + expect(err.stderr.toString()).toBe("bad") + }) + + test("aborts a running process", async () => { + const abort = new AbortController() + const started = Date.now() + setTimeout(() => abort.abort(), 25) + + const out = await Process.run(node("setInterval(() => {}, 1000)"), { + abort: abort.signal, + nothrow: true, + }) + + expect(out.code).not.toBe(0) + expect(Date.now() - started).toBeLessThan(1000) + }, 3000) + + test("kills after timeout when process ignores terminate signal", async () => { + if (process.platform === "win32") return + + const abort = new AbortController() + const started = Date.now() + setTimeout(() => abort.abort(), 25) + + const out = await Process.run(node('process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'), { + abort: abort.signal, + nothrow: true, + timeout: 25, + }) + + expect(out.code).not.toBe(0) + expect(Date.now() - started).toBeLessThan(1000) + }, 3000) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 623a117929f..e476c41e2fb 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.2.10", + "version": "1.2.15", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts index 647b56e5e2d..d2fe49f23ce 100755 --- a/packages/plugin/script/publish.ts +++ b/packages/plugin/script/publish.ts @@ -1,8 +1,9 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" import { $ } from "bun" +import { fileURLToPath } from "url" -const dir = new URL("..", import.meta.url).pathname +const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) await $`bun tsc` diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 4fe0794d0ce..ffbdf219824 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,12 +1,12 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.2.10", + "version": "1.2.15", "type": "module", "license": "MIT", "scripts": { "typecheck": "tsgo --noEmit", - "build": "./script/build.ts" + "build": "bun ./script/build.ts" }, "exports": { ".": "./src/index.ts", diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 7568c54b0f2..268233a012e 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun +import { fileURLToPath } from "url" -const dir = new URL("..", import.meta.url).pathname +const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) import { $ } from "bun" diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts index c21f06230d1..ea5c5d634b2 100755 --- a/packages/sdk/js/script/publish.ts +++ b/packages/sdk/js/script/publish.ts @@ -2,8 +2,9 @@ import { Script } from "@opencode-ai/script" import { $ } from "bun" +import { fileURLToPath } from "url" -const dir = new URL("..", import.meta.url).pathname +const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) const pkg = (await import("../package.json").then((m) => m.default)) as { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b4848e60540..6165c0f7b09 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -107,6 +107,8 @@ import type { SessionCreateErrors, SessionCreateResponses, SessionDeleteErrors, + SessionDeleteMessageErrors, + SessionDeleteMessageResponses, SessionDeleteResponses, SessionDiffResponses, SessionForkResponses, @@ -1561,6 +1563,42 @@ export class Session2 extends HeyApiClient { }) } + /** + * Delete message + * + * Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message. + */ + public deleteMessage( + parameters: { + sessionID: string + messageID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + SessionDeleteMessageResponses, + SessionDeleteMessageErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/message/{messageID}", + ...options, + ...params, + }) + } + /** * Get message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4050ef15738..be6c00cf445 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -991,388 +991,6 @@ export type GlobalEvent = { payload: Event } -/** - * Custom keybind configurations - */ -export type KeybindsConfig = { - /** - * Leader key for keybind combinations - */ - leader?: string - /** - * Exit the application - */ - app_exit?: string - /** - * Open external editor - */ - editor_open?: string - /** - * List available themes - */ - theme_list?: string - /** - * Toggle sidebar - */ - sidebar_toggle?: string - /** - * Toggle session scrollbar - */ - scrollbar_toggle?: string - /** - * Toggle username visibility - */ - username_toggle?: string - /** - * View status - */ - status_view?: string - /** - * Export session to editor - */ - session_export?: string - /** - * Create a new session - */ - session_new?: string - /** - * List all sessions - */ - session_list?: string - /** - * Show session timeline - */ - session_timeline?: string - /** - * Fork session from message - */ - session_fork?: string - /** - * Rename session - */ - session_rename?: string - /** - * Delete session - */ - session_delete?: string - /** - * Delete stash entry - */ - stash_delete?: string - /** - * Open provider list from model dialog - */ - model_provider_list?: string - /** - * Toggle model favorite status - */ - model_favorite_toggle?: string - /** - * Share current session - */ - session_share?: string - /** - * Unshare current session - */ - session_unshare?: string - /** - * Interrupt current session - */ - session_interrupt?: string - /** - * Compact the session - */ - session_compact?: string - /** - * Scroll messages up by one page - */ - messages_page_up?: string - /** - * Scroll messages down by one page - */ - messages_page_down?: string - /** - * Scroll messages up by one line - */ - messages_line_up?: string - /** - * Scroll messages down by one line - */ - messages_line_down?: string - /** - * Scroll messages up by half page - */ - messages_half_page_up?: string - /** - * Scroll messages down by half page - */ - messages_half_page_down?: string - /** - * Navigate to first message - */ - messages_first?: string - /** - * Navigate to last message - */ - messages_last?: string - /** - * Navigate to next message - */ - messages_next?: string - /** - * Navigate to previous message - */ - messages_previous?: string - /** - * Navigate to last user message - */ - messages_last_user?: string - /** - * Copy message - */ - messages_copy?: string - /** - * Undo message - */ - messages_undo?: string - /** - * Redo message - */ - messages_redo?: string - /** - * Toggle code block concealment in messages - */ - messages_toggle_conceal?: string - /** - * Toggle tool details visibility - */ - tool_details?: string - /** - * List available models - */ - model_list?: string - /** - * Next recently used model - */ - model_cycle_recent?: string - /** - * Previous recently used model - */ - model_cycle_recent_reverse?: string - /** - * Next favorite model - */ - model_cycle_favorite?: string - /** - * Previous favorite model - */ - model_cycle_favorite_reverse?: string - /** - * List available commands - */ - command_list?: string - /** - * List agents - */ - agent_list?: string - /** - * Next agent - */ - agent_cycle?: string - /** - * Previous agent - */ - agent_cycle_reverse?: string - /** - * Cycle model variants - */ - variant_cycle?: string - /** - * Clear input field - */ - input_clear?: string - /** - * Paste from clipboard - */ - input_paste?: string - /** - * Submit input - */ - input_submit?: string - /** - * Insert newline in input - */ - input_newline?: string - /** - * Move cursor left in input - */ - input_move_left?: string - /** - * Move cursor right in input - */ - input_move_right?: string - /** - * Move cursor up in input - */ - input_move_up?: string - /** - * Move cursor down in input - */ - input_move_down?: string - /** - * Select left in input - */ - input_select_left?: string - /** - * Select right in input - */ - input_select_right?: string - /** - * Select up in input - */ - input_select_up?: string - /** - * Select down in input - */ - input_select_down?: string - /** - * Move to start of line in input - */ - input_line_home?: string - /** - * Move to end of line in input - */ - input_line_end?: string - /** - * Select to start of line in input - */ - input_select_line_home?: string - /** - * Select to end of line in input - */ - input_select_line_end?: string - /** - * Move to start of visual line in input - */ - input_visual_line_home?: string - /** - * Move to end of visual line in input - */ - input_visual_line_end?: string - /** - * Select to start of visual line in input - */ - input_select_visual_line_home?: string - /** - * Select to end of visual line in input - */ - input_select_visual_line_end?: string - /** - * Move to start of buffer in input - */ - input_buffer_home?: string - /** - * Move to end of buffer in input - */ - input_buffer_end?: string - /** - * Select to start of buffer in input - */ - input_select_buffer_home?: string - /** - * Select to end of buffer in input - */ - input_select_buffer_end?: string - /** - * Delete line in input - */ - input_delete_line?: string - /** - * Delete to end of line in input - */ - input_delete_to_line_end?: string - /** - * Delete to start of line in input - */ - input_delete_to_line_start?: string - /** - * Backspace in input - */ - input_backspace?: string - /** - * Delete character in input - */ - input_delete?: string - /** - * Undo in input - */ - input_undo?: string - /** - * Redo in input - */ - input_redo?: string - /** - * Move word forward in input - */ - input_word_forward?: string - /** - * Move word backward in input - */ - input_word_backward?: string - /** - * Select word forward in input - */ - input_select_word_forward?: string - /** - * Select word backward in input - */ - input_select_word_backward?: string - /** - * Delete word forward in input - */ - input_delete_word_forward?: string - /** - * Delete word backward in input - */ - input_delete_word_backward?: string - /** - * Previous history item - */ - history_previous?: string - /** - * Next history item - */ - history_next?: string - /** - * Next child session - */ - session_child_cycle?: string - /** - * Previous child session - */ - session_child_cycle_reverse?: string - /** - * Go to parent session - */ - session_parent?: string - /** - * Suspend terminal - */ - terminal_suspend?: string - /** - * Toggle terminal title - */ - terminal_title_toggle?: string - /** - * Toggle tips on home screen - */ - tips_toggle?: string - /** - * Toggle thinking blocks visibility - */ - display_thinking?: string -} - /** * Log level */ @@ -1672,34 +1290,7 @@ export type Config = { * JSON schema reference for configuration validation */ $schema?: string - /** - * Theme name to use for the interface - */ - theme?: string - keybinds?: KeybindsConfig logLevel?: LogLevel - /** - * TUI specific settings - */ - tui?: { - /** - * TUI scroll speed - */ - scroll_speed?: number - /** - * Scroll acceleration settings - */ - scroll_acceleration?: { - /** - * Enable scroll acceleration - */ - enabled: boolean - } - /** - * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column - */ - diff_style?: "auto" | "stacked" - } server?: ServerConfig /** * Command configuration, see https://opencode.ai/docs/commands @@ -3564,6 +3155,46 @@ export type SessionPromptResponses = { export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses] +export type SessionDeleteMessageData = { + body?: never + path: { + /** + * Session ID + */ + sessionID: string + /** + * Message ID + */ + messageID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/message/{messageID}" +} + +export type SessionDeleteMessageErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionDeleteMessageError = SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors] + +export type SessionDeleteMessageResponses = { + /** + * Successfully deleted message + */ + 200: boolean +} + +export type SessionDeleteMessageResponse = SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses] + export type SessionMessageData = { body?: never path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2741c2362ec..0f9c6f0203c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2630,6 +2630,76 @@ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" } ] + }, + "delete": { + "operationId": "session.deleteMessage", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Session ID" + }, + { + "in": "path", + "name": "messageID", + "schema": { + "type": "string" + }, + "required": true, + "description": "Message ID" + } + ], + "summary": "Delete message", + "description": "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", + "responses": { + "200": { + "description": "Successfully deleted message", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + } + ] } }, "/session/{sessionID}/message/{messageID}/part/{partID}": { @@ -8651,483 +8721,6 @@ }, "required": ["directory", "payload"] }, - "KeybindsConfig": { - "description": "Custom keybind configurations", - "type": "object", - "properties": { - "leader": { - "description": "Leader key for keybind combinations", - "default": "ctrl+x", - "type": "string" - }, - "app_exit": { - "description": "Exit the application", - "default": "ctrl+c,ctrl+d,q", - "type": "string" - }, - "editor_open": { - "description": "Open external editor", - "default": "e", - "type": "string" - }, - "theme_list": { - "description": "List available themes", - "default": "t", - "type": "string" - }, - "sidebar_toggle": { - "description": "Toggle sidebar", - "default": "b", - "type": "string" - }, - "scrollbar_toggle": { - "description": "Toggle session scrollbar", - "default": "none", - "type": "string" - }, - "username_toggle": { - "description": "Toggle username visibility", - "default": "none", - "type": "string" - }, - "status_view": { - "description": "View status", - "default": "s", - "type": "string" - }, - "session_export": { - "description": "Export session to editor", - "default": "x", - "type": "string" - }, - "session_new": { - "description": "Create a new session", - "default": "n", - "type": "string" - }, - "session_list": { - "description": "List all sessions", - "default": "l", - "type": "string" - }, - "session_timeline": { - "description": "Show session timeline", - "default": "g", - "type": "string" - }, - "session_fork": { - "description": "Fork session from message", - "default": "none", - "type": "string" - }, - "session_rename": { - "description": "Rename session", - "default": "ctrl+r", - "type": "string" - }, - "session_delete": { - "description": "Delete session", - "default": "ctrl+d", - "type": "string" - }, - "stash_delete": { - "description": "Delete stash entry", - "default": "ctrl+d", - "type": "string" - }, - "model_provider_list": { - "description": "Open provider list from model dialog", - "default": "ctrl+a", - "type": "string" - }, - "model_favorite_toggle": { - "description": "Toggle model favorite status", - "default": "ctrl+f", - "type": "string" - }, - "session_share": { - "description": "Share current session", - "default": "none", - "type": "string" - }, - "session_unshare": { - "description": "Unshare current session", - "default": "none", - "type": "string" - }, - "session_interrupt": { - "description": "Interrupt current session", - "default": "escape", - "type": "string" - }, - "session_compact": { - "description": "Compact the session", - "default": "c", - "type": "string" - }, - "messages_page_up": { - "description": "Scroll messages up by one page", - "default": "pageup,ctrl+alt+b", - "type": "string" - }, - "messages_page_down": { - "description": "Scroll messages down by one page", - "default": "pagedown,ctrl+alt+f", - "type": "string" - }, - "messages_line_up": { - "description": "Scroll messages up by one line", - "default": "ctrl+alt+y", - "type": "string" - }, - "messages_line_down": { - "description": "Scroll messages down by one line", - "default": "ctrl+alt+e", - "type": "string" - }, - "messages_half_page_up": { - "description": "Scroll messages up by half page", - "default": "ctrl+alt+u", - "type": "string" - }, - "messages_half_page_down": { - "description": "Scroll messages down by half page", - "default": "ctrl+alt+d", - "type": "string" - }, - "messages_first": { - "description": "Navigate to first message", - "default": "ctrl+g,home", - "type": "string" - }, - "messages_last": { - "description": "Navigate to last message", - "default": "ctrl+alt+g,end", - "type": "string" - }, - "messages_next": { - "description": "Navigate to next message", - "default": "none", - "type": "string" - }, - "messages_previous": { - "description": "Navigate to previous message", - "default": "none", - "type": "string" - }, - "messages_last_user": { - "description": "Navigate to last user message", - "default": "none", - "type": "string" - }, - "messages_copy": { - "description": "Copy message", - "default": "y", - "type": "string" - }, - "messages_undo": { - "description": "Undo message", - "default": "u", - "type": "string" - }, - "messages_redo": { - "description": "Redo message", - "default": "r", - "type": "string" - }, - "messages_toggle_conceal": { - "description": "Toggle code block concealment in messages", - "default": "h", - "type": "string" - }, - "tool_details": { - "description": "Toggle tool details visibility", - "default": "none", - "type": "string" - }, - "model_list": { - "description": "List available models", - "default": "m", - "type": "string" - }, - "model_cycle_recent": { - "description": "Next recently used model", - "default": "f2", - "type": "string" - }, - "model_cycle_recent_reverse": { - "description": "Previous recently used model", - "default": "shift+f2", - "type": "string" - }, - "model_cycle_favorite": { - "description": "Next favorite model", - "default": "none", - "type": "string" - }, - "model_cycle_favorite_reverse": { - "description": "Previous favorite model", - "default": "none", - "type": "string" - }, - "command_list": { - "description": "List available commands", - "default": "ctrl+p", - "type": "string" - }, - "agent_list": { - "description": "List agents", - "default": "a", - "type": "string" - }, - "agent_cycle": { - "description": "Next agent", - "default": "tab", - "type": "string" - }, - "agent_cycle_reverse": { - "description": "Previous agent", - "default": "shift+tab", - "type": "string" - }, - "variant_cycle": { - "description": "Cycle model variants", - "default": "ctrl+t", - "type": "string" - }, - "input_clear": { - "description": "Clear input field", - "default": "ctrl+c", - "type": "string" - }, - "input_paste": { - "description": "Paste from clipboard", - "default": "ctrl+v", - "type": "string" - }, - "input_submit": { - "description": "Submit input", - "default": "return", - "type": "string" - }, - "input_newline": { - "description": "Insert newline in input", - "default": "shift+return,ctrl+return,alt+return,ctrl+j", - "type": "string" - }, - "input_move_left": { - "description": "Move cursor left in input", - "default": "left,ctrl+b", - "type": "string" - }, - "input_move_right": { - "description": "Move cursor right in input", - "default": "right,ctrl+f", - "type": "string" - }, - "input_move_up": { - "description": "Move cursor up in input", - "default": "up", - "type": "string" - }, - "input_move_down": { - "description": "Move cursor down in input", - "default": "down", - "type": "string" - }, - "input_select_left": { - "description": "Select left in input", - "default": "shift+left", - "type": "string" - }, - "input_select_right": { - "description": "Select right in input", - "default": "shift+right", - "type": "string" - }, - "input_select_up": { - "description": "Select up in input", - "default": "shift+up", - "type": "string" - }, - "input_select_down": { - "description": "Select down in input", - "default": "shift+down", - "type": "string" - }, - "input_line_home": { - "description": "Move to start of line in input", - "default": "ctrl+a", - "type": "string" - }, - "input_line_end": { - "description": "Move to end of line in input", - "default": "ctrl+e", - "type": "string" - }, - "input_select_line_home": { - "description": "Select to start of line in input", - "default": "ctrl+shift+a", - "type": "string" - }, - "input_select_line_end": { - "description": "Select to end of line in input", - "default": "ctrl+shift+e", - "type": "string" - }, - "input_visual_line_home": { - "description": "Move to start of visual line in input", - "default": "alt+a", - "type": "string" - }, - "input_visual_line_end": { - "description": "Move to end of visual line in input", - "default": "alt+e", - "type": "string" - }, - "input_select_visual_line_home": { - "description": "Select to start of visual line in input", - "default": "alt+shift+a", - "type": "string" - }, - "input_select_visual_line_end": { - "description": "Select to end of visual line in input", - "default": "alt+shift+e", - "type": "string" - }, - "input_buffer_home": { - "description": "Move to start of buffer in input", - "default": "home", - "type": "string" - }, - "input_buffer_end": { - "description": "Move to end of buffer in input", - "default": "end", - "type": "string" - }, - "input_select_buffer_home": { - "description": "Select to start of buffer in input", - "default": "shift+home", - "type": "string" - }, - "input_select_buffer_end": { - "description": "Select to end of buffer in input", - "default": "shift+end", - "type": "string" - }, - "input_delete_line": { - "description": "Delete line in input", - "default": "ctrl+shift+d", - "type": "string" - }, - "input_delete_to_line_end": { - "description": "Delete to end of line in input", - "default": "ctrl+k", - "type": "string" - }, - "input_delete_to_line_start": { - "description": "Delete to start of line in input", - "default": "ctrl+u", - "type": "string" - }, - "input_backspace": { - "description": "Backspace in input", - "default": "backspace,shift+backspace", - "type": "string" - }, - "input_delete": { - "description": "Delete character in input", - "default": "ctrl+d,delete,shift+delete", - "type": "string" - }, - "input_undo": { - "description": "Undo in input", - "default": "ctrl+-,super+z", - "type": "string" - }, - "input_redo": { - "description": "Redo in input", - "default": "ctrl+.,super+shift+z", - "type": "string" - }, - "input_word_forward": { - "description": "Move word forward in input", - "default": "alt+f,alt+right,ctrl+right", - "type": "string" - }, - "input_word_backward": { - "description": "Move word backward in input", - "default": "alt+b,alt+left,ctrl+left", - "type": "string" - }, - "input_select_word_forward": { - "description": "Select word forward in input", - "default": "alt+shift+f,alt+shift+right", - "type": "string" - }, - "input_select_word_backward": { - "description": "Select word backward in input", - "default": "alt+shift+b,alt+shift+left", - "type": "string" - }, - "input_delete_word_forward": { - "description": "Delete word forward in input", - "default": "alt+d,alt+delete,ctrl+delete", - "type": "string" - }, - "input_delete_word_backward": { - "description": "Delete word backward in input", - "default": "ctrl+w,ctrl+backspace,alt+backspace", - "type": "string" - }, - "history_previous": { - "description": "Previous history item", - "default": "up", - "type": "string" - }, - "history_next": { - "description": "Next history item", - "default": "down", - "type": "string" - }, - "session_child_cycle": { - "description": "Next child session", - "default": "right", - "type": "string" - }, - "session_child_cycle_reverse": { - "description": "Previous child session", - "default": "left", - "type": "string" - }, - "session_parent": { - "description": "Go to parent session", - "default": "up", - "type": "string" - }, - "terminal_suspend": { - "description": "Suspend terminal", - "default": "ctrl+z", - "type": "string" - }, - "terminal_title_toggle": { - "description": "Toggle terminal title", - "default": "none", - "type": "string" - }, - "tips_toggle": { - "description": "Toggle tips on home screen", - "default": "h", - "type": "string" - }, - "display_thinking": { - "description": "Toggle thinking blocks visibility", - "default": "none", - "type": "string" - } - }, - "additionalProperties": false - }, "LogLevel": { "description": "Log level", "type": "string", @@ -9707,43 +9300,9 @@ "description": "JSON schema reference for configuration validation", "type": "string" }, - "theme": { - "description": "Theme name to use for the interface", - "type": "string" - }, - "keybinds": { - "$ref": "#/components/schemas/KeybindsConfig" - }, "logLevel": { "$ref": "#/components/schemas/LogLevel" }, - "tui": { - "description": "TUI specific settings", - "type": "object", - "properties": { - "scroll_speed": { - "description": "TUI scroll speed", - "type": "number", - "minimum": 0.001 - }, - "scroll_acceleration": { - "description": "Scroll acceleration settings", - "type": "object", - "properties": { - "enabled": { - "description": "Enable scroll acceleration", - "type": "boolean" - } - }, - "required": ["enabled"] - }, - "diff_style": { - "description": "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", - "type": "string", - "enum": ["auto", "stacked"] - } - } - }, "server": { "$ref": "#/components/schemas/ServerConfig" }, diff --git a/packages/slack/package.json b/packages/slack/package.json index d000cb47994..72ffe20d5e3 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.2.10", + "version": "1.2.15", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/.gitignore b/packages/storybook/.gitignore new file mode 100644 index 00000000000..b122737adfe --- /dev/null +++ b/packages/storybook/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +storybook-static/ +.storybook-cache/ diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts new file mode 100644 index 00000000000..6c850858a55 --- /dev/null +++ b/packages/storybook/.storybook/main.ts @@ -0,0 +1,37 @@ +import { defineMain } from "storybook-solidjs-vite" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const here = path.dirname(fileURLToPath(import.meta.url)) +const ui = path.resolve(here, "../../ui") + +export default defineMain({ + framework: { + name: "storybook-solidjs-vite", + options: {}, + }, + addons: [ + "@storybook/addon-onboarding", + "@storybook/addon-docs", + "@storybook/addon-links", + "@storybook/addon-a11y", + "@storybook/addon-vitest", + ], + stories: ["../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + async viteFinal(config) { + const { mergeConfig, searchForWorkspaceRoot } = await import("vite") + return mergeConfig(config, { + resolve: { + dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"], + }, + worker: { + format: "es", + }, + server: { + fs: { + allow: [searchForWorkspaceRoot(process.cwd()), ui], + }, + }, + }) + }, +}) diff --git a/packages/storybook/.storybook/manager.ts b/packages/storybook/.storybook/manager.ts new file mode 100644 index 00000000000..9af9ba0a828 --- /dev/null +++ b/packages/storybook/.storybook/manager.ts @@ -0,0 +1,11 @@ +import { addons, types } from "storybook/manager-api" +import { ThemeTool } from "./theme-tool" + +addons.register("opencode/theme-toggle", () => { + addons.add("opencode/theme-toggle/tool", { + type: types.TOOL, + title: "Theme", + match: ({ viewMode }) => viewMode === "story" || viewMode === "docs", + render: ThemeTool, + }) +}) diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx new file mode 100644 index 00000000000..cb5ee4329bb --- /dev/null +++ b/packages/storybook/.storybook/preview.tsx @@ -0,0 +1,106 @@ +import "@opencode-ai/ui/styles" + +import { createEffect, onCleanup, onMount } from "solid-js" +import addonA11y from "@storybook/addon-a11y" +import addonDocs from "@storybook/addon-docs" +import { MetaProvider } from "@solidjs/meta" +import { addons } from "storybook/preview-api" +import { GLOBALS_UPDATED } from "storybook/internal/core-events" +import { createJSXDecorator, definePreview } from "storybook-solidjs-vite" +import { Code } from "@opencode-ai/ui/code" +import { CodeComponentProvider } from "@opencode-ai/ui/context/code" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { Diff } from "@opencode-ai/ui/diff" +import { ThemeProvider, useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { Font } from "@opencode-ai/ui/font" + +function resolveScheme(value: unknown): ColorScheme { + if (value === "light" || value === "dark" || value === "system") return value + return "system" +} + +const channel = addons.getChannel() + +const Scheme = (props: { value?: unknown }) => { + const theme = useTheme() + const apply = (value?: unknown) => { + theme.setColorScheme(resolveScheme(value)) + } + createEffect(() => { + apply(props.value) + }) + createEffect(() => { + const root = document.documentElement + root.classList.remove("light", "dark") + root.classList.add(theme.mode()) + }) + onMount(() => { + const handler = (event: { globals?: Record }) => { + apply(event.globals?.theme) + } + channel.on(GLOBALS_UPDATED, handler) + onCleanup(() => channel.off(GLOBALS_UPDATED, handler)) + }) + return null +} + +const frame = createJSXDecorator((Story, context) => { + const override = context.parameters?.themes?.themeOverride + const selected = context.globals?.theme + const pick = override === "light" || override === "dark" ? override : selected + const scheme = resolveScheme(pick) + return ( + + + + + + + + +
+ +
+
+
+
+
+
+
+ ) +}) + +export default definePreview({ + addons: [addonDocs(), addonA11y()], + decorators: [frame], + globalTypes: { + theme: { + name: "Theme", + description: "Global theme", + defaultValue: "light", + }, + }, + parameters: { + actions: { + argTypesRegex: "^on.*", + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + test: "todo", + }, + }, +}) diff --git a/packages/storybook/.storybook/theme-tool.ts b/packages/storybook/.storybook/theme-tool.ts new file mode 100644 index 00000000000..3dac777cd7d --- /dev/null +++ b/packages/storybook/.storybook/theme-tool.ts @@ -0,0 +1,21 @@ +import { createElement } from "react" +import { useGlobals } from "storybook/manager-api" +import { ToggleButton } from "storybook/internal/components" + +export function ThemeTool() { + const [globals, updateGlobals] = useGlobals() + const mode = globals.theme === "dark" ? "dark" : "light" + const toggle = () => { + const next = mode === "dark" ? "light" : "dark" + updateGlobals({ theme: next }) + } + return createElement( + ToggleButton, + { + title: "Toggle theme", + active: mode === "dark", + onClick: toggle, + }, + mode === "dark" ? "Dark" : "Light", + ) +} diff --git a/packages/storybook/debug-storybook.log b/packages/storybook/debug-storybook.log new file mode 100644 index 00000000000..e13d40c8e8e --- /dev/null +++ b/packages/storybook/debug-storybook.log @@ -0,0 +1,307 @@ +[14:25:48.462] [INFO] storybook v10.2.10 +[14:25:48.749] [DEBUG] Getting package.json info for /Users/davidhill/Documents/Local/opencode/packages/storybook/package.json... +[14:25:48.997] [INFO] Starting... +[14:25:49.095] [DEBUG] Starting preview.. +[14:25:49.098] [WARN] 🚨 Unable to index files: +- ./../ui/src/components/accordion.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/accordion.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/app-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/app-icon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/avatar.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/avatar.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/basic-tool.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/basic-tool.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/checkbox.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/checkbox.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/code.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/code.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/collapsible.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/collapsible.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/context-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/context-menu.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/dialog.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dialog.stories.tsx (line 10, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/diff-changes.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-changes.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/diff-ssr.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-ssr.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/diff.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/dock-prompt.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dock-prompt.stories.tsx (line 15, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/dropdown-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dropdown-menu.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/favicon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/favicon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/file-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/file-icon.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/font.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/font.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/hover-card.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/hover-card.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/icon-button.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon-button.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/image-preview.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/image-preview.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/inline-input.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/inline-input.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/keybind.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/keybind.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/line-comment.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/line-comment.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/list.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/list.stories.tsx (line 15, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/logo.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/logo.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/markdown.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/markdown.stories.tsx (line 12, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/message-nav.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-nav.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/message-part.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-part.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/popover.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/popover.stories.tsx (line 16, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/progress-circle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress-circle.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/progress.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress.stories.tsx (line 15, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/provider-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/provider-icon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/radio-group.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/radio-group.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/resize-handle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/resize-handle.stories.tsx (line 17, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/select.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/select.stories.tsx (line 16, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/session-review.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-review.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/session-turn.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/spinner.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/spinner.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/sticky-accordion-header.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/sticky-accordion-header.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/switch.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/switch.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/tabs.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tabs.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/tag.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tag.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/text-field.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-field.stories.tsx (line 14, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/text-shimmer.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-shimmer.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/toast.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/toast.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/tooltip.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tooltip.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/typewriter.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/typewriter.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +[14:25:49.109] [ERROR] Failed to build the preview +[14:25:49.110] [ERROR] Error: Unable to index files: +- ./../ui/src/components/accordion.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/accordion.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/app-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/app-icon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/avatar.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/avatar.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/basic-tool.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/basic-tool.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/checkbox.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/checkbox.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/code.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/code.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/collapsible.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/collapsible.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/context-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/context-menu.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/dialog.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dialog.stories.tsx (line 10, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/diff-changes.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-changes.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/diff-ssr.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-ssr.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/diff.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/dock-prompt.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dock-prompt.stories.tsx (line 15, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/dropdown-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dropdown-menu.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/favicon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/favicon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/file-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/file-icon.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/font.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/font.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/hover-card.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/hover-card.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/icon-button.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon-button.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/image-preview.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/image-preview.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/inline-input.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/inline-input.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/keybind.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/keybind.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/line-comment.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/line-comment.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/list.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/list.stories.tsx (line 15, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/logo.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/logo.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/markdown.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/markdown.stories.tsx (line 12, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/message-nav.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-nav.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/message-part.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-part.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/popover.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/popover.stories.tsx (line 16, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/progress-circle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress-circle.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/progress.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress.stories.tsx (line 15, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/provider-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/provider-icon.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/radio-group.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/radio-group.stories.tsx (line 13, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/resize-handle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/resize-handle.stories.tsx (line 17, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/select.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/select.stories.tsx (line 16, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/session-review.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-review.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/session-turn.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/spinner.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/spinner.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/sticky-accordion-header.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/sticky-accordion-header.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/switch.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/switch.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/tabs.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tabs.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/tag.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tag.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/text-field.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-field.stories.tsx (line 14, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/text-shimmer.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-shimmer.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/toast.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/toast.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/tooltip.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tooltip.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export +- ./../ui/src/components/typewriter.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/typewriter.stories.tsx (line 6, col 0) + +More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export + at _StoryIndexGenerator.getIndexAndStats (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:6085:15) + at async _StoryIndexGenerator.getIndex (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:6074:13) + at async getOptimizeDeps (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1862:15) + at async createViteServer (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1888:19) + at async Module.start (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1923:17) + at async storybookDevServer (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:7241:83) + at async buildOrThrow (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:4504:12) + at async buildDevStandalone (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:7611:66) + at async withTelemetry (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/_node-chunks/chunk-S3MWHNYJ.js:218:12) + at async dev (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/bin/core.js:2734:3) +[14:25:49.118] [WARN] Broken build, fix the error above. +You may need to refresh the browser. \ No newline at end of file diff --git a/packages/storybook/package.json b/packages/storybook/package.json new file mode 100644 index 00000000000..2ab92bd5f81 --- /dev/null +++ b/packages/storybook/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/storybook", + "private": true, + "type": "module", + "scripts": { + "storybook": "storybook dev -p 6006", + "build": "storybook build" + }, + "devDependencies": { + "@opencode-ai/ui": "workspace:*", + "@solidjs/meta": "catalog:", + "@storybook/addon-a11y": "^10.2.10", + "@storybook/addon-docs": "^10.2.10", + "@storybook/addon-links": "^10.2.10", + "@storybook/addon-onboarding": "^10.2.10", + "@storybook/addon-vitest": "^10.2.10", + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@types/react": "18.0.25", + "react": "18.2.0", + "solid-js": "catalog:", + "storybook": "^10.2.10", + "storybook-solidjs-vite": "^10.0.9", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/storybook/tsconfig.json b/packages/storybook/tsconfig.json new file mode 100644 index 00000000000..68ae315d2bf --- /dev/null +++ b/packages/storybook/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "target": "ESNext", + "lib": ["es2023", "dom", "dom.iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "types": ["vite/client", "node"] + }, + "include": [".storybook/**/*.ts", ".storybook/**/*.tsx"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 3519996085d..b2f9bb40111 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,9 +1,10 @@ { "name": "@opencode-ai/ui", - "version": "1.2.10", + "version": "1.2.15", "type": "module", "license": "MIT", "exports": { + "./package.json": "./package.json", "./*": "./src/components/*.tsx", "./i18n/*": "./src/i18n/*.ts", "./pierre": "./src/pierre/index.ts", diff --git a/packages/ui/src/assets/icons/provider/302ai.svg b/packages/ui/src/assets/icons/provider/302ai.svg new file mode 100644 index 00000000000..46f2e4315e0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/302ai.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/berget.svg b/packages/ui/src/assets/icons/provider/berget.svg new file mode 100644 index 00000000000..831547a59ed --- /dev/null +++ b/packages/ui/src/assets/icons/provider/berget.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg b/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg new file mode 100644 index 00000000000..6f09a794e6c --- /dev/null +++ b/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/firmware.svg b/packages/ui/src/assets/icons/provider/firmware.svg new file mode 100644 index 00000000000..baa524ba2d4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/firmware.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/packages/ui/src/assets/icons/provider/gitlab.svg b/packages/ui/src/assets/icons/provider/gitlab.svg new file mode 100644 index 00000000000..eef04ace2b0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/gitlab.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/jiekou.svg b/packages/ui/src/assets/icons/provider/jiekou.svg new file mode 100644 index 00000000000..7fe6378e561 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/jiekou.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/kilo.svg b/packages/ui/src/assets/icons/provider/kilo.svg new file mode 100644 index 00000000000..0a761347a8e --- /dev/null +++ b/packages/ui/src/assets/icons/provider/kilo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg b/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg new file mode 100644 index 00000000000..3d0d0c45573 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/meganova.svg b/packages/ui/src/assets/icons/provider/meganova.svg new file mode 100644 index 00000000000..ab294f1e173 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/meganova.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg b/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg new file mode 100644 index 00000000000..086e9aa1fca --- /dev/null +++ b/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg b/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg new file mode 100644 index 00000000000..086e9aa1fca --- /dev/null +++ b/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/moark.svg b/packages/ui/src/assets/icons/provider/moark.svg new file mode 100644 index 00000000000..dc84a9191c7 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/moark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/nova.svg b/packages/ui/src/assets/icons/provider/nova.svg new file mode 100644 index 00000000000..9fcae228c0a --- /dev/null +++ b/packages/ui/src/assets/icons/provider/nova.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/novita-ai.svg b/packages/ui/src/assets/icons/provider/novita-ai.svg new file mode 100644 index 00000000000..ac537b8dd42 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/novita-ai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/privatemode-ai.svg b/packages/ui/src/assets/icons/provider/privatemode-ai.svg new file mode 100644 index 00000000000..edb5a6d7648 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/privatemode-ai.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/qihang-ai.svg b/packages/ui/src/assets/icons/provider/qihang-ai.svg new file mode 100644 index 00000000000..3b356637a12 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/qihang-ai.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/qiniu-ai.svg b/packages/ui/src/assets/icons/provider/qiniu-ai.svg new file mode 100644 index 00000000000..858560f9ffe --- /dev/null +++ b/packages/ui/src/assets/icons/provider/qiniu-ai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/stackit.svg b/packages/ui/src/assets/icons/provider/stackit.svg new file mode 100644 index 00000000000..0d78b781acf --- /dev/null +++ b/packages/ui/src/assets/icons/provider/stackit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/assets/icons/provider/stepfun.svg b/packages/ui/src/assets/icons/provider/stepfun.svg new file mode 100644 index 00000000000..086e9aa1fca --- /dev/null +++ b/packages/ui/src/assets/icons/provider/stepfun.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/vivgrid.svg b/packages/ui/src/assets/icons/provider/vivgrid.svg new file mode 100644 index 00000000000..928fa3ff1ed --- /dev/null +++ b/packages/ui/src/assets/icons/provider/vivgrid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/components/accordion.stories.tsx b/packages/ui/src/components/accordion.stories.tsx new file mode 100644 index 00000000000..c53b6d3da9a --- /dev/null +++ b/packages/ui/src/components/accordion.stories.tsx @@ -0,0 +1,149 @@ +// @ts-nocheck +import { createEffect, createSignal } from "solid-js" +import * as mod from "./accordion" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Accordion for collapsible content sections with optional multi-open behavior. + +Use one trigger per item; keep content concise. + +### API +- Root supports Kobalte Accordion props: \`value\`, \`multiple\`, \`collapsible\`, \`onChange\`. +- Compose with \`Accordion.Item\`, \`Header\`, \`Trigger\`, \`Content\`. + +### Variants and states +- Single or multiple open items. +- Collapsible or fixed-open behavior. + +### Behavior +- Controlled via \`value\`/\`onChange\` when provided. + +### Accessibility +- TODO: confirm keyboard navigation from Kobalte Accordion. + +### Theming/tokens +- Uses \`data-component="accordion"\` and slot data attributes. + +` + +const story = create({ title: "UI/Accordion", mod }) +export default { + title: "UI/Accordion", + id: "components-accordion", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} +export const Basic = { + args: { + collapsible: true, + multiple: false, + value: "first", + }, + argTypes: { + collapsible: { control: "boolean" }, + multiple: { control: "boolean" }, + value: { + control: "select", + options: ["first", "second", "none"], + mapping: { + none: undefined, + }, + }, + }, + render: (props) => { + const [value, setValue] = createSignal(props.value) + createEffect(() => { + setValue(props.value) + }) + + const current = () => { + if (props.multiple) { + if (Array.isArray(value())) return value() + if (value()) return [value()] + return [] + } + + if (Array.isArray(value())) return value()[0] + return value() + } + + return ( +
+ + + + First + + +
Accordion content.
+
+
+ + + Second + + +
More content.
+
+
+
+
+ ) + }, +} + +export const Multiple = { + args: { + collapsible: true, + multiple: true, + value: ["first", "second"], + }, + render: (props) => ( + + + + First + + +
Accordion content.
+
+
+ + + Second + + +
More content.
+
+
+
+ ), +} + +export const NonCollapsible = { + args: { + collapsible: false, + multiple: false, + value: "first", + }, + render: (props) => ( + + + + First + + +
Accordion content.
+
+
+
+ ), +} diff --git a/packages/ui/src/components/app-icon.stories.tsx b/packages/ui/src/components/app-icon.stories.tsx new file mode 100644 index 00000000000..24460b6da22 --- /dev/null +++ b/packages/ui/src/components/app-icon.stories.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +import { iconNames } from "./app-icons/types" +import * as mod from "./app-icon" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Application icon renderer for known editor/terminal apps. + +Use in provider or app selection lists. + +### API +- Required: \`id\` (app icon name). +- Accepts standard img props except \`src\`. + +### Variants and states +- Auto-switches themed icons when available. + +### Behavior +- Watches color scheme changes to swap themed assets. + +### Accessibility +- Provide \`alt\` text when the icon conveys meaning. + +### Theming/tokens +- Uses \`data-component="app-icon"\`. + +` + +const story = create({ title: "UI/AppIcon", mod, args: { id: "vscode" } }) +export default { + title: "UI/AppIcon", + id: "components-app-icon", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + id: { + control: "select", + options: iconNames, + }, + }, +} + +export const Basic = story.Basic + +export const AllIcons = { + render: () => ( +
+ {iconNames.map((id) => ( +
+ +
{id}
+
+ ))} +
+ ), +} diff --git a/packages/ui/src/components/avatar.stories.tsx b/packages/ui/src/components/avatar.stories.tsx new file mode 100644 index 00000000000..044224ae8c3 --- /dev/null +++ b/packages/ui/src/components/avatar.stories.tsx @@ -0,0 +1,76 @@ +// @ts-nocheck +import * as mod from "./avatar" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +User avatar with image fallback to initials. + +Use in user lists and headers. + +### API +- Required: \`fallback\` string. +- Optional: \`src\`, \`background\`, \`foreground\`, \`size\`. + +### Variants and states +- Sizes: small, normal, large. +- Image vs fallback state. + +### Behavior +- Uses grapheme-aware fallback rendering. + +### Accessibility +- TODO: provide alt text when using images; currently image is decorative. + +### Theming/tokens +- Uses \`data-component="avatar"\` with size and image state attributes. + +` + +const story = create({ title: "UI/Avatar", mod, args: { fallback: "A" } }) + +export default { + title: "UI/Avatar", + id: "components-avatar", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + size: { + control: "select", + options: ["small", "normal", "large"], + }, + }, +} + +export const Basic = story.Basic + +export const WithImage = { + args: { + src: "https://placehold.co/80x80/png", + fallback: "J", + }, +} + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} + +export const CustomColors = { + args: { + fallback: "C", + background: "#1f2a44", + foreground: "#f2f5ff", + }, +} diff --git a/packages/ui/src/components/basic-tool.stories.tsx b/packages/ui/src/components/basic-tool.stories.tsx new file mode 100644 index 00000000000..9d9d97acfeb --- /dev/null +++ b/packages/ui/src/components/basic-tool.stories.tsx @@ -0,0 +1,133 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./basic-tool" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Expandable tool panel with a structured trigger and optional details. + +Use structured triggers for consistent layout; custom triggers allowed. + +### API +- Required: \`icon\` and \`trigger\` (structured or custom JSX). +- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`. + +### Variants and states +- Pending/running status animates the title via TextShimmer. + +### Behavior +- Uses Collapsible; can defer content rendering until open. +- Locked state prevents closing. + +### Accessibility +- TODO: confirm trigger semantics and aria labeling. + +### Theming/tokens +- Uses \`data-component="tool-trigger"\` and related slots. + +` + +const story = create({ + title: "UI/Basic Tool", + mod, + args: { + icon: "mcp", + defaultOpen: true, + trigger: { + title: "Basic Tool", + subtitle: "Example subtitle", + args: ["--flag", "value"], + }, + children: "Details content", + }, +}) + +export default { + title: "UI/Basic Tool", + id: "components-basic-tool", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Pending = { + args: { + status: "pending", + trigger: { + title: "Running tool", + subtitle: "Working...", + }, + children: "Progress details", + }, +} + +export const Locked = { + args: { + locked: true, + trigger: { + title: "Locked tool", + subtitle: "Cannot close", + }, + children: "Locked details", + }, +} + +export const Deferred = { + args: { + defer: true, + defaultOpen: false, + trigger: { + title: "Deferred tool", + subtitle: "Content mounts on open", + }, + children: "Deferred content", + }, +} + +export const ForceOpen = { + args: { + forceOpen: true, + trigger: { + title: "Forced open", + subtitle: "Cannot close", + }, + children: "Forced content", + }, +} + +export const HideDetails = { + args: { + hideDetails: true, + trigger: { + title: "Summary only", + subtitle: "Details hidden", + }, + children: "Hidden content", + }, +} + +export const SubtitleAction = { + render: () => { + const [message, setMessage] = createSignal("Subtitle not clicked") + return ( +
+
{message()}
+ setMessage("Subtitle clicked")} + > + Subtitle action details + +
+ ) + }, +} diff --git a/packages/ui/src/components/button.stories.tsx b/packages/ui/src/components/button.stories.tsx new file mode 100644 index 00000000000..24fad5c8a0f --- /dev/null +++ b/packages/ui/src/components/button.stories.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +import { Button } from "./button" + +const docs = `### Overview +Primary action button with size, variant, and optional icon support. + +Use \`IconButton\` for icon-only actions. + +### API +- \`variant\`: "primary" | "secondary" | "ghost". +- \`size\`: "small" | "normal" | "large". +- \`icon\`: Icon name for a leading icon. +- Inherits Kobalte Button props and native button attributes. + +### Variants and states +- Variants: primary, secondary, ghost. +- States: disabled. + +### Behavior +- Renders an Icon when \`icon\` is set. + +### Accessibility +- Provide clear label text; use \`aria-label\` for icon-only buttons. + +### Theming/tokens +- Uses \`data-component="button"\` with size/variant data attributes. + +` + +export default { + title: "UI/Button", + id: "components-button", + component: Button, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + args: { + children: "Button", + variant: "secondary", + size: "normal", + }, + argTypes: { + variant: { + control: "select", + options: ["primary", "secondary", "ghost"], + }, + size: { + control: "select", + options: ["small", "normal", "large"], + }, + icon: { + control: "select", + options: ["none", "check", "plus", "arrow-right"], + mapping: { + none: undefined, + }, + }, + }, +} + +export const Primary = { + args: { + variant: "primary", + }, +} + +export const Secondary = {} + +export const Ghost = { + args: { + variant: "ghost", + }, +} + +export const WithIcon = { + args: { + children: "Continue", + icon: "arrow-right", + }, +} + +export const Disabled = { + args: { + variant: "primary", + disabled: true, + }, +} + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} diff --git a/packages/ui/src/components/card.stories.tsx b/packages/ui/src/components/card.stories.tsx new file mode 100644 index 00000000000..befb2d34fc7 --- /dev/null +++ b/packages/ui/src/components/card.stories.tsx @@ -0,0 +1,90 @@ +// @ts-nocheck +import { Card } from "./card" +import { Button } from "./button" + +const docs = `### Overview +Surface container for grouping related content and actions. + +Pair with \`Button\` or \`Tag\` for quick actions. + +### API +- Optional: \`variant\` (normal, error, warning, success, info). +- Accepts standard div props. + +### Variants and states +- Semantic variants for status-driven messaging. + +### Behavior +- Pure presentational container. + +### Accessibility +- Provide headings or aria labels when used in isolation. + +### Theming/tokens +- Uses \`data-component="card"\` with variant data attributes. + +` + +export default { + title: "UI/Card", + id: "components-card", + component: Card, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + args: { + variant: "normal", + }, + argTypes: { + variant: { + control: "select", + options: ["normal", "error", "warning", "success", "info"], + }, + }, + render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => { + return ( + +
+
+
Card title
+
Small supporting text.
+
+ +
+
+ ) + }, +} + +export const Normal = {} + +export const Error = { + args: { + variant: "error", + }, +} + +export const Warning = { + args: { + variant: "warning", + }, +} + +export const Success = { + args: { + variant: "success", + }, +} + +export const Info = { + args: { + variant: "info", + }, +} diff --git a/packages/ui/src/components/checkbox.stories.tsx b/packages/ui/src/components/checkbox.stories.tsx new file mode 100644 index 00000000000..ceb09f103e5 --- /dev/null +++ b/packages/ui/src/components/checkbox.stories.tsx @@ -0,0 +1,71 @@ +// @ts-nocheck +import { Icon } from "./icon" +import * as mod from "./checkbox" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Checkbox control for multi-select or agreement inputs. + +Use in forms and multi-select lists. + +### API +- Uses Kobalte Checkbox props (\`checked\`, \`defaultChecked\`, \`onChange\`). +- Optional: \`hideLabel\`, \`description\`, \`icon\`. +- Children render as the label. + +### Variants and states +- Checked/unchecked, indeterminate, disabled (via Kobalte). + +### Behavior +- Controlled or uncontrolled usage. + +### Accessibility +- TODO: confirm aria attributes from Kobalte. + +### Theming/tokens +- Uses \`data-component="checkbox"\` and related slots. + +` + +const story = create({ title: "UI/Checkbox", mod, args: { children: "Checkbox", defaultChecked: true } }) +export default { + title: "UI/Checkbox", + id: "components-checkbox", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const States = { + render: () => ( +
+ Checked + Unchecked + Disabled + With description +
+ ), +} + +export const CustomIcon = { + render: () => ( + } defaultChecked> + Custom icon + + ), +} + +export const HiddenLabel = { + args: { + children: "Hidden label", + hideLabel: true, + }, +} diff --git a/packages/ui/src/components/code.stories.tsx b/packages/ui/src/components/code.stories.tsx new file mode 100644 index 00000000000..992fa630242 --- /dev/null +++ b/packages/ui/src/components/code.stories.tsx @@ -0,0 +1,70 @@ +// @ts-nocheck +import * as mod from "./code" +import { create } from "../storybook/scaffold" +import { code } from "../storybook/fixtures" + +const docs = `### Overview +Syntax-highlighted code viewer with selection support and large-file virtualization. + +Use alongside \`LineComment\` and \`Diff\` in review workflows. + +### API +- Required: \`file\` with file name + contents. +- Optional: \`language\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`. +- Optional callbacks: \`onRendered\`, \`onLineSelectionEnd\`. + +### Variants and states +- Supports large-file virtualization automatically. + +### Behavior +- Re-renders when \`file\` or rendering options change. +- Optional line selection integrates with selection callbacks. + +### Accessibility +- TODO: confirm keyboard find and selection behavior. + +### Theming/tokens +- Uses \`data-component="code"\` and Pierre CSS variables from \`styleVariables\`. + +` + +const story = create({ + title: "UI/Code", + mod, + args: { + file: code, + language: "ts", + }, +}) + +export default { + title: "UI/Code", + id: "components-code", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const SelectedLines = { + args: { + enableLineSelection: true, + selectedLines: { start: 2, end: 4 }, + }, +} + +export const CommentedLines = { + args: { + commentedLines: [ + { start: 1, end: 1 }, + { start: 5, end: 6 }, + ], + }, +} diff --git a/packages/ui/src/components/collapsible.stories.tsx b/packages/ui/src/components/collapsible.stories.tsx new file mode 100644 index 00000000000..67883b22997 --- /dev/null +++ b/packages/ui/src/components/collapsible.stories.tsx @@ -0,0 +1,86 @@ +// @ts-nocheck +import * as mod from "./collapsible" + +const docs = `### Overview +Toggleable content region with optional arrow indicator. + +Compose \`Collapsible.Trigger\`, \`Collapsible.Content\`, and \`Collapsible.Arrow\`. + +### API +- Root accepts Kobalte Collapsible props (\`open\`, \`defaultOpen\`, \`onOpenChange\`). +- \`variant\` controls styling ("normal" | "ghost"). + +### Variants and states +- Normal and ghost variants. +- Open/closed states. + +### Behavior +- Trigger toggles the content visibility. + +### Accessibility +- TODO: confirm ARIA attributes provided by Kobalte. + +### Theming/tokens +- Uses \`data-component="collapsible"\` and slots for trigger/content/arrow. + +` + +export default { + title: "UI/Collapsible", + id: "components-collapsible", + component: mod.Collapsible, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + variant: { + control: "select", + options: ["normal", "ghost"], + }, + }, +} + +export const Basic = { + args: { + variant: "normal", + defaultOpen: true, + }, + render: (props) => ( + + +
+ Details + +
+
+ +
Optional details sit here.
+
+
+ ), +} + +export const Ghost = { + args: { + variant: "ghost", + defaultOpen: false, + }, + render: (props) => ( + + +
+ Ghost trigger + +
+
+ +
Ghost content.
+
+
+ ), +} diff --git a/packages/ui/src/components/context-menu.stories.tsx b/packages/ui/src/components/context-menu.stories.tsx new file mode 100644 index 00000000000..bee5a559655 --- /dev/null +++ b/packages/ui/src/components/context-menu.stories.tsx @@ -0,0 +1,113 @@ +// @ts-nocheck +import * as mod from "./context-menu" + +const docs = `### Overview +Context menu for right-click interactions with composable items and submenus. + +Use \`ItemLabel\` and \`ItemDescription\` for rich items. + +### API +- Root accepts Kobalte ContextMenu props (\`open\`, \`defaultOpen\`, \`onOpenChange\`). +- Compose \`Trigger\`, \`Content\`, \`Item\`, \`Separator\`, and optional \`Sub\` sections. + +### Variants and states +- Supports grouped sections and nested submenus. + +### Behavior +- Opens on context menu gesture over the trigger element. + +### Accessibility +- TODO: confirm keyboard and focus behavior from Kobalte. + +### Theming/tokens +- Uses \`data-component="context-menu"\` and slot attributes for styling. + +` + +export default { + title: "UI/ContextMenu", + id: "components-context-menu", + component: mod.ContextMenu, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( + + +
+ Right click (or open) here +
+
+ + + + Actions + + Copy + + + Paste + + + + + More + + + Duplicate + + + Move + + + + + +
+ ), +} + +export const CheckboxRadio = { + render: () => ( + + +
+ Right click (or open) here +
+
+ + + Show line numbers + Wrap lines + + + Compact + Comfortable + + + +
+ ), +} diff --git a/packages/ui/src/components/dialog.stories.tsx b/packages/ui/src/components/dialog.stories.tsx new file mode 100644 index 00000000000..60cd0a1c191 --- /dev/null +++ b/packages/ui/src/components/dialog.stories.tsx @@ -0,0 +1,173 @@ +// @ts-nocheck +import { onMount } from "solid-js" +import * as mod from "./dialog" +import { Button } from "./button" +import { useDialog } from "../context/dialog" + +const docs = `### Overview +Dialog content wrapper used with the DialogProvider for modal flows. + +Provide concise title/description and keep body focused. + +### API +- Optional: \`title\`, \`description\`, \`action\`. +- \`size\`: normal | large | x-large. +- \`fit\` and \`transition\` control layout and animation. + +### Variants and states +- Sizes and optional header/action controls. + +### Behavior +- Intended to be rendered via \`useDialog().show\`. + +### Accessibility +- TODO: confirm focus trapping and aria attributes from Kobalte Dialog. + +### Theming/tokens +- Uses \`data-component="dialog"\` and slot attributes. + +` + +export default { + title: "UI/Dialog", + id: "components-dialog", + component: mod.Dialog, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => { + const dialog = useDialog() + const open = () => + dialog.show(() => ( + + Dialog body content. + + )) + + onMount(open) + + return ( + + ) + }, +} + +export const Sizes = { + render: () => { + const dialog = useDialog() + return ( +
+ + + +
+ ) + }, +} + +export const Transition = { + render: () => { + const dialog = useDialog() + return ( + + ) + }, +} + +export const CustomAction = { + render: () => { + const dialog = useDialog() + return ( + } + > + Dialog body content. + + )) + } + > + Open action dialog + + ) + }, +} + +export const Fit = { + render: () => { + const dialog = useDialog() + return ( + + ) + }, +} diff --git a/packages/ui/src/components/diff-changes.stories.tsx b/packages/ui/src/components/diff-changes.stories.tsx new file mode 100644 index 00000000000..fe0ba6eb4f2 --- /dev/null +++ b/packages/ui/src/components/diff-changes.stories.tsx @@ -0,0 +1,81 @@ +// @ts-nocheck +import * as mod from "./diff-changes" +import { create } from "../storybook/scaffold" +import { changes } from "../storybook/fixtures" + +const docs = `### Overview +Summarize additions/deletions as text or compact bars. + +Pair with \`Diff\`/\`DiffSSR\` to contextualize a change set. + +### API +- Required: \`changes\` as { additions, deletions } or an array of those objects. +- Optional: \`variant\` ("default" | "bars"). + +### Variants and states +- Default text summary or bar visualization. +- Handles zero-change state (renders nothing in default variant). + +### Behavior +- Aggregates arrays into total additions/deletions. + +### Accessibility +- Ensure surrounding context conveys meaning of the counts/bars. + +### Theming/tokens +- Uses \`data-component="diff-changes"\` and diff color tokens. + +` + +const story = create({ + title: "UI/DiffChanges", + mod, + args: { + changes, + variant: "default", + }, +}) + +export default { + title: "UI/DiffChanges", + id: "components-diff-changes", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + variant: { + control: "select", + options: ["default", "bars"], + }, + }, +} + +export const Default = story.Basic + +export const Bars = { + args: { + variant: "bars", + }, +} + +export const MultipleFiles = { + args: { + changes: [ + { additions: 4, deletions: 1 }, + { additions: 8, deletions: 3 }, + { additions: 2, deletions: 0 }, + ], + }, +} + +export const Zero = { + args: { + changes: { additions: 0, deletions: 0 }, + }, +} diff --git a/packages/ui/src/components/diff-ssr.stories.tsx b/packages/ui/src/components/diff-ssr.stories.tsx new file mode 100644 index 00000000000..d1adce28066 --- /dev/null +++ b/packages/ui/src/components/diff-ssr.stories.tsx @@ -0,0 +1,97 @@ +// @ts-nocheck +import { preloadMultiFileDiff } from "@pierre/diffs/ssr" +import { createResource, Show } from "solid-js" +import * as mod from "./diff-ssr" +import { createDefaultOptions } from "../pierre" +import { WorkerPoolProvider } from "../context/worker-pool" +import { getWorkerPools } from "../pierre/worker" +import { diff } from "../storybook/fixtures" + +const docs = `### Overview +Server-rendered diff hydration component for preloaded Pierre diff output. + +Use alongside server routes that preload diffs. +Pair with \`DiffChanges\` for summaries. + +### API +- Required: \`before\`, \`after\`, and \`preloadedDiff\` from \`preloadMultiFileDiff\`. +- Optional: \`diffStyle\`, \`annotations\`, \`selectedLines\`, \`commentedLines\`. + +### Variants and states +- Unified/split styles (preloaded must match the style used during preload). + +### Behavior +- Hydrates pre-rendered diff HTML into a live diff instance. +- Requires a worker pool provider for syntax highlighting. + +### Accessibility +- TODO: confirm keyboard behavior from the Pierre diff engine. + +### Theming/tokens +- Uses \`data-component="diff"\` with Pierre CSS variables and theme tokens. + +` + +const load = async () => { + return preloadMultiFileDiff({ + oldFile: diff.before, + newFile: diff.after, + options: createDefaultOptions("unified"), + }) +} + +const loadSplit = async () => { + return preloadMultiFileDiff({ + oldFile: diff.before, + newFile: diff.after, + options: createDefaultOptions("split"), + }) +} + +export default { + title: "UI/DiffSSR", + id: "components-diff-ssr", + component: mod.Diff, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => { + const [data] = createResource(load) + return ( + + Loading pre-rendered diff...
}> + {(preloaded) => ( +
+ +
+ )} + + + ) + }, +} + +export const Split = { + render: () => { + const [data] = createResource(loadSplit) + return ( + + Loading pre-rendered diff...
}> + {(preloaded) => ( +
+ +
+ )} +
+ + ) + }, +} diff --git a/packages/ui/src/components/diff.stories.tsx b/packages/ui/src/components/diff.stories.tsx new file mode 100644 index 00000000000..03bf4a0e0f0 --- /dev/null +++ b/packages/ui/src/components/diff.stories.tsx @@ -0,0 +1,96 @@ +// @ts-nocheck +import * as mod from "./diff" +import { create } from "../storybook/scaffold" +import { diff } from "../storybook/fixtures" + +const docs = `### Overview +Render a code diff with OpenCode styling using the Pierre diff engine. + +Pair with \`DiffChanges\` for summary counts. +Use \`LineComment\` or external UI for annotation workflows. + +### API +- Required: \`before\` and \`after\` file contents (name + contents). +- Optional: \`diffStyle\` ("unified" | "split"), \`annotations\`, \`selectedLines\`, \`commentedLines\`. +- Optional interaction: \`enableLineSelection\`, \`onLineSelectionEnd\`. +- Passes through Pierre FileDiff options (see component source). + +### Variants and states +- Unified and split diff styles. +- Optional line selection + commented line highlighting. + +### Behavior +- Re-renders when \`before\`/\`after\` or diff options change. +- Line selection uses mouse drag/selection when enabled. + +### Accessibility +- TODO: confirm keyboard behavior from the Pierre diff engine. +- Provide surrounding labels or headings when used as a standalone view. + +### Theming/tokens +- Uses \`data-component="diff"\` and Pierre CSS variables from \`styleVariables\`. +- Colors derive from theme tokens (diff add/delete, background, text). + +` + +const story = create({ + title: "UI/Diff", + mod, + args: { + before: diff.before, + after: diff.after, + diffStyle: "unified", + }, +}) + +export default { + title: "UI/Diff", + id: "components-diff", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + diffStyle: { + control: "select", + options: ["unified", "split"], + }, + enableLineSelection: { + control: "boolean", + }, + }, +} + +export const Unified = story.Basic + +export const Split = { + args: { + diffStyle: "split", + }, +} + +export const Selectable = { + args: { + enableLineSelection: true, + }, +} + +export const SelectedLines = { + args: { + selectedLines: { start: 2, end: 4 }, + }, +} + +export const CommentedLines = { + args: { + commentedLines: [ + { start: 1, end: 1 }, + { start: 4, end: 4 }, + ], + }, +} diff --git a/packages/ui/src/components/dock-prompt.stories.tsx b/packages/ui/src/components/dock-prompt.stories.tsx new file mode 100644 index 00000000000..de017a1ba26 --- /dev/null +++ b/packages/ui/src/components/dock-prompt.stories.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import * as mod from "./dock-prompt" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Docked prompt layout for questions and permission requests. + +Use with form controls or confirmation buttons in the footer. + +### API +- Required: \`kind\` (question | permission), \`header\`, \`children\`, \`footer\`. +- Optional: \`ref\` for measuring or focus management. + +### Variants and states +- Question and permission layouts (data attributes). + +### Behavior +- Pure layout component; behavior handled by parent. + +### Accessibility +- Ensure header and footer content provide clear context and actions. + +### Theming/tokens +- Uses \`data-component="dock-prompt"\` with kind data attribute. + +` + +const story = create({ + title: "UI/DockPrompt", + mod, + args: { + kind: "question", + header: "Header", + children: "Prompt content", + footer: "Footer", + }, +}) + +export default { + title: "UI/DockPrompt", + id: "components-dock-prompt", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Permission = { + args: { + kind: "permission", + header: "Allow access?", + children: "This action needs permission to proceed.", + footer: "Approve or deny", + }, +} diff --git a/packages/ui/src/components/dropdown-menu.stories.tsx b/packages/ui/src/components/dropdown-menu.stories.tsx new file mode 100644 index 00000000000..7a2d644fac3 --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.stories.tsx @@ -0,0 +1,97 @@ +// @ts-nocheck +import * as mod from "./dropdown-menu" +import { Button } from "./button" + +const docs = `### Overview +Dropdown menu built on Kobalte with composable items, groups, and submenus. + +Use \`DropdownMenu.ItemLabel\`/\`ItemDescription\` for richer rows. + +### API +- Root accepts Kobalte DropdownMenu props (\`open\`, \`defaultOpen\`, \`onOpenChange\`). +- Compose with \`Trigger\`, \`Content\`, \`Item\`, \`Separator\`, and optional \`Sub\` sections. + +### Variants and states +- Supports item groups, separators, and nested submenus. + +### Behavior +- Menu opens from trigger and renders in a portal by default. + +### Accessibility +- TODO: confirm keyboard navigation from Kobalte. + +### Theming/tokens +- Uses \`data-component="dropdown-menu"\` and slot attributes for styling. + +` + +export default { + title: "UI/DropdownMenu", + id: "components-dropdown-menu", + component: mod.DropdownMenu, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( + + + Open menu + + + + + Actions + + New file + + + Rename + Shift+R + + + + + More options + + + Duplicate + + + Move + + + + + + + ), +} + +export const CheckboxRadio = { + render: () => ( + + + Open menu + + + + Show line numbers + Wrap lines + + + Compact + Comfortable + + + + + ), +} diff --git a/packages/ui/src/components/favicon.stories.tsx b/packages/ui/src/components/favicon.stories.tsx new file mode 100644 index 00000000000..a693c0f4607 --- /dev/null +++ b/packages/ui/src/components/favicon.stories.tsx @@ -0,0 +1,49 @@ +// @ts-nocheck +import * as mod from "./favicon" + +const docs = `### Overview +Injects favicon and app icon meta tags for the document head. + +Render once near the app root (head management). + +### API +- No props. + +### Variants and states +- Single configuration. + +### Behavior +- Registers link and meta tags via Solid Meta components. + +### Accessibility +- Not applicable. + +### Theming/tokens +- Not applicable. + +` + +export default { + title: "UI/Favicon", + id: "components-favicon", + component: mod.Favicon, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( +
+ +
+ Head tags are injected for favicon and app icons. +
+
+ ), +} diff --git a/packages/ui/src/components/file-icon.stories.tsx b/packages/ui/src/components/file-icon.stories.tsx new file mode 100644 index 00000000000..937328502f7 --- /dev/null +++ b/packages/ui/src/components/file-icon.stories.tsx @@ -0,0 +1,94 @@ +// @ts-nocheck +import * as mod from "./file-icon" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +File and folder icon renderer based on file name and extension. + +Use in file trees and lists. + +### API +- Required: \`node\` with \`path\` and \`type\`. +- Optional: \`expanded\` (for folders), \`mono\` for monochrome rendering. + +### Variants and states +- Folder vs file icons; expanded folder variant. + +### Behavior +- Maps file names and extensions to sprite icons. + +### Accessibility +- Provide adjacent text labels for filenames; icons are decorative. + +### Theming/tokens +- Uses \`data-component="file-icon"\` and sprite-based styling. + +` + +const story = create({ + title: "UI/FileIcon", + mod, + args: { + node: { path: "package.json", type: "file" }, + mono: true, + }, +}) + +export default { + title: "UI/FileIcon", + id: "components-file-icon", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Folder = { + args: { + node: { path: "src", type: "directory" }, + expanded: true, + mono: false, + }, +} + +export const Samples = { + render: () => { + const items = [ + { path: "README.md", type: "file" }, + { path: "package.json", type: "file" }, + { path: "tsconfig.json", type: "file" }, + { path: "index.ts", type: "file" }, + { path: "styles.css", type: "file" }, + { path: "logo.svg", type: "file" }, + { path: "photo.png", type: "file" }, + { path: "Dockerfile", type: "file" }, + { path: ".env", type: "file" }, + { path: "src", type: "directory" }, + { path: "public", type: "directory" }, + ] as const + + return ( +
+ {items.map((node) => ( +
+ +
{node.path}
+
+ ))} +
+ ) + }, +} diff --git a/packages/ui/src/components/font.stories.tsx b/packages/ui/src/components/font.stories.tsx new file mode 100644 index 00000000000..153a2c8dc9b --- /dev/null +++ b/packages/ui/src/components/font.stories.tsx @@ -0,0 +1,48 @@ +// @ts-nocheck +import * as mod from "./font" + +const docs = `### Overview +Loads OpenCode typography assets and mono nerd fonts. + +Render once at the app root or Storybook preview. + +### API +- No props. + +### Variants and states +- Fonts include sans and multiple mono families. + +### Behavior +- Injects @font-face rules and preload links into the document head. + +### Accessibility +- Not applicable. + +### Theming/tokens +- Provides font families used by theme tokens. + +` + +export default { + title: "UI/Font", + id: "components-font", + component: mod.Font, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( +
+ +
OpenCode Sans Sample
+
OpenCode Mono Sample
+
+ ), +} diff --git a/packages/ui/src/components/hover-card.stories.tsx b/packages/ui/src/components/hover-card.stories.tsx new file mode 100644 index 00000000000..3f5cf102818 --- /dev/null +++ b/packages/ui/src/components/hover-card.stories.tsx @@ -0,0 +1,70 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./hover-card" + +const docs = `### Overview +Hover-triggered card for lightweight previews and metadata. + +Use for short summaries; avoid dense interactive controls. + +### API +- Required: \`trigger\` element. +- Children render inside the hover card body. + +### Variants and states +- None; content and trigger are fully composable. + +### Behavior +- Opens on hover/focus over the trigger. + +### Accessibility +- TODO: confirm focus and hover intent behavior from Kobalte. + +### Theming/tokens +- Uses \`data-component="hover-card-content"\` and slots for styling. + +` + +export default { + title: "UI/HoverCard", + id: "components-hover-card", + component: mod.HoverCard, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( + Hover me}> +
+
Preview
+
Short supporting text.
+
+
+ ), +} + +export const InlineMount = { + render: () => { + const [mount, setMount] = createSignal(undefined) + return ( +
+ Hover me} + > +
+
Mounted inside
+
Uses custom mount node.
+
+
+
+ ) + }, +} diff --git a/packages/ui/src/components/icon-button.stories.tsx b/packages/ui/src/components/icon-button.stories.tsx new file mode 100644 index 00000000000..9782759f823 --- /dev/null +++ b/packages/ui/src/components/icon-button.stories.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +import * as mod from "./icon-button" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Compact icon-only button with size and variant control. + +Use \`Button\` for text labels and primary actions. + +### API +- Required: \`icon\` icon name. +- Optional: \`size\`, \`iconSize\`, \`variant\`. +- Inherits Kobalte Button props and native button attributes. + +### Variants and states +- Variants: primary, secondary, ghost. +- Sizes: small, normal, large. + +### Behavior +- Icon size adapts to button size unless overridden. + +### Accessibility +- Provide \`aria-label\` when there is no visible text. + +### Theming/tokens +- Uses \`data-component="icon-button"\` and size/variant data attributes. + +` + +const story = create({ title: "UI/IconButton", mod, args: { icon: "check", "aria-label": "Icon" } }) +export default { + title: "UI/IconButton", + id: "components-icon-button", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} + +export const Variants = { + render: () => ( +
+ + + +
+ ), +} + +export const IconSizeOverride = { + render: () => ( +
+ + +
+ ), +} diff --git a/packages/ui/src/components/icon.stories.tsx b/packages/ui/src/components/icon.stories.tsx new file mode 100644 index 00000000000..1986d747722 --- /dev/null +++ b/packages/ui/src/components/icon.stories.tsx @@ -0,0 +1,170 @@ +// @ts-nocheck +import * as mod from "./icon" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Inline icon renderer using the built-in OpenCode icon set. + +Use with \`Button\`, \`IconButton\`, and menu items. + +### API +- Required: \`name\` (icon key). +- Optional: \`size\` (small | normal | medium | large). +- Accepts standard SVG props. + +### Variants and states +- Size variants only. + +### Behavior +- Uses an internal SVG path map. + +### Accessibility +- Icons are aria-hidden by default; wrap with accessible text when needed. + +### Theming/tokens +- Uses \`data-component="icon"\` with size data attributes. + +` + +const names = [ + "align-right", + "arrow-up", + "arrow-left", + "arrow-right", + "archive", + "bubble-5", + "prompt", + "brain", + "bullet-list", + "check-small", + "chevron-down", + "chevron-left", + "chevron-right", + "chevron-grabber-vertical", + "chevron-double-right", + "circle-x", + "close", + "close-small", + "checklist", + "console", + "expand", + "collapse", + "code", + "code-lines", + "circle-ban-sign", + "edit-small-2", + "eye", + "enter", + "folder", + "file-tree", + "file-tree-active", + "magnifying-glass", + "plus-small", + "plus", + "new-session", + "pencil-line", + "mcp", + "glasses", + "magnifying-glass-menu", + "window-cursor", + "task", + "stop", + "layout-left", + "layout-left-partial", + "layout-left-full", + "layout-right", + "layout-right-partial", + "layout-right-full", + "square-arrow-top-right", + "open-file", + "speech-bubble", + "comment", + "folder-add-left", + "github", + "discord", + "layout-bottom", + "layout-bottom-partial", + "layout-bottom-full", + "dot-grid", + "circle-check", + "copy", + "check", + "photo", + "share", + "download", + "menu", + "server", + "branch", + "edit", + "help", + "settings-gear", + "dash", + "cloud-upload", + "trash", + "sliders", + "keyboard", + "selector", + "arrow-down-to-line", + "warning", + "link", + "providers", + "models", +] + +const story = create({ title: "UI/Icon", mod, args: { name: "check" } }) + +export default { + title: "UI/Icon", + id: "components-icon", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + name: { + control: "select", + options: names, + }, + size: { + control: "select", + options: ["small", "normal", "medium", "large"], + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ + + + +
+ ), +} + +export const Gallery = { + render: () => ( +
+ {names.map((name) => ( +
+ +
{name}
+
+ ))} +
+ ), +} diff --git a/packages/ui/src/components/image-preview.stories.tsx b/packages/ui/src/components/image-preview.stories.tsx new file mode 100644 index 00000000000..f0a00c78251 --- /dev/null +++ b/packages/ui/src/components/image-preview.stories.tsx @@ -0,0 +1,59 @@ +// @ts-nocheck +import { onMount } from "solid-js" +import * as mod from "./image-preview" +import { Button } from "./button" +import { useDialog } from "../context/dialog" + +const docs = `### Overview +Image preview content intended to render inside the dialog stack. + +Use for full-size image inspection; keep images optimized. + +### API +- Required: \`src\`. +- Optional: \`alt\` text. + +### Variants and states +- Single layout with close action. + +### Behavior +- Intended to be used via \`useDialog().show\`. + +### Accessibility +- Uses localized aria-label for close button. + +### Theming/tokens +- Uses \`data-component="image-preview"\` and slot attributes. + +` + +export default { + title: "UI/ImagePreview", + id: "components-image-preview", + component: mod.ImagePreview, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => { + const dialog = useDialog() + const src = "https://placehold.co/640x360/png" + + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) + }, +} diff --git a/packages/ui/src/components/inline-input.stories.tsx b/packages/ui/src/components/inline-input.stories.tsx new file mode 100644 index 00000000000..e364c896315 --- /dev/null +++ b/packages/ui/src/components/inline-input.stories.tsx @@ -0,0 +1,50 @@ +// @ts-nocheck +import * as mod from "./inline-input" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Compact inline input for short values. + +Use inside text or table rows for quick edits. + +### API +- Optional: \`width\` to set a fixed width. +- Accepts standard input props. + +### Variants and states +- No built-in variants; style via class or width. + +### Behavior +- Uses inline width when provided. + +### Accessibility +- Provide a label or aria-label when used standalone. + +### Theming/tokens +- Uses \`data-component="inline-input"\`. + +` + +const story = create({ title: "UI/InlineInput", mod, args: { placeholder: "Type...", value: "Inline" } }) +export default { + title: "UI/InlineInput", + id: "components-inline-input", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const FixedWidth = { + args: { + value: "80px", + width: "80px", + }, +} diff --git a/packages/ui/src/components/keybind.stories.tsx b/packages/ui/src/components/keybind.stories.tsx new file mode 100644 index 00000000000..a458a53a742 --- /dev/null +++ b/packages/ui/src/components/keybind.stories.tsx @@ -0,0 +1,43 @@ +// @ts-nocheck +import * as mod from "./keybind" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Keyboard shortcut pill for displaying keybindings. + +Pair with menu items or command palettes. + +### API +- Children render the key sequence text. +- Accepts standard span props. + +### Variants and states +- Single visual style. + +### Behavior +- Presentational only. + +### Accessibility +- Ensure text conveys the shortcut (e.g., "Cmd+K"). + +### Theming/tokens +- Uses \`data-component="keybind"\`. + +` + +const story = create({ title: "UI/Keybind", mod, args: { children: "Cmd+K" } }) +export default { + title: "UI/Keybind", + id: "components-keybind", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic diff --git a/packages/ui/src/components/line-comment.stories.tsx b/packages/ui/src/components/line-comment.stories.tsx new file mode 100644 index 00000000000..c48674e3c74 --- /dev/null +++ b/packages/ui/src/components/line-comment.stories.tsx @@ -0,0 +1,115 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./line-comment" + +const docs = `### Overview +Inline comment anchor and editor for code review or annotation flows. + +Pair with \`Diff\` or \`Code\` to align comments to lines. + +### API +- \`LineCommentAnchor\`: position with \`top\`, control \`open\`, render custom children. +- \`LineComment\`: convenience wrapper for displaying comment + selection label. +- \`LineCommentEditor\`: controlled textarea with submit/cancel handlers. + +### Variants and states +- Default display and editor display variants. + +### Behavior +- Anchor positions relative to a containing element. +- Editor submits on Enter (Shift+Enter for newline). + +### Accessibility +- TODO: confirm ARIA labeling for comment button and editor textarea. + +### Theming/tokens +- Uses \`data-component="line-comment"\` and related slots. + +` + +export default { + title: "UI/LineComment", + id: "components-line-comment", + component: mod.LineComment, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Default = { + render: () => ( +
+
12 | const total = sum(values)
+
13 | return total / values.length
+ +
+ ), +} + +export const Editor = { + render: () => { + const [value, setValue] = createSignal("Add context for this change.") + return ( +
+
40 | if (values.length === 0) return 0
+ setValue("")} + onSubmit={(next) => setValue(next)} + /> +
+ ) + }, +} + +export const AnchorOnly = { + render: () => ( +
+
20 | const ready = true
+ +
Anchor content
+
+
+ ), +} diff --git a/packages/ui/src/components/list.stories.tsx b/packages/ui/src/components/list.stories.tsx new file mode 100644 index 00000000000..280f323c0bd --- /dev/null +++ b/packages/ui/src/components/list.stories.tsx @@ -0,0 +1,170 @@ +// @ts-nocheck +import * as mod from "./list" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Filterable list with keyboard navigation and optional search input. + +Use within panels or popovers where keyboard navigation is expected. + +### API +- Required: \`items\` and \`key\`. +- Required: \`children\` render function for items. +- Optional: \`search\`, \`filterKeys\`, \`groupBy\`, \`onSelect\`, \`onKeyEvent\`. + +### Variants and states +- Optional search bar and group headers. + +### Behavior +- Uses fuzzy search when \`search\` is enabled. +- Keyboard navigation via arrow keys; Enter selects. + +### Accessibility +- TODO: confirm ARIA roles for list items and search input. + +### Theming/tokens +- Uses \`data-component="list"\` and data slots for structure. + +` + +const story = create({ + title: "UI/List", + mod, + args: { + items: ["One", "Two", "Three", "Four"], + key: (x: string) => x, + children: (x: string) => x, + search: true, + }, +}) + +export default { + title: "UI/List", + id: "components-list", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Grouped = { + render: () => { + const items = [ + { id: "a1", title: "Alpha", group: "Group A" }, + { id: "a2", title: "Bravo", group: "Group A" }, + { id: "b1", title: "Delta", group: "Group B" }, + ] + return ( + item.id} groupBy={(item) => item.group} search={true}> + {(item) => item.title} + + ) + }, +} + +export const Empty = { + render: () => ( + item} search={true}> + {(item) => item} + + ), +} + +export const WithAdd = { + render: () => ( + item} + search={true} + add={{ + render: () => ( + + ), + }} + > + {(item) => item} + + ), +} + +export const Divider = { + render: () => ( + item} divider={true}> + {(item) => item} + + ), +} + +export const ActiveIcon = { + render: () => ( + item} activeIcon="chevron-right"> + {(item) => item} + + ), +} + +export const NoSearch = { + render: () => ( + item} search={false}> + {(item) => item} + + ), +} + +export const SearchOptions = { + render: () => ( + item} + search={{ + placeholder: "Filter...", + hideIcon: true, + action: , + }} + > + {(item) => item} + + ), +} + +export const ItemWrapper = { + render: () => ( + item} + itemWrapper={(item, node) => ( +
{node}
+ )} + > + {(item) => item} +
+ ), +} + +export const GroupHeader = { + render: () => { + const items = [ + { id: "a1", title: "Alpha", group: "Group A" }, + { id: "b1", title: "Beta", group: "Group B" }, + ] + return ( + item.id} + groupBy={(item) => item.group} + groupHeader={(group) => {group.category}} + > + {(item) => item.title} + + ) + }, +} diff --git a/packages/ui/src/components/logo.stories.tsx b/packages/ui/src/components/logo.stories.tsx new file mode 100644 index 00000000000..3f5dd9cef73 --- /dev/null +++ b/packages/ui/src/components/logo.stories.tsx @@ -0,0 +1,57 @@ +// @ts-nocheck +import * as mod from "./logo" + +const docs = `### Overview +OpenCode logo assets: mark, splash, and wordmark. + +Use Mark for compact spaces, Logo for headers, Splash for hero sections. + +### API +- \`Mark\`, \`Splash\`, and \`Logo\` components accept standard SVG props. + +### Variants and states +- Multiple logo variants for different contexts. + +### Behavior +- Pure SVG rendering. + +### Accessibility +- Provide title/aria-label when logos convey meaning. + +### Theming/tokens +- Uses theme color tokens via CSS variables. + +` + +export default { + title: "UI/Logo", + id: "components-logo", + component: mod.Logo, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( +
+
+
Mark
+ +
+
+
Splash
+ +
+
+
Logo
+ +
+
+ ), +} diff --git a/packages/ui/src/components/markdown.stories.tsx b/packages/ui/src/components/markdown.stories.tsx new file mode 100644 index 00000000000..cae42948696 --- /dev/null +++ b/packages/ui/src/components/markdown.stories.tsx @@ -0,0 +1,53 @@ +// @ts-nocheck +import * as mod from "./markdown" +import { create } from "../storybook/scaffold" +import { markdown } from "../storybook/fixtures" + +const docs = `### Overview +Render sanitized Markdown with code blocks, inline code, and safe links. + +Pair with \`Code\` for standalone code views. + +### API +- Required: \`text\` Markdown string. +- Uses the Marked context provider for parsing and sanitization. + +### Variants and states +- Code blocks include copy buttons when rendered. + +### Behavior +- Sanitizes HTML and auto-converts inline URL code to links. +- Adds copy buttons to code blocks. + +### Accessibility +- Copy buttons include aria-labels from i18n. +- TODO: confirm link target behavior in sanitized output. + +### Theming/tokens +- Uses \`data-component="markdown"\` and related slots for styling. + +` + +const story = create({ + title: "UI/Markdown", + mod, + args: { + text: markdown, + }, +}) + +export default { + title: "UI/Markdown", + id: "components-markdown", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic diff --git a/packages/ui/src/components/message-nav.stories.tsx b/packages/ui/src/components/message-nav.stories.tsx new file mode 100644 index 00000000000..7ce31a7beda --- /dev/null +++ b/packages/ui/src/components/message-nav.stories.tsx @@ -0,0 +1,7 @@ +// @ts-nocheck +import * as mod from "./message-nav" +import { create } from "../storybook/scaffold" + +const story = create({ title: "UI/MessageNav", mod }) +export default { title: "UI/MessageNav", id: "components-message-nav", component: story.meta.component } +export const Basic = story.Basic diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index ce76d8e1887..bea33ff54cf 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -252,6 +252,12 @@ } } +@media (prefers-color-scheme: dark) { + [data-component="reasoning-part"] [data-component="markdown"] :not(pre) > code { + opacity: 0.6; + } +} + [data-component="tool-error"] { display: flex; align-items: start; @@ -654,105 +660,6 @@ [data-component="tool-part-wrapper"] { width: 100%; - - &[data-permission="true"], - &[data-question="true"] { - position: sticky; - top: calc(2px + var(--sticky-header-height, 40px)); - bottom: 0px; - z-index: 20; - border-radius: 6px; - border: none; - box-shadow: var(--shadow-xs-border-base); - background-color: var(--surface-raised-base); - overflow: visible; - overflow-anchor: none; - - & > *:first-child { - border-top-left-radius: 6px; - border-top-right-radius: 6px; - overflow: hidden; - } - - & > *:last-child { - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; - overflow: hidden; - } - - [data-component="collapsible"] { - border: none; - } - - [data-component="card"] { - border: none; - } - } - - &[data-permission="true"] { - &::before { - content: ""; - position: absolute; - inset: -1.5px; - top: -5px; - border-radius: 7.5px; - border: 1.5px solid transparent; - background: - linear-gradient(var(--background-base) 0 0) padding-box, - conic-gradient( - from var(--border-angle), - transparent 0deg, - transparent 0deg, - var(--border-warning-strong, var(--border-warning-selected)) 300deg, - var(--border-warning-base) 360deg - ) - border-box; - animation: chase-border 2.5s linear infinite; - pointer-events: none; - z-index: -1; - } - } - - &[data-question="true"] { - background: var(--background-base); - border: 1px solid var(--border-weak-base); - } -} - -@property --border-angle { - syntax: ""; - initial-value: 0deg; - inherits: false; -} - -@keyframes chase-border { - from { - --border-angle: 0deg; - } - - to { - --border-angle: 360deg; - } -} - -[data-component="permission-prompt"] { - display: flex; - flex-direction: column; - padding: 8px 12px; - background-color: var(--surface-raised-strong); - border-radius: 0 0 6px 6px; - - [data-slot="permission-actions"] { - display: flex; - align-items: center; - gap: 8px; - justify-content: flex-end; - - [data-component="button"] { - padding-left: 12px; - padding-right: 12px; - } - } } [data-component="dock-prompt"][data-kind="permission"] { @@ -867,7 +774,7 @@ } } -:is([data-component="question-prompt"], [data-component="dock-prompt"][data-kind="question"]) { +[data-component="dock-prompt"][data-kind="question"] { position: relative; display: flex; flex-direction: column; diff --git a/packages/ui/src/components/message-part.stories.tsx b/packages/ui/src/components/message-part.stories.tsx new file mode 100644 index 00000000000..28489dc7b1b --- /dev/null +++ b/packages/ui/src/components/message-part.stories.tsx @@ -0,0 +1,7 @@ +// @ts-nocheck +import * as mod from "./message-part" +import { create } from "../storybook/scaffold" + +const story = create({ title: "UI/MessagePart", mod }) +export default { title: "UI/MessagePart", id: "components-message-part", component: story.meta.component } +export const Basic = story.Basic diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index adba42ce930..0f67d683f6a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -23,11 +23,9 @@ import { ToolPart, UserMessage, Todo, - QuestionRequest, QuestionAnswer, QuestionInfo, } from "@opencode-ai/sdk/v2" -import { createStore } from "solid-js/store" import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { useCodeComponent } from "../context/code" @@ -37,7 +35,6 @@ import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" -import { Button } from "./button" import { Card } from "./card" import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" @@ -148,17 +145,22 @@ function createThrottledValue(getValue: () => string) { return value } -function relativizeProjectPaths(text: string, directory?: string) { - if (!text) return "" - if (!directory) return text - if (directory === "/") return text - if (directory === "\\") return text - return text.split(directory).join("") +function relativizeProjectPath(path: string, directory?: string) { + if (!path) return "" + if (!directory) return path + if (directory === "/") return path + if (directory === "\\") return path + if (path === directory) return "" + + const separator = directory.includes("\\") ? "\\" : "/" + const prefix = directory.endsWith(separator) ? directory : directory + separator + if (!path.startsWith(prefix)) return path + return path.slice(directory.length) } function getDirectory(path: string | undefined) { const data = useData() - return relativizeProjectPaths(_getDirectory(path), data.directory) + return relativizeProjectPath(_getDirectory(path), data.directory) } import type { IconProps } from "./icon" @@ -950,7 +952,6 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre } PART_MAPPING["tool"] = function ToolPartDisplay(props) { - const data = useData() const i18n = useI18n() const part = props.part as ToolPart if (part.tool === "todowrite" || part.tool === "todoread") return null @@ -959,75 +960,18 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { () => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"), ) - const permission = createMemo(() => { - const next = data.store.permission?.[props.message.sessionID]?.[0] - if (!next || !next.tool) return undefined - if (next.tool!.callID !== part.callID) return undefined - return next - }) - - const questionRequest = createMemo(() => { - const next = data.store.question?.[props.message.sessionID]?.[0] - if (!next || !next.tool) return undefined - if (next.tool!.callID !== part.callID) return undefined - return next - }) - - const [showPermission, setShowPermission] = createSignal(false) - const [showQuestion, setShowQuestion] = createSignal(false) - - createEffect(() => { - const perm = permission() - if (perm) { - const timeout = setTimeout(() => setShowPermission(true), 50) - onCleanup(() => clearTimeout(timeout)) - } else { - setShowPermission(false) - } - }) - - createEffect(() => { - const question = questionRequest() - if (question) { - const timeout = setTimeout(() => setShowQuestion(true), 50) - onCleanup(() => clearTimeout(timeout)) - } else { - setShowQuestion(false) - } - }) - - const [forceOpen, setForceOpen] = createSignal(false) - createEffect(() => { - if (permission() || questionRequest()) setForceOpen(true) - }) - - const respond = (response: "once" | "always" | "reject") => { - const perm = permission() - if (!perm || !data.respondToPermission) return - data.respondToPermission({ - sessionID: perm.sessionID, - permissionID: perm.id, - response, - }) - } - const emptyInput: Record = {} const emptyMetadata: Record = {} const input = () => part.state?.input ?? emptyInput // @ts-expect-error const partMetadata = () => part.state?.metadata ?? emptyMetadata - const metadata = () => { - const perm = permission() - if (perm?.metadata) return { ...perm.metadata, ...partMetadata() } - return partMetadata() - } const render = ToolRegistry.render(part.tool) ?? GenericTool return ( -
+
{(error) => { @@ -1067,33 +1011,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { component={render} input={input()} tool={part.tool} - metadata={metadata()} + metadata={partMetadata()} // @ts-expect-error output={part.state.output} status={part.state.status} hideDetails={props.hideDetails} - forceOpen={forceOpen()} - locked={showPermission() || showQuestion()} defaultOpen={props.defaultOpen} /> - -
-
- - - -
-
-
- {(request) => }
) @@ -1145,7 +1071,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return items.filter((x) => !!x).join(" \u00B7 ") }) - const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) + const displayText = () => (part.text ?? "").trim() const throttledText = createThrottledValue(displayText) const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) @@ -1247,7 +1173,7 @@ ToolRegistry.register({
- {i18n.t("ui.tool.loaded")} {relativizeProjectPaths(filepath, data.directory)} + {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
)} @@ -1963,245 +1889,3 @@ ToolRegistry.register({ ) }, }) - -function QuestionPrompt(props: { request: QuestionRequest }) { - const data = useData() - const i18n = useI18n() - const questions = createMemo(() => props.request.questions) - const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) - - const [store, setStore] = createStore({ - tab: 0, - answers: [] as QuestionAnswer[], - custom: [] as string[], - editing: false, - }) - - const question = createMemo(() => questions()[store.tab]) - const confirm = createMemo(() => !single() && store.tab === questions().length) - const options = createMemo(() => question()?.options ?? []) - const input = createMemo(() => store.custom[store.tab] ?? "") - const multi = createMemo(() => question()?.multiple === true) - const customPicked = createMemo(() => { - const value = input() - if (!value) return false - return store.answers[store.tab]?.includes(value) ?? false - }) - - function submit() { - const answers = questions().map((_, i) => store.answers[i] ?? []) - data.replyToQuestion?.({ - requestID: props.request.id, - answers, - }) - } - - function reject() { - data.rejectQuestion?.({ - requestID: props.request.id, - }) - } - - function pick(answer: string, custom: boolean = false) { - const answers = [...store.answers] - answers[store.tab] = [answer] - setStore("answers", answers) - if (custom) { - const inputs = [...store.custom] - inputs[store.tab] = answer - setStore("custom", inputs) - } - if (single()) { - data.replyToQuestion?.({ - requestID: props.request.id, - answers: [[answer]], - }) - return - } - setStore("tab", store.tab + 1) - } - - function toggle(answer: string) { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - const index = next.indexOf(answer) - if (index === -1) next.push(answer) - if (index !== -1) next.splice(index, 1) - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) - } - - function selectTab(index: number) { - setStore("tab", index) - setStore("editing", false) - } - - function selectOption(optIndex: number) { - if (optIndex === options().length) { - setStore("editing", true) - return - } - const opt = options()[optIndex] - if (!opt) return - if (multi()) { - toggle(opt.label) - return - } - pick(opt.label) - } - - function handleCustomSubmit(e: Event) { - e.preventDefault() - const value = input().trim() - if (!value) { - setStore("editing", false) - return - } - if (multi()) { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - if (!next.includes(value)) next.push(value) - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) - setStore("editing", false) - return - } - pick(value, true) - setStore("editing", false) - } - - return ( -
- -
- - {(q, index) => { - const active = () => index() === store.tab - const answered = () => (store.answers[index()]?.length ?? 0) > 0 - return ( - - ) - }} - - -
-
- - -
-
- {question()?.question} - {multi() ? " " + i18n.t("ui.question.multiHint") : ""} -
-
- - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( - - ) - }} - - - -
- setTimeout(() => el.focus(), 0)} - type="text" - data-slot="custom-input" - placeholder={i18n.t("ui.question.custom.placeholder")} - value={input()} - onInput={(e) => { - const inputs = [...store.custom] - inputs[store.tab] = e.currentTarget.value - setStore("custom", inputs) - }} - /> - - -
-
-
-
-
- - -
-
{i18n.t("ui.messagePart.review.title")}
- - {(q, index) => { - const value = () => store.answers[index()]?.join(", ") ?? "" - const answered = () => Boolean(value()) - return ( -
- {q.question} - - {answered() ? value() : i18n.t("ui.question.review.notAnswered")} - -
- ) - }} -
-
-
- -
- - - - - - - - - -
-
- ) -} diff --git a/packages/ui/src/components/popover.stories.tsx b/packages/ui/src/components/popover.stories.tsx new file mode 100644 index 00000000000..e5117b451b1 --- /dev/null +++ b/packages/ui/src/components/popover.stories.tsx @@ -0,0 +1,87 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./popover" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Composable popover with optional title, description, and close button. + +Use for small contextual details; avoid long forms. + +### API +- \`trigger\` and \`children\` define the anchor and content. +- Optional: \`title\`, \`description\`, \`portal\`, \`open\`, \`defaultOpen\`. + +### Variants and states +- Supports controlled and uncontrolled open state. + +### Behavior +- Closes on outside click or Escape by default. + +### Accessibility +- TODO: confirm focus management from Kobalte. + +### Theming/tokens +- Uses \`data-component="popover-content"\` and related slots. + +` + +const story = create({ + title: "UI/Popover", + mod, + args: { + trigger: "Open popover", + title: "Popover", + description: "Optional description", + defaultOpen: true, + children: "Popover content", + }, +}) + +export default { + title: "UI/Popover", + id: "components-popover", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const NoHeader = { + args: { + title: undefined, + description: undefined, + children: "Popover body only", + }, +} + +export const Inline = { + args: { + portal: false, + defaultOpen: true, + }, +} + +export const Controlled = { + render: () => { + const [open, setOpen] = createSignal(true) + return ( + + Controlled content + + ) + }, +} diff --git a/packages/ui/src/components/progress-circle.stories.tsx b/packages/ui/src/components/progress-circle.stories.tsx new file mode 100644 index 00000000000..5bc23c3108c --- /dev/null +++ b/packages/ui/src/components/progress-circle.stories.tsx @@ -0,0 +1,59 @@ +// @ts-nocheck +import * as mod from "./progress-circle" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Circular progress indicator for compact loading states. + +Pair with labels for clarity in dashboards. + +### API +- Required: \`percentage\` (0-100). +- Optional: \`size\`, \`strokeWidth\`. + +### Variants and states +- Single visual style; size and stroke width adjust appearance. + +### Behavior +- Percentage is clamped between 0 and 100. + +### Accessibility +- Use alongside text or aria-live messaging for progress context. + +### Theming/tokens +- Uses \`data-component="progress-circle"\` with background/progress slots. + +` + +const story = create({ title: "UI/ProgressCircle", mod, args: { percentage: 65, size: 48 } }) + +export default { + title: "UI/ProgressCircle", + id: "components-progress-circle", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + percentage: { + control: { type: "range", min: 0, max: 100, step: 1 }, + }, + }, +} + +export const Basic = story.Basic + +export const States = { + render: () => ( +
+ + + +
+ ), +} diff --git a/packages/ui/src/components/progress.stories.tsx b/packages/ui/src/components/progress.stories.tsx new file mode 100644 index 00000000000..2ee32234349 --- /dev/null +++ b/packages/ui/src/components/progress.stories.tsx @@ -0,0 +1,67 @@ +// @ts-nocheck +import * as mod from "./progress" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Linear progress indicator with optional label and value display. + +Use in forms, uploads, or background tasks. + +### API +- \`value\` and \`maxValue\` control progress. +- Optional: \`showValueLabel\`, \`hideLabel\`. +- Children provide the label text. + +### Variants and states +- Supports indeterminate state via Kobalte props (if provided). + +### Behavior +- Uses Kobalte Progress for value calculation. + +### Accessibility +- TODO: confirm ARIA attributes from Kobalte. + +### Theming/tokens +- Uses \`data-component="progress"\` with track/fill slots. + +` + +const story = create({ + title: "UI/Progress", + mod, + args: { + value: 60, + maxValue: 100, + children: "Progress", + showValueLabel: true, + }, +}) + +export default { + title: "UI/Progress", + id: "components-progress", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const NoLabel = { + args: { + children: "", + hideLabel: true, + showValueLabel: false, + value: 30, + }, +} + +export const Indeterminate = { + render: () => Loading, +} diff --git a/packages/ui/src/components/provider-icon.stories.tsx b/packages/ui/src/components/provider-icon.stories.tsx new file mode 100644 index 00000000000..e7fc39967bf --- /dev/null +++ b/packages/ui/src/components/provider-icon.stories.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +import { iconNames } from "./provider-icons/types" +import * as mod from "./provider-icon" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Provider icon sprite renderer for model/provider badges. + +Use in model pickers or provider lists. + +### API +- Required: \`id\` (provider icon name). +- Accepts standard SVG props. + +### Variants and states +- Single visual style; size via CSS. + +### Behavior +- Renders from the provider SVG sprite sheet. + +### Accessibility +- Provide accessible text nearby when the icon conveys meaning. + +### Theming/tokens +- Uses \`data-component="provider-icon"\`. + +` + +const story = create({ title: "UI/ProviderIcon", mod, args: { id: "openai" } }) +export default { + title: "UI/ProviderIcon", + id: "components-provider-icon", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + id: { + control: "select", + options: iconNames, + }, + }, +} + +export const Basic = story.Basic + +export const AllIcons = { + render: () => ( +
+ {iconNames.map((id) => ( +
+ +
{id}
+
+ ))} +
+ ), +} diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index 88406fa8c3c..29d22461d05 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -87,6 +87,18 @@ fill="currentColor" > + + + + @@ -175,6 +187,36 @@ fill="currentColor" > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/components/provider-icons/types.ts b/packages/ui/src/components/provider-icons/types.ts index 89fbc0625f5..bafa7ffaf04 100644 --- a/packages/ui/src/components/provider-icons/types.ts +++ b/packages/ui/src/components/provider-icons/types.ts @@ -10,6 +10,7 @@ export const iconNames = [ "xai", "wandb", "vultr", + "vivgrid", "vercel", "venice", "v0", @@ -17,11 +18,16 @@ export const iconNames = [ "togetherai", "synthetic", "submodel", + "stepfun", + "stackit", "siliconflow", "siliconflow-cn", "scaleway", "sap-ai-core", "requesty", + "qiniu-ai", + "qihang-ai", + "privatemode-ai", "poe", "perplexity", "ovhcloud", @@ -30,19 +36,28 @@ export const iconNames = [ "openai", "ollama-cloud", "nvidia", + "novita-ai", + "nova", "nebius", "nano-gpt", "morph", "moonshotai", "moonshotai-cn", "modelscope", + "moark", "mistral", "minimax", + "minimax-coding-plan", "minimax-cn", + "minimax-cn-coding-plan", + "meganova", "lucidquery", "lmstudio", "llama", + "kuae-cloud-coding-plan", "kimi-for-coding", + "kilo", + "jiekou", "io-net", "inference", "inception", @@ -53,9 +68,11 @@ export const iconNames = [ "google", "google-vertex", "google-vertex-anthropic", + "gitlab", "github-models", "github-copilot", "friendli", + "firmware", "fireworks-ai", "fastrouter", "deepseek", @@ -64,8 +81,10 @@ export const iconNames = [ "cohere", "cloudflare-workers-ai", "cloudflare-ai-gateway", + "cloudferro-sherlock", "chutes", "cerebras", + "berget", "baseten", "bailing", "azure", @@ -76,6 +95,7 @@ export const iconNames = [ "alibaba-cn", "aihubmix", "abacus", + "302ai", ] as const export type IconName = (typeof iconNames)[number] diff --git a/packages/ui/src/components/radio-group.stories.tsx b/packages/ui/src/components/radio-group.stories.tsx new file mode 100644 index 00000000000..4900ead8467 --- /dev/null +++ b/packages/ui/src/components/radio-group.stories.tsx @@ -0,0 +1,92 @@ +// @ts-nocheck +import * as mod from "./radio-group" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Segmented radio group for choosing a single option. + +Use for view toggles or mode selection. + +### API +- Required: \`options\`. +- Optional: \`current\`, \`defaultValue\`, \`value\`, \`label\`, \`onSelect\`. +- Optional layout: \`size\`, \`fill\`, \`pad\`. + +### Variants and states +- Size variants: small, medium. +- Optional fill and padding behavior. + +### Behavior +- Maps options to segmented items and manages selection. + +### Accessibility +- TODO: confirm role/aria attributes from Kobalte SegmentedControl. + +### Theming/tokens +- Uses \`data-component="radio-group"\` with size/pad data attributes. + +` + +const story = create({ + title: "UI/RadioGroup", + mod, + args: { + options: ["One", "Two", "Three"], + defaultValue: "One", + }, +}) + +export default { + title: "UI/RadioGroup", + id: "components-radio-group", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + size: { + control: "select", + options: ["small", "medium"], + }, + pad: { + control: "select", + options: ["none", "normal"], + }, + fill: { + control: "boolean", + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ + +
+ ), +} + +export const Filled = { + args: { + fill: true, + pad: "none", + }, +} + +export const CustomLabels = { + render: () => ( + (value === "list" ? "List view" : "Grid view")} + /> + ), +} diff --git a/packages/ui/src/components/resize-handle.stories.tsx b/packages/ui/src/components/resize-handle.stories.tsx new file mode 100644 index 00000000000..474cf71e2d1 --- /dev/null +++ b/packages/ui/src/components/resize-handle.stories.tsx @@ -0,0 +1,156 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import * as mod from "./resize-handle" + +const docs = `### Overview +Drag handle for resizing panels or split views. + +Use alongside resizable panels and split layouts. + +### API +- Required: \`direction\`, \`size\`, \`min\`, \`max\`, \`onResize\`. +- Optional: \`edge\`, \`onCollapse\`, \`collapseThreshold\`. + +### Variants and states +- Horizontal and vertical directions. + +### Behavior +- Drag updates size and calls \`onResize\` with clamped values. + +### Accessibility +- TODO: provide keyboard resizing guidance if needed. + +### Theming/tokens +- Uses \`data-component="resize-handle"\` with direction/edge data attributes. + +` + +export default { + title: "UI/ResizeHandle", + id: "components-resize-handle", + component: mod.ResizeHandle, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => { + const [size, setSize] = createSignal(240) + return ( +
+
Size: {size()}px
+
+ +
+ ) + }, +} + +export const Vertical = { + render: () => { + const [size, setSize] = createSignal(180) + return ( +
+
Size: {size()}px
+
+ +
+ ) + }, +} + +export const Collapse = { + render: () => { + const [size, setSize] = createSignal(200) + const [collapsed, setCollapsed] = createSignal(false) + return ( +
+
+ {collapsed() ? "Collapsed" : `Size: ${size()}px`} +
+
+ { + setCollapsed(false) + setSize(next) + }} + onCollapse={() => setCollapsed(true)} + style="height:24px;border:1px dashed color-mix(in oklab, var(--text-base) 20%, transparent)" + /> +
+ ) + }, +} + +export const EdgeStart = { + render: () => { + const [size, setSize] = createSignal(240) + return ( +
+
Size: {size()}px
+
+ +
+ ) + }, +} diff --git a/packages/ui/src/components/select.stories.tsx b/packages/ui/src/components/select.stories.tsx new file mode 100644 index 00000000000..1ee00ab851e --- /dev/null +++ b/packages/ui/src/components/select.stories.tsx @@ -0,0 +1,113 @@ +// @ts-nocheck +import * as mod from "./select" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Select menu for choosing a single option with optional grouping. + +Use \`children\` to customize option rendering. + +### API +- Required: \`options\`. +- Optional: \`current\`, \`placeholder\`, \`value\`, \`label\`, \`groupBy\`. +- Accepts Button props for the trigger (\`variant\`, \`size\`). + +### Variants and states +- Trigger supports "settings" style via \`triggerVariant\`. + +### Behavior +- Uses Kobalte Select with optional item highlight callbacks. + +### Accessibility +- TODO: confirm keyboard navigation and aria attributes from Kobalte. + +### Theming/tokens +- Uses \`data-component="select"\` with slot attributes. + +` + +const story = create({ + title: "UI/Select", + mod, + args: { + options: ["One", "Two", "Three"], + current: "One", + placeholder: "Choose...", + variant: "secondary", + size: "normal", + }, +}) + +export default { + title: "UI/Select", + id: "components-select", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + triggerVariant: { + control: "select", + options: ["settings", undefined], + }, + }, +} + +export const Basic = story.Basic + +export const Grouped = { + render: () => { + const options = [ + { id: "alpha", label: "Alpha", group: "Group A" }, + { id: "bravo", label: "Bravo", group: "Group A" }, + { id: "delta", label: "Delta", group: "Group B" }, + ] + return ( + item.id} + label={(item) => item.label} + groupBy={(item) => item.group} + placeholder="Choose..." + variant="secondary" + /> + ) + }, +} + +export const SettingsTrigger = { + args: { + triggerVariant: "settings", + }, +} + +export const CustomRender = { + render: () => ( + + {(item) => {item}} + + ), +} + +export const CustomTriggerStyle = { + args: { + triggerStyle: { "min-width": "180px", "justify-content": "space-between" }, + }, +} + +export const Disabled = { + args: { + disabled: true, + }, +} diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index b9a2180cb8d..fae181e20ce 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -10,6 +10,11 @@ display: none; } + .scroll-view__viewport { + display: flex; + flex-direction: column; + } + [data-slot="session-review-container"] { flex: 1 1 auto; padding-right: 4px; @@ -94,6 +99,7 @@ align-items: center; justify-content: space-between; width: 100%; + min-width: 0; gap: 20px; } @@ -110,9 +116,12 @@ align-items: center; flex-grow: 1; min-width: 0; + overflow: hidden; } [data-slot="session-review-directory"] { + flex: 1 1 auto; + min-width: 0; color: var(--text-base); text-overflow: ellipsis; overflow: hidden; @@ -124,6 +133,11 @@ [data-slot="session-review-filename"] { color: var(--text-strong); flex-shrink: 0; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } [data-slot="session-review-view-button"] { @@ -158,6 +172,7 @@ gap: 16px; align-items: center; justify-content: flex-end; + margin-left: auto; } [data-slot="session-review-diff-chevron"] { diff --git a/packages/ui/src/components/session-review.stories.tsx b/packages/ui/src/components/session-review.stories.tsx new file mode 100644 index 00000000000..7ab1eb20389 --- /dev/null +++ b/packages/ui/src/components/session-review.stories.tsx @@ -0,0 +1,7 @@ +// @ts-nocheck +import * as mod from "./session-review" +import { create } from "../storybook/scaffold" + +const story = create({ title: "UI/SessionReview", mod }) +export default { title: "UI/SessionReview", id: "components-session-review", component: story.meta.component } +export const Basic = story.Basic diff --git a/packages/ui/src/components/session-turn.stories.tsx b/packages/ui/src/components/session-turn.stories.tsx new file mode 100644 index 00000000000..927402c8db7 --- /dev/null +++ b/packages/ui/src/components/session-turn.stories.tsx @@ -0,0 +1,7 @@ +// @ts-nocheck +import * as mod from "./session-turn" +import { create } from "../storybook/scaffold" + +const story = create({ title: "UI/SessionTurn", mod }) +export default { title: "UI/SessionTurn", id: "components-session-turn", component: story.meta.component } +export const Basic = story.Basic diff --git a/packages/ui/src/components/spinner.stories.tsx b/packages/ui/src/components/spinner.stories.tsx new file mode 100644 index 00000000000..be6106d1486 --- /dev/null +++ b/packages/ui/src/components/spinner.stories.tsx @@ -0,0 +1,53 @@ +// @ts-nocheck +import * as mod from "./spinner" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Animated loading indicator for inline or page-level loading states. + +Use with \`Button\` or in empty states. + +### API +- Accepts standard SVG props (class, style). + +### Variants and states +- Single default animation style. + +### Behavior +- Animation is CSS-driven via data attributes. + +### Accessibility +- Use alongside text or aria-live regions to convey loading state. + +### Theming/tokens +- Uses \`data-component="spinner"\` for styling hooks. + +` + +const story = create({ title: "UI/Spinner", mod }) + +export default { + title: "UI/Spinner", + id: "components-spinner", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} diff --git a/packages/ui/src/components/sticky-accordion-header.stories.tsx b/packages/ui/src/components/sticky-accordion-header.stories.tsx new file mode 100644 index 00000000000..3f033562677 --- /dev/null +++ b/packages/ui/src/components/sticky-accordion-header.stories.tsx @@ -0,0 +1,54 @@ +// @ts-nocheck +import { Accordion } from "./accordion" +import * as mod from "./sticky-accordion-header" + +const docs = `### Overview +Sticky accordion header wrapper for persistent section labels. + +Use only inside \`Accordion.Item\` with \`Accordion.Trigger\`. + +### API +- Accepts standard header props and children. + +### Variants and states +- Inherits accordion states. + +### Behavior +- Renders inside an Accordion item header. + +### Accessibility +- TODO: confirm semantics from Accordion.Header usage. + +### Theming/tokens +- Uses \`data-component="sticky-accordion-header"\`. + +` + +export default { + title: "UI/StickyAccordionHeader", + id: "components-sticky-accordion-header", + component: mod.StickyAccordionHeader, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( + + + + Sticky header + + +
Accordion content.
+
+
+
+ ), +} diff --git a/packages/ui/src/components/switch.stories.tsx b/packages/ui/src/components/switch.stories.tsx new file mode 100644 index 00000000000..540e91e3654 --- /dev/null +++ b/packages/ui/src/components/switch.stories.tsx @@ -0,0 +1,68 @@ +// @ts-nocheck +import * as mod from "./switch" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Toggle control for binary settings. + +Use in settings panels or forms. + +### API +- Uses Kobalte Switch props (\`checked\`, \`defaultChecked\`, \`onChange\`). +- Optional: \`hideLabel\`, \`description\`. +- Children render as the label. + +### Variants and states +- Checked/unchecked, disabled states. + +### Behavior +- Controlled or uncontrolled usage via Kobalte props. + +### Accessibility +- TODO: confirm aria attributes from Kobalte. + +### Theming/tokens +- Uses \`data-component="switch"\` and slot attributes. + +` + +const story = create({ + title: "UI/Switch", + mod, + args: { defaultChecked: true, children: "Enable notifications" }, +}) + +export default { + title: "UI/Switch", + id: "components-switch", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const States = { + render: () => ( +
+ Enabled + Disabled + Disabled switch + With description +
+ ), +} + +export const HiddenLabel = { + args: { + children: "Hidden label", + hideLabel: true, + defaultChecked: true, + }, +} diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 03df9cd84ea..43b74cf33e1 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -1,4 +1,9 @@ [data-component="tabs"] { + --tabs-bar-height: 48px; + --tabs-compact-pill-height: 24px; + --tabs-compact-pill-radius: 6px; + --tabs-compact-pill-padding-x: 4px; + width: 100%; height: 100%; display: flex; @@ -93,17 +98,6 @@ outline: none; box-shadow: none; } - &:has([data-hidden]) { - [data-slot="tabs-trigger-close-button"] { - opacity: 0; - } - - &:hover { - [data-slot="tabs-trigger-close-button"] { - opacity: 1; - } - } - } &:has([data-selected]) { color: var(--text-strong); background-color: transparent; @@ -112,6 +106,7 @@ opacity: 1; } } + &:hover:not(:disabled):not([data-selected]) { color: var(--text-strong); } @@ -140,6 +135,118 @@ } } + #review-panel &[data-variant="normal"][data-orientation="horizontal"] { + background-color: var(--background-stronger); + + [data-slot="tabs-list"] { + height: var(--tabs-bar-height); + padding-left: 12px; + padding-right: 0; + --tabs-review-gap: 16px; + --tabs-review-fade: 16px; + gap: var(--tabs-review-gap); + background-color: var(--background-stronger); + border-bottom: 1px solid var(--border-weak-base); + + &::after { + display: none; + } + + > .sticky { + border-bottom: none; + background-color: var(--background-stronger); + + &::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--tabs-review-fade) * -1); + width: var(--tabs-review-fade); + pointer-events: none; + background: linear-gradient(90deg, transparent, var(--background-stronger)); + } + } + } + + [data-slot="tabs-trigger-wrapper"] { + height: var(--tabs-compact-pill-height); + margin-block: calc((var(--tabs-bar-height) - var(--tabs-compact-pill-height)) / 2); + max-width: 320px; + padding-inline: var(--tabs-compact-pill-padding-x); + box-sizing: border-box; + border: 1px solid transparent; + border-radius: var(--tabs-compact-pill-radius); + background-color: transparent; + gap: 8px; + color: var(--text-weak); + transition: + color 120ms ease, + background-color 120ms ease, + border-color 120ms ease; + + &::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: calc((var(--tabs-compact-pill-height) - var(--tabs-bar-height)) / 2); + height: 1px; + background-color: var(--text-strong); + opacity: 0; + transform: scaleX(0.75); + transform-origin: center; + transition: + opacity 120ms ease, + transform 120ms ease; + } + + &[data-value="review"] { + padding-left: 8px; + padding-right: 8px; + } + + [data-slot="tabs-trigger"] { + height: 100%; + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 5px; + [data-slot="tabs-trigger"] { + padding-right: 0 !important; + } + } + + &:has([data-selected]) { + color: var(--text-strong); + background-color: var(--surface-base-active); + border-color: var(--border-weak-base); + + &::after { + opacity: 1; + transform: scaleX(1); + } + } + + [data-component="file-icon"] { + filter: grayscale(1) !important; + transition: filter 120ms ease; + } + + &:has([data-selected]) { + [data-component="file-icon"] { + filter: grayscale(0) !important; + } + } + + &:hover:not(:disabled):not(:has([data-selected])) { + color: var(--text-base); + background-color: var(--surface-base-hover); + } + } + } + &[data-variant="alt"] { [data-slot="tabs-list"] { padding-left: 24px; @@ -282,9 +389,15 @@ } [data-slot="tabs-trigger-wrapper"] { - height: 24px; - border-radius: 6px; + height: var(--tabs-compact-pill-height); + border-radius: var(--tabs-compact-pill-radius); color: var(--text-weak); + box-sizing: border-box; + border: 1px solid transparent; + transition: + color 120ms ease, + background-color 120ms ease, + border-color 120ms ease; &:not(:has([data-selected])):hover:not(:disabled) { color: var(--text-base); @@ -292,6 +405,7 @@ &:has([data-selected]) { color: var(--text-strong); + border-color: var(--border-weak-base); } } } @@ -459,3 +573,41 @@ } } } + +[data-component="tabs-drag-preview"] { + position: relative; + display: flex; + align-items: center; + height: var(--tabs-bar-height, 48px); + max-width: 320px; + padding-inline: var(--tabs-compact-pill-padding-x, 4px); + overflow: hidden; + color: var(--text-strong); + opacity: 0.6; +} + +[data-component="tabs-drag-preview"]::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: calc((var(--tabs-bar-height, 48px) - var(--tabs-compact-pill-height, 24px)) / 2); + height: var(--tabs-compact-pill-height, 24px); + border: 1px solid var(--border-weak-base); + border-radius: var(--tabs-compact-pill-radius, 6px); + background-color: var(--surface-base-active); +} + +[data-component="tabs-drag-preview"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background-color: var(--text-strong); +} + +[data-component="tabs-drag-preview"] > * { + position: relative; +} diff --git a/packages/ui/src/components/tabs.stories.tsx b/packages/ui/src/components/tabs.stories.tsx new file mode 100644 index 00000000000..8e09764dcc6 --- /dev/null +++ b/packages/ui/src/components/tabs.stories.tsx @@ -0,0 +1,179 @@ +// @ts-nocheck +import { IconButton } from "./icon-button" +import { createSignal } from "solid-js" +import * as mod from "./tabs" + +const docs = `### Overview +Tabbed navigation for switching between related panels. + +Compose \`Tabs.List\` + \`Tabs.Trigger\` + \`Tabs.Content\`. + +### API +- Root accepts Kobalte Tabs props (\`value\`, \`defaultValue\`, \`onChange\`). +- \`variant\` sets visual style: normal, alt, pill, settings. +- \`orientation\` supports horizontal or vertical layouts. +- Trigger supports \`closeButton\`, \`hideCloseButton\`, and \`onMiddleClick\`. + +### Variants and states +- Normal, alt, pill, settings variants. +- Horizontal and vertical orientations. + +### Behavior +- Uses Kobalte Tabs for roving focus and selection management. + +### Accessibility +- TODO: confirm keyboard interactions from Kobalte Tabs. + +### Theming/tokens +- Uses \`data-component="tabs"\` with variant/orientation data attributes. + +` + +export default { + title: "UI/Tabs", + id: "components-tabs", + component: mod.Tabs, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + variant: { + control: "select", + options: ["normal", "alt", "pill", "settings"], + }, + orientation: { + control: "select", + options: ["horizontal", "vertical"], + }, + }, +} + +export const Basic = { + args: { + variant: "normal", + orientation: "horizontal", + defaultValue: "overview", + }, + render: (props) => ( + + + Overview + Details + Activity + + Overview content + Details content + Activity content + + ), +} + +export const Settings = { + args: { + variant: "settings", + orientation: "horizontal", + defaultValue: "general", + }, + render: (props) => ( + + + General + Appearance + + General settings + Appearance settings + + ), +} + +export const Alt = { + args: { + variant: "alt", + orientation: "horizontal", + defaultValue: "first", + }, + render: (props) => ( + + + First + Second + + Alt content + Alt content 2 + + ), +} + +export const Vertical = { + args: { + variant: "pill", + orientation: "vertical", + defaultValue: "alpha", + }, + render: (props) => ( + + + Alpha + Beta + + Alpha content + Beta content + + ), +} + +export const Closable = { + args: { + variant: "normal", + orientation: "horizontal", + defaultValue: "tab-1", + }, + render: (props) => ( + + + } + > + Tab 1 + + Tab 2 + + Closable content + Standard content + + ), +} + +export const MiddleClick = { + args: { + variant: "normal", + orientation: "horizontal", + defaultValue: "tab-1", + }, + render: (props) => { + const [message, setMessage] = createSignal("Middle click a tab") + return ( +
+
{message()}
+ + + setMessage("Middle clicked tab-1")}> + Tab 1 + + setMessage("Middle clicked tab-2")}> + Tab 2 + + + Tab 1 content + Tab 2 content + +
+ ) + }, +} diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 4836a0864c2..396504dd728 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -61,10 +61,16 @@ function TabsTrigger(props: ParentProps) { return (
{ + if (e.button === 1 && split.onMiddleClick) { + e.preventDefault() + } + }} onAuxClick={(e) => { if (e.button === 1 && split.onMiddleClick) { e.preventDefault() @@ -75,6 +81,7 @@ function TabsTrigger(props: ParentProps) { {split.children} diff --git a/packages/ui/src/components/tag.stories.tsx b/packages/ui/src/components/tag.stories.tsx new file mode 100644 index 00000000000..73ae880ba1c --- /dev/null +++ b/packages/ui/src/components/tag.stories.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import * as mod from "./tag" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Small label tag for metadata and status chips. + +Use alongside headings or lists for quick metadata. + +### API +- Optional: \`size\` (normal | large). +- Accepts standard span props. + +### Variants and states +- Size variants only. + +### Behavior +- Inline element; size controls padding and font size via CSS. + +### Accessibility +- Ensure text conveys meaning; avoid color-only distinction. + +### Theming/tokens +- Uses \`data-component="tag"\` with size data attributes. + +` + +const story = create({ title: "UI/Tag", mod, args: { children: "Tag" } }) +export default { + title: "UI/Tag", + id: "components-tag", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + size: { + control: "select", + options: ["normal", "large"], + }, + }, +} + +export const Basic = story.Basic + +export const Sizes = { + render: () => ( +
+ Normal + Large +
+ ), +} diff --git a/packages/ui/src/components/text-field.stories.tsx b/packages/ui/src/components/text-field.stories.tsx new file mode 100644 index 00000000000..73f9006607b --- /dev/null +++ b/packages/ui/src/components/text-field.stories.tsx @@ -0,0 +1,111 @@ +// @ts-nocheck +import * as mod from "./text-field" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Text input with label, description, and optional copy-to-clipboard action. + +Pair with \`Tooltip\` and \`IconButton\` for copy affordance (built in). + +### API +- Supports Kobalte TextField props: \`value\`, \`defaultValue\`, \`onChange\`, \`disabled\`, \`readOnly\`. +- Optional: \`label\`, \`description\`, \`error\`, \`variant\`, \`copyable\`, \`multiline\`. + +### Variants and states +- Normal and ghost variants. +- Supports multiline textarea. + +### Behavior +- When \`copyable\` is true, clicking copies the current value. + +### Accessibility +- Label is hidden when \`hideLabel\` is true (sr-only). + +### Theming/tokens +- Uses \`data-component="input"\` with slot attributes for styling. + +` + +const story = create({ + title: "UI/TextField", + mod, + args: { + label: "Label", + placeholder: "Type here...", + defaultValue: "Hello", + }, +}) + +export default { + title: "UI/TextField", + id: "components-text-field", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Variants = { + render: () => ( +
+ + +
+ ), +} + +export const Multiline = { + args: { + label: "Description", + multiline: true, + defaultValue: "Line one\nLine two", + }, +} + +export const Copyable = { + args: { + label: "Invite link", + defaultValue: "https://example.com/invite/abc", + copyable: true, + copyKind: "link", + }, +} + +export const Error = { + args: { + label: "Email", + defaultValue: "invalid@", + error: "Enter a valid email address", + }, +} + +export const Disabled = { + args: { + label: "Disabled", + defaultValue: "Readonly", + disabled: true, + }, +} + +export const ReadOnly = { + args: { + label: "Read only", + defaultValue: "Read only value", + readOnly: true, + }, +} + +export const HiddenLabel = { + args: { + label: "Hidden label", + hideLabel: true, + placeholder: "Hidden label", + }, +} diff --git a/packages/ui/src/components/text-shimmer.stories.tsx b/packages/ui/src/components/text-shimmer.stories.tsx new file mode 100644 index 00000000000..4b6de34c2e9 --- /dev/null +++ b/packages/ui/src/components/text-shimmer.stories.tsx @@ -0,0 +1,59 @@ +// @ts-nocheck +import * as mod from "./text-shimmer" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Animated shimmer effect for loading text placeholders. + +Use for pending states inside buttons or list rows. + +### API +- Required: \`text\` string. +- Optional: \`as\`, \`active\`, \`stepMs\`, \`durationMs\`. + +### Variants and states +- Active/inactive state via \`active\`. + +### Behavior +- Characters animate with staggered delays. + +### Accessibility +- Uses \`aria-label\` with the full text. + +### Theming/tokens +- Uses \`data-component="text-shimmer"\` and CSS custom properties for timing. + +` + +const story = create({ title: "UI/TextShimmer", mod, args: { text: "Loading..." } }) + +export default { + title: "UI/TextShimmer", + id: "components-text-shimmer", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Inactive = { + args: { + text: "Static text", + active: false, + }, +} + +export const CustomTiming = { + args: { + text: "Custom timing", + stepMs: 80, + durationMs: 1800, + }, +} diff --git a/packages/ui/src/components/toast.stories.tsx b/packages/ui/src/components/toast.stories.tsx new file mode 100644 index 00000000000..ef9cbb68ef2 --- /dev/null +++ b/packages/ui/src/components/toast.stories.tsx @@ -0,0 +1,138 @@ +// @ts-nocheck +import * as mod from "./toast" +import { Button } from "./button" + +const docs = `### Overview +Toast notifications with optional icons, actions, and progress. + +Use brief titles/descriptions; limit actions to 1-2. + +### API +- Use \`showToast\` or \`showPromiseToast\` to trigger toasts. +- Render \`Toast.Region\` once per page. +- \`Toast\` subcomponents compose the structure. + +### Variants and states +- Variants: default, success, error, loading. +- Optional actions and persistent toasts. + +### Behavior +- Toasts render in a portal and auto-dismiss unless persistent. + +### Accessibility +- TODO: confirm aria-live behavior from Kobalte Toast. + +### Theming/tokens +- Uses \`data-component="toast"\` and slot data attributes. + +` + +export default { + title: "UI/Toast", + id: "components-toast", + component: mod.Toast, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( +
+ + + +
+ ), +} + +export const Actions = { + render: () => ( +
+ + +
+ ), +} + +export const Promise = { + render: () => ( +
+ + +
+ ), +} + +export const Loading = { + render: () => ( +
+ + +
+ ), +} diff --git a/packages/ui/src/components/tooltip.stories.tsx b/packages/ui/src/components/tooltip.stories.tsx new file mode 100644 index 00000000000..efe11d92efb --- /dev/null +++ b/packages/ui/src/components/tooltip.stories.tsx @@ -0,0 +1,64 @@ +// @ts-nocheck +import * as mod from "./tooltip" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Tooltip for contextual hints and keybind callouts. + +Use for short hints; avoid long descriptions. + +### API +- Required: \`value\` (tooltip content). +- Optional: \`inactive\`, \`forceOpen\`, placement props from Kobalte. + +### Variants and states +- Supports keybind-style tooltip via \`TooltipKeybind\`. + +### Behavior +- Opens on hover/focus; can be forced open. + +### Accessibility +- TODO: confirm trigger semantics and focus behavior. + +### Theming/tokens +- Uses \`data-component="tooltip"\` and related slots. + +` + +const story = create({ title: "UI/Tooltip", mod, args: { value: "Tooltip", children: "Hover me" } }) + +export default { + title: "UI/Tooltip", + id: "components-tooltip", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Keybind = { + render: () => ( + + Hover for keybind + + ), +} + +export const ForcedOpen = { + args: { + forceOpen: true, + }, +} + +export const Inactive = { + args: { + inactive: true, + }, +} diff --git a/packages/ui/src/components/typewriter.stories.tsx b/packages/ui/src/components/typewriter.stories.tsx new file mode 100644 index 00000000000..880ca748943 --- /dev/null +++ b/packages/ui/src/components/typewriter.stories.tsx @@ -0,0 +1,51 @@ +// @ts-nocheck +import * as mod from "./typewriter" +import { create } from "../storybook/scaffold" + +const docs = `### Overview +Animated typewriter text effect for short inline messages. + +Use for short status lines; avoid long paragraphs. + +### API +- Optional: \`text\` string; if absent, nothing is rendered. +- Optional: \`as\` to change the rendered element. + +### Variants and states +- Single animation style with cursor blink. + +### Behavior +- Types one character at a time with randomized delays. + +### Accessibility +- TODO: confirm if cursor should be aria-hidden in all contexts. + +### Theming/tokens +- Uses \`blinking-cursor\` class for cursor styling. + +` + +const story = create({ title: "UI/Typewriter", mod, args: { text: "Typewriter text" } }) + +export default { + title: "UI/Typewriter", + id: "components-typewriter", + component: story.meta.component, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = story.Basic + +export const Inline = { + args: { + text: "Inline typewriter", + as: "span", + }, +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 2c44763f536..e116199eb23 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,14 +1,4 @@ -import type { - Message, - Session, - Part, - FileDiff, - SessionStatus, - PermissionRequest, - QuestionRequest, - QuestionAnswer, - ProviderListResponse, -} from "@opencode-ai/sdk/v2" +import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -24,12 +14,6 @@ type Data = { session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] } - permission?: { - [sessionID: string]: PermissionRequest[] - } - question?: { - [sessionID: string]: QuestionRequest[] - } message: { [sessionID: string]: Message[] } @@ -38,16 +22,6 @@ type Data = { } } -export type PermissionRespondFn = (input: { - sessionID: string - permissionID: string - response: "once" | "always" | "reject" -}) => void - -export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void - -export type QuestionRejectFn = (input: { requestID: string }) => void - export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string @@ -57,9 +31,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ init: (props: { data: Data directory: string - onPermissionRespond?: PermissionRespondFn - onQuestionReply?: QuestionReplyFn - onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn }) => { @@ -70,9 +41,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, - respondToPermission: props.onPermissionRespond, - replyToQuestion: props.onQuestionReply, - rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, } diff --git a/packages/ui/src/storybook/fixtures.ts b/packages/ui/src/storybook/fixtures.ts new file mode 100644 index 00000000000..59d4129709a --- /dev/null +++ b/packages/ui/src/storybook/fixtures.ts @@ -0,0 +1,51 @@ +export const diff = { + before: { + name: "src/greet.ts", + contents: `export function greet(name: string) { + return \`Hello, \${name}!\` +} +`, + }, + after: { + name: "src/greet.ts", + contents: `export function greet(name: string, excited = false) { + const message = \`Hello, \${name}!\` + return excited ? \`\${message}!!\` : message +} +`, + }, +} + +export const code = { + name: "src/calc.ts", + contents: `export function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +export function average(values: number[]) { + if (values.length === 0) return 0 + return sum(values) / values.length +} +`, +} + +export const markdown = [ + "# Markdown", + "", + "Use **Markdown** for rich text.", + "", + "## Highlights", + "- Headings, lists, and code blocks", + "- Inline `code` and links", + "", + "```ts", + "export const value = 42", + "```", + "", + "More at https://example.com/docs", +].join("\n") + +export const changes = { + additions: 18, + deletions: 6, +} diff --git a/packages/ui/src/storybook/scaffold.tsx b/packages/ui/src/storybook/scaffold.tsx new file mode 100644 index 00000000000..2512aa09be5 --- /dev/null +++ b/packages/ui/src/storybook/scaffold.tsx @@ -0,0 +1,62 @@ +import { ErrorBoundary, type ValidComponent } from "solid-js" +import { Dynamic } from "solid-js/web" + +function fn(value: unknown): value is (...args: never[]) => unknown { + return typeof value === "function" +} + +function pick(mod: Record, name?: string) { + if (name && fn(mod[name])) return mod[name] + if (fn(mod.default)) return mod.default + + const preferred = Object.keys(mod) + .filter((k) => k[0] && k[0] === k[0].toUpperCase()) + .find((k) => fn(mod[k])) + if (preferred) return mod[preferred] + + const first = Object.keys(mod).find((k) => fn(mod[k])) + if (first) return mod[first] + + return () => { + return ( +
+
Missing component export.
+
Exports: {Object.keys(mod).join(", ") || "(none)"}
+
+ ) + } +} + +export function create(input: { + title: string + mod: Record + name?: string + args?: Record +}) { + const component = pick(input.mod, input.name) as unknown as ValidComponent + + return { + meta: { + title: input.title, + component, + }, + Basic: { + args: input.args ?? {}, + render: (args: Record) => { + return ( + { + return ( +
+                  {String(err)}
+                
+ ) + }} + > + +
+ ) + }, + }, + } +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 832f7cbf816..25f6a3dcc45 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -17,5 +17,6 @@ // Type Checking & Safety "strict": true, "types": ["vite/client", "bun"] - } + }, + "exclude": ["**/*.stories.*", "**/*.mdx"] } diff --git a/packages/util/package.json b/packages/util/package.json index 4bcbb0305d4..36a235639ee 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.2.10", + "version": "1.2.15", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index b14a7ccb8f8..612d4fb8cdd 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -314,7 +314,7 @@ function configSchema() { hooks: { "astro:build:done": async () => { console.log("generating config schema") - spawnSync("../opencode/script/schema.ts", ["./dist/config.json"]) + spawnSync("../opencode/script/schema.ts", ["./dist/config.json", "./dist/tui.json"]) }, }, } diff --git a/packages/web/package.json b/packages/web/package.json index 110c6ca2354..daf2ad3480f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.2.10", + "version": "1.2.15", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/share.mdx b/packages/web/src/content/docs/ar/share.mdx index 535d44dadf8..6d13410458c 100644 --- a/packages/web/src/content/docs/ar/share.mdx +++ b/packages/web/src/content/docs/ar/share.mdx @@ -41,7 +41,7 @@ description: شارك محادثات OpenCode الخاصة بك. ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ description: شارك محادثات OpenCode الخاصة بك. ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ description: شارك محادثات OpenCode الخاصة بك. ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/bs/share.mdx b/packages/web/src/content/docs/bs/share.mdx index a15e1507434..b0760ee0c13 100644 --- a/packages/web/src/content/docs/bs/share.mdx +++ b/packages/web/src/content/docs/bs/share.mdx @@ -41,7 +41,7 @@ Da eksplicitno postavite rucni nacin u [config datoteci](/docs/config): ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Mozete ukljuciti automatsko dijeljenje za sve nove razgovore tako sto `share` po ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Dijeljenje mozete potpuno iskljuciti tako sto `share` postavite na `"disabled"` ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index c504f734fa5..6b1c3dee57e 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -558,6 +558,7 @@ OpenCode can be configured using environment variables. | `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions | | `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows | | `OPENCODE_CONFIG` | string | Path to config file | +| `OPENCODE_TUI_CONFIG` | string | Path to TUI config file | | `OPENCODE_CONFIG_DIR` | string | Path to config directory | | `OPENCODE_CONFIG_CONTENT` | string | Inline json config content | | `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks | diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index eeccde2f791..038f253274e 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -14,10 +14,11 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats. ```jsonc title="opencode.jsonc" { "$schema": "https://opencode.ai/config.json", - // Theme configuration - "theme": "opencode", "model": "anthropic/claude-sonnet-4-5", "autoupdate": true, + "server": { + "port": 4096, + }, } ``` @@ -34,7 +35,7 @@ Configuration files are **merged together**, not replaced. Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved. -For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings. +For example, if your global config sets `autoupdate: true` and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include both settings. --- @@ -95,7 +96,9 @@ You can enable specific servers in your local config: ### Global -Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds. +Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide server/runtime preferences like providers, models, and permissions. + +For TUI-specific settings, use `~/.config/opencode/tui.json`. Global config overrides remote organizational defaults. @@ -105,6 +108,8 @@ Global config overrides remote organizational defaults. Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs. +For project-specific TUI settings, add `tui.json` alongside it. + :::tip Place project specific config in the root of your project. ::: @@ -146,7 +151,9 @@ The custom directory is loaded after the global config and `.opencode` directori ## Schema -The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json). +The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json). + +TUI config uses [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json). Your editor should be able to validate and autocomplete based on the schema. @@ -154,28 +161,24 @@ Your editor should be able to validate and autocomplete based on the schema. ### TUI -You can configure TUI-specific settings through the `tui` option. +Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", - "tui": { - "scroll_speed": 3, - "scroll_acceleration": { - "enabled": true - }, - "diff_style": "auto" - } + "$schema": "https://opencode.ai/tui.json", + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": true + }, + "diff_style": "auto" } ``` -Available options: +Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file. -- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.** -- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`. -- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column. +Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible. -[Learn more about using the TUI here](/docs/tui). +[Learn more about TUI configuration here](/docs/tui#configure). --- @@ -301,12 +304,12 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr ### Themes -You can configure the theme you want to use in your OpenCode config through the `theme` option. +Set your UI theme in `tui.json`. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", - "theme": "" + "$schema": "https://opencode.ai/tui.json", + "theme": "tokyonight" } ``` @@ -406,11 +409,11 @@ You can also define commands using markdown files in `~/.config/opencode/command ### Keybinds -You can customize your keybinds through the `keybinds` option. +Customize keybinds in `tui.json`. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "keybinds": {} } ``` diff --git a/packages/web/src/content/docs/da/share.mdx b/packages/web/src/content/docs/da/share.mdx index 1ac2094ca70..80b9f0959e2 100644 --- a/packages/web/src/content/docs/da/share.mdx +++ b/packages/web/src/content/docs/da/share.mdx @@ -41,7 +41,7 @@ For at eksplisitt angi manuell modus i [konfigurasjonsfilen](/docs/config): ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Du kan aktivere automatisk deling for alle nye samtaler ved at sette alternative ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Du kan deaktivere deling helt ved at sette alternativet `share` til `"disabled"` ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/de/share.mdx b/packages/web/src/content/docs/de/share.mdx index 99fad21099b..9b7e9284c7a 100644 --- a/packages/web/src/content/docs/de/share.mdx +++ b/packages/web/src/content/docs/de/share.mdx @@ -43,7 +43,7 @@ Um den manuellen Modus explizit in der [Konfiguration](/docs/config) zu setzen: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -56,7 +56,7 @@ Du kannst automatisches Teilen fuer neue Unterhaltungen aktivieren, indem du in ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -71,7 +71,7 @@ Du kannst Teilen komplett deaktivieren, indem du in der [Konfiguration](/docs/co ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/es/share.mdx b/packages/web/src/content/docs/es/share.mdx index e1c62a031c0..3bb376f3862 100644 --- a/packages/web/src/content/docs/es/share.mdx +++ b/packages/web/src/content/docs/es/share.mdx @@ -41,7 +41,7 @@ Para configurar explícitamente el modo manual en su [archivo de configuración] ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Puede habilitar el uso compartido automático para todas las conversaciones nuev ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Puede desactivar el uso compartido por completo configurando la opción `share` ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/fr/share.mdx b/packages/web/src/content/docs/fr/share.mdx index acc7c03f83e..e6b067a8c82 100644 --- a/packages/web/src/content/docs/fr/share.mdx +++ b/packages/web/src/content/docs/fr/share.mdx @@ -41,7 +41,7 @@ Pour définir explicitement le mode manuel dans votre [fichier de configuration] ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Vous pouvez activer le partage automatique pour toutes les nouvelles conversatio ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Vous pouvez désactiver entièrement le partage en définissant l'option `share` ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/it/share.mdx b/packages/web/src/content/docs/it/share.mdx index f9eff6ca9bd..9b410a6b865 100644 --- a/packages/web/src/content/docs/it/share.mdx +++ b/packages/web/src/content/docs/it/share.mdx @@ -41,7 +41,7 @@ Per impostare esplicitamente la modalita manuale nel tuo [file di config](/docs/ ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Puoi abilitare la condivisione automatica per tutte le nuove conversazioni impos ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Puoi disabilitare completamente la condivisione impostando l'opzione `share` su ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/ja/share.mdx b/packages/web/src/content/docs/ja/share.mdx index 7995ba9a075..606e807dc8a 100644 --- a/packages/web/src/content/docs/ja/share.mdx +++ b/packages/web/src/content/docs/ja/share.mdx @@ -41,7 +41,7 @@ OpenCode は、会話の共有方法を制御する 3 つの共有モードを ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ OpenCode は、会話の共有方法を制御する 3 つの共有モードを ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ OpenCode は、会話の共有方法を制御する 3 つの共有モードを ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 25fe2a1d910..95b3d496391 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -3,11 +3,11 @@ title: Keybinds description: Customize your keybinds. --- -OpenCode has a list of keybinds that you can customize through the OpenCode config. +OpenCode has a list of keybinds that you can customize through `tui.json`. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "keybinds": { "leader": "ctrl+x", "app_exit": "ctrl+c,ctrl+d,q", @@ -117,11 +117,11 @@ You don't need to use a leader key for your keybinds but we recommend doing so. ## Disable keybind -You can disable a keybind by adding the key to your config with a value of "none". +You can disable a keybind by adding the key to `tui.json` with a value of "none". -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "keybinds": { "session_compact": "none" } diff --git a/packages/web/src/content/docs/ko/share.mdx b/packages/web/src/content/docs/ko/share.mdx index 55cf6a2c3e3..9e5c6388243 100644 --- a/packages/web/src/content/docs/ko/share.mdx +++ b/packages/web/src/content/docs/ko/share.mdx @@ -41,7 +41,7 @@ opencode는 대화가 공유되는 방법을 제어하는 세 가지 공유 모 ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ opencode는 대화가 공유되는 방법을 제어하는 세 가지 공유 모 ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ opencode는 대화가 공유되는 방법을 제어하는 세 가지 공유 모 ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/nb/share.mdx b/packages/web/src/content/docs/nb/share.mdx index 370477d1cfc..ca0cb4829ac 100644 --- a/packages/web/src/content/docs/nb/share.mdx +++ b/packages/web/src/content/docs/nb/share.mdx @@ -41,7 +41,7 @@ For å eksplisitt angi manuell modus i [konfigurasjonsfilen](/docs/config): ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Du kan aktivere automatisk deling for alle nye samtaler ved å sette alternative ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Du kan deaktivere deling helt ved å sette alternativet `share` til `"disabled"` ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/pl/share.mdx b/packages/web/src/content/docs/pl/share.mdx index 463019295a3..0389267b59a 100644 --- a/packages/web/src/content/docs/pl/share.mdx +++ b/packages/web/src/content/docs/pl/share.mdx @@ -41,7 +41,7 @@ Aby jawnie ustawić tryb ręczny w [pliku konfiguracyjnym] (./config): ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Możesz włączyć automatyczne udostępnianie dla wszystkich nowych rozmów, us ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Możesz całkowicie wyłączyć udostępnianie, ustawiając opcję `share` na `" ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index db3bfeaeebe..34e3626499c 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -57,7 +57,39 @@ tested and verified to work well with OpenCode. [Learn more](/docs/zen). If you are new, we recommend starting with OpenCode Zen. ::: -1. Run the `/connect` command in the TUI, select opencode, and head to [opencode.ai/auth](https://opencode.ai/auth). +1. Run the `/connect` command in the TUI, select `OpenCode Zen`, and head to [opencode.ai/auth](https://opencode.ai/zen). + + ```txt + /connect + ``` + +2. Sign in, add your billing details, and copy your API key. + +3. Paste your API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run `/models` in the TUI to see the list of models we recommend. + + ```txt + /models + ``` + +It works like any other provider in OpenCode and is completely optional to use. + +--- + +## OpenCode Go + +OpenCode Go is a low cost subscription plan that provides reliable access to popular open coding models provided by the OpenCode team that have been +tested and verified to work well with OpenCode. + +1. Run the `/connect` command in the TUI, select `OpenCode Go`, and head to [opencode.ai/auth](https://opencode.ai/zen). ```txt /connect diff --git a/packages/web/src/content/docs/pt-br/share.mdx b/packages/web/src/content/docs/pt-br/share.mdx index 5aa0439d068..166226d6dc2 100644 --- a/packages/web/src/content/docs/pt-br/share.mdx +++ b/packages/web/src/content/docs/pt-br/share.mdx @@ -41,7 +41,7 @@ Para definir explicitamente o modo manual em seu [arquivo de configuração](/do ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Você pode habilitar o compartilhamento automático para todas as novas conversa ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Você pode desativar o compartilhamento completamente definindo a opção `share ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/ru/share.mdx b/packages/web/src/content/docs/ru/share.mdx index c4df3b6a703..8982afb08df 100644 --- a/packages/web/src/content/docs/ru/share.mdx +++ b/packages/web/src/content/docs/ru/share.mdx @@ -41,7 +41,7 @@ opencode поддерживает три режима общего доступ ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ opencode поддерживает три режима общего доступ ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ opencode поддерживает три режима общего доступ ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/share.mdx b/packages/web/src/content/docs/share.mdx index 475ee08d041..b2c79334097 100644 --- a/packages/web/src/content/docs/share.mdx +++ b/packages/web/src/content/docs/share.mdx @@ -41,7 +41,7 @@ To explicitly set manual mode in your [config file](/docs/config): ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ You can enable automatic sharing for all new conversations by setting the `share ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ You can disable sharing entirely by setting the `share` option to `"disabled"` i ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/th/share.mdx b/packages/web/src/content/docs/th/share.mdx index 195d7696f9b..91bfce44172 100644 --- a/packages/web/src/content/docs/th/share.mdx +++ b/packages/web/src/content/docs/th/share.mdx @@ -41,7 +41,7 @@ OpenCode รองรับโหมดการแชร์สามโหม ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ OpenCode รองรับโหมดการแชร์สามโหม ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ OpenCode รองรับโหมดการแชร์สามโหม ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/themes.mdx b/packages/web/src/content/docs/themes.mdx index d37ce313556..8a7c6a46ac8 100644 --- a/packages/web/src/content/docs/themes.mdx +++ b/packages/web/src/content/docs/themes.mdx @@ -61,11 +61,11 @@ The system theme is for users who: ## Using a theme -You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in your [config](/docs/config). +You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in `tui.json`. -```json title="opencode.json" {3} +```json title="tui.json" {3} { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "theme": "tokyonight" } ``` diff --git a/packages/web/src/content/docs/tr/share.mdx b/packages/web/src/content/docs/tr/share.mdx index 1b7abfdb7ea..a0544eb02aa 100644 --- a/packages/web/src/content/docs/tr/share.mdx +++ b/packages/web/src/content/docs/tr/share.mdx @@ -41,7 +41,7 @@ Manuel modu acikca ayarlamak icin [config dosyaniza](/docs/config) sunu ekleyin: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ Tum yeni konusmalar icin otomatik paylasimi acmak isterseniz, [config dosyanizda ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ Paylasimi tamamen kapatmak icin [config dosyanizda](/docs/config) `share` degeri ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 1e48d42ccb1..010e8328f41 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -355,24 +355,34 @@ Some editors need command-line arguments to run in blocking mode. The `--wait` f ## Configure -You can customize TUI behavior through your OpenCode config file. +You can customize TUI behavior through `tui.json` (or `tui.jsonc`). -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", - "tui": { - "scroll_speed": 3, - "scroll_acceleration": { - "enabled": true - } - } + "$schema": "https://opencode.ai/tui.json", + "theme": "opencode", + "keybinds": { + "leader": "ctrl+x" + }, + "scroll_speed": 3, + "scroll_acceleration": { + "enabled": true + }, + "diff_style": "auto" } ``` +This is separate from `opencode.json`, which configures server/runtime behavior. + ### Options -- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** -- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** +- `theme` - Sets your UI theme. [Learn more](/docs/themes). +- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** +- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** +- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. + +Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. --- diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 453093206b9..48c040cf2df 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -64,6 +64,7 @@ You can also access our models through the following API endpoints. | Model | Model ID | Endpoint | AI SDK Package | | ------------------ | ------------------ | -------------------------------------------------- | --------------------------- | +| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | @@ -88,11 +89,9 @@ You can also access our models through the following API endpoints. | MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| GLM 5 Free | glm-5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Kimi K2.5 Free | kimi-k2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -124,11 +123,9 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | MiniMax M2.5 Free | Free | Free | Free | - | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - | | MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - | -| GLM 5 Free | Free | Free | Free | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 4.7 | $0.60 | $2.20 | $0.10 | - | | GLM 4.6 | $0.60 | $2.20 | $0.10 | - | -| Kimi K2.5 Free | Free | Free | Free | - | | Kimi K2.5 | $0.60 | $3.00 | $0.08 | - | | Kimi K2 Thinking | $0.40 | $2.50 | - | - | | Kimi K2 | $0.40 | $2.50 | - | - | @@ -150,6 +147,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | | Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | | Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | +| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - | | GPT 5.2 | $1.75 | $14.00 | $0.175 | - | | GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - | | GPT 5.1 | $1.07 | $8.50 | $0.107 | - | @@ -168,8 +166,6 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don The free models: -- GLM 5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Kimi K2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. @@ -201,8 +197,6 @@ charging you more than $20 if your balance goes below $5. All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions: - Big Pickle: During its free period, collected data may be used to improve the model. -- GLM 5 Free: During its free period, collected data may be used to improve the model. -- Kimi K2.5 Free: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/zh-cn/custom-tools.mdx b/packages/web/src/content/docs/zh-cn/custom-tools.mdx index 81a90a2bcb5..8b44a0450c2 100644 --- a/packages/web/src/content/docs/zh-cn/custom-tools.mdx +++ b/packages/web/src/content/docs/zh-cn/custom-tools.mdx @@ -79,6 +79,32 @@ export const multiply = tool({ --- +#### 与内置工具的名称冲突 + +自定义工具通过工具名称进行索引。如果自定义工具使用了与内置工具相同的名称,则优先使用自定义工具。 + +例如,这个文件取代了内置的bash工具: + +```ts title=".opencode/tools/bash.ts" +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "Restricted bash wrapper", + args: { + command: tool.schema.string(), + }, + async execute(args) { + return `blocked: ${args.command}` + }, +}) +``` + +:::note +除非你有意替换内置工具,否则最好用独特的名字。如果你想禁用内置工具但不想覆盖它,使用 [权限](/docs/permissions). +::: + +--- + ### 参数 你可以使用 `tool.schema`(即 [Zod](https://zod.dev))来定义参数类型。 diff --git a/packages/web/src/content/docs/zh-cn/lsp.mdx b/packages/web/src/content/docs/zh-cn/lsp.mdx index 57b81219021..59dd7082a1e 100644 --- a/packages/web/src/content/docs/zh-cn/lsp.mdx +++ b/packages/web/src/content/docs/zh-cn/lsp.mdx @@ -27,6 +27,7 @@ OpenCode 内置了多种适用于主流语言的 LSP 服务器: | gopls | .go | 需要 `go` 命令可用 | | hls | .hs, .lhs | 需要 `haskell-language-server-wrapper` 命令可用 | | jdtls | .java | 需要已安装 `Java SDK (version 21+)` | +| julials | .jl | 需要安装 `julia` and `LanguageServer.jl` | | kotlin-ls | .kt, .kts | 为 Kotlin 项目自动安装 | | lua-ls | .lua | 为 Lua 项目自动安装 | | nixd | .nix | 需要 `nixd` 命令可用 | diff --git a/packages/web/src/content/docs/zh-cn/plugins.mdx b/packages/web/src/content/docs/zh-cn/plugins.mdx index 0df6d1ee659..e8a8bd70cbc 100644 --- a/packages/web/src/content/docs/zh-cn/plugins.mdx +++ b/packages/web/src/content/docs/zh-cn/plugins.mdx @@ -307,6 +307,10 @@ export const CustomToolsPlugin: Plugin = async (ctx) => { 你的自定义工具将与内置工具一起在 OpenCode 中可用。 +:::note +如果插件工具与内置工具使用相同的名称,则优先使用插件工具。 +::: + --- ### 日志记录 diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index ccc2bf7d406..9c1616876d7 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -131,6 +131,8 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过 2. 使用以下方法之一**配置身份验证**: + *** + #### 环境变量(快速上手) 运行 opencode 时设置以下环境变量之一: @@ -153,6 +155,8 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过 export AWS_REGION=us-east-1 ``` + *** + #### 配置文件(推荐) 如需项目级别或持久化的配置,请使用 `opencode.json`: @@ -180,6 +184,8 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过 配置文件中的选项优先级高于环境变量。 ::: + *** + #### 进阶:VPC 端点 如果你使用 Bedrock 的 VPC 端点: @@ -203,12 +209,16 @@ OpenCode Zen 是由 OpenCode 团队提供的模型列表,这些模型已经过 `endpoint` 选项是通用 `baseURL` 选项的别名,使用了 AWS 特有的术语。如果同时指定了 `endpoint` 和 `baseURL`,则 `endpoint` 优先。 ::: + *** + #### 认证方式 - **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**:在 AWS 控制台中创建 IAM 用户并生成访问密钥 - **`AWS_PROFILE`**:使用 `~/.aws/credentials` 中的命名配置文件。需要先通过 `aws configure --profile my-profile` 或 `aws sso login` 进行配置 - **`AWS_BEARER_TOKEN_BEDROCK`**:从 Amazon Bedrock 控制台生成长期 API 密钥 - **`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`**:适用于 EKS IRSA(服务账户的 IAM 角色)或其他支持 OIDC 联合的 Kubernetes 环境。使用服务账户注解时,Kubernetes 会自动注入这些环境变量。 + *** + #### 认证优先级 Amazon Bedrock 使用以下认证优先级: diff --git a/packages/web/src/content/docs/zh-cn/share.mdx b/packages/web/src/content/docs/zh-cn/share.mdx index 8a7be16dc91..a2b34688e4d 100644 --- a/packages/web/src/content/docs/zh-cn/share.mdx +++ b/packages/web/src/content/docs/zh-cn/share.mdx @@ -41,7 +41,7 @@ OpenCode 支持三种分享模式,用于控制对话的共享方式: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ OpenCode 支持三种分享模式,用于控制对话的共享方式: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ OpenCode 支持三种分享模式,用于控制对话的共享方式: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/packages/web/src/content/docs/zh-cn/tui.mdx b/packages/web/src/content/docs/zh-cn/tui.mdx index e34c088cb3a..df8ce38fecc 100644 --- a/packages/web/src/content/docs/zh-cn/tui.mdx +++ b/packages/web/src/content/docs/zh-cn/tui.mdx @@ -234,7 +234,7 @@ How is auth handled in @packages/functions/src/api/index.ts? 列出可用主题。 ```bash frame="none" -/theme +/themes ``` **快捷键:** `ctrl+x t` diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 39358c41700..e3fe35e8672 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -64,19 +64,22 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | +| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | -| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` | | Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` | | Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| GLM 5 Free | glm-5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -104,42 +107,47 @@ https://opencode.ai/zen/v1/models 我们支持按量付费模式。以下是**每 100 万 Token** 的价格。 -| 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 | -| -------------------------------- | ------ | ------ | -------- | -------- | -| Big Pickle | 免费 | 免费 | 免费 | - | -| MiniMax M2.5 Free | 免费 | 免费 | 免费 | - | -| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - | -| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - | -| GLM 5 | $1.00 | $3.20 | $0.20 | - | -| GLM 4.7 | $0.60 | $2.20 | $0.10 | - | -| GLM 4.6 | $0.60 | $2.20 | $0.10 | - | -| Kimi K2.5 Free | 免费 | 免费 | 免费 | - | -| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - | -| Kimi K2 Thinking | $0.40 | $2.50 | - | - | -| Kimi K2 | $0.40 | $2.50 | - | - | -| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | -| Claude Sonnet 4.5 (≤ 200K Token) | $3.00 | $15.00 | $0.30 | $3.75 | -| Claude Sonnet 4.5 (> 200K Token) | $6.00 | $22.50 | $0.60 | $7.50 | -| Claude Sonnet 4 (≤ 200K Token) | $3.00 | $15.00 | $0.30 | $3.75 | -| Claude Sonnet 4 (> 200K Token) | $6.00 | $22.50 | $0.60 | $7.50 | -| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 | -| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 | -| Claude Opus 4.6 (≤ 200K Token) | $5.00 | $25.00 | $0.50 | $6.25 | -| Claude Opus 4.6 (> 200K Token) | $10.00 | $37.50 | $1.00 | $12.50 | -| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | -| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | -| Gemini 3 Pro (≤ 200K Token) | $2.00 | $12.00 | $0.20 | - | -| Gemini 3 Pro (> 200K Token) | $4.00 | $18.00 | $0.40 | - | -| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | -| GPT 5.2 | $1.75 | $14.00 | $0.175 | - | -| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - | -| GPT 5.1 | $1.07 | $8.50 | $0.107 | - | -| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - | -| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | -| GPT 5 | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | 免费 | 免费 | 免费 | - | +| 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 | +| --------------------------------- | ------ | ------ | -------- | -------- | +| Big Pickle | 免费 | 免费 | 免费 | - | +| MiniMax M2.5 Free | 免费 | 免费 | 免费 | - | +| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - | +| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - | +| GLM 5 Free | Free | Free | Free | - | +| GLM 5 | $1.00 | $3.20 | $0.20 | - | +| GLM 4.7 | $0.60 | $2.20 | $0.10 | - | +| GLM 4.6 | $0.60 | $2.20 | $0.10 | - | +| Kimi K2.5 Free | 免费 | 免费 | 免费 | - | +| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - | +| Kimi K2 Thinking | $0.40 | $2.50 | - | - | +| Kimi K2 | $0.40 | $2.50 | - | - | +| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | +| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 | +| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | +| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | +| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | +| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | +| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 | +| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 | +| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 | +| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 | +| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | +| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | +| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - | +| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - | +| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - | +| GPT 5.2 | $1.75 | $14.00 | $0.175 | - | +| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - | +| GPT 5.1 | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - | +| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | +| GPT 5 | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | +| GPT 5 Nano | 免费 | 免费 | 免费 | - | 你可能会在使用记录中看到 _Claude Haiku 3.5_。这是一个[低成本模型](/docs/config/#models),用于生成会话标题。 @@ -149,6 +157,7 @@ https://opencode.ai/zen/v1/models 免费模型说明: +- GLM 5 Free 在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Kimi K2.5 Free 在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - MiniMax M2.5 Free 在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Big Pickle 是一个隐身模型,在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -178,6 +187,7 @@ https://opencode.ai/zen/v1/models 我们所有的模型都托管在美国。我们的提供商遵循零保留政策,不会将你的数据用于模型训练,但以下情况除外: - Big Pickle:在免费期间,收集的数据可能会被用于改进模型。 +- GLM 5 Free:在免费期间,收集的数据可能会被用于改进模型。 - Kimi K2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 - MiniMax M2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 - OpenAI API:请求会根据 [OpenAI 数据政策](https://platform.openai.com/docs/guides/your-data)保留 30 天。 diff --git a/packages/web/src/content/docs/zh-tw/share.mdx b/packages/web/src/content/docs/zh-tw/share.mdx index 1512007bc35..58365035b64 100644 --- a/packages/web/src/content/docs/zh-tw/share.mdx +++ b/packages/web/src/content/docs/zh-tw/share.mdx @@ -41,7 +41,7 @@ OpenCode 支援三種分享模式,用於控制對話的共享方式: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "manual" } ``` @@ -54,7 +54,7 @@ OpenCode 支援三種分享模式,用於控制對話的共享方式: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "auto" } ``` @@ -69,7 +69,7 @@ OpenCode 支援三種分享模式,用於控制對話的共享方式: ```json title="opencode.json" { - "$schema": "https://opncd.ai/config.json", + "$schema": "https://opencode.ai/config.json", "share": "disabled" } ``` diff --git a/script/beta.ts b/script/beta.ts index a5fb027e633..b0e6c2dcc15 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -30,6 +30,52 @@ Please resolve this issue to include this PR in the next beta release.` } } +async function conflicts() { + const out = await $`git diff --name-only --diff-filter=U`.text().catch(() => "") + return out + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) +} + +async function cleanup() { + try { + await $`git merge --abort` + } catch {} + try { + await $`git checkout -- .` + } catch {} + try { + await $`git clean -fd` + } catch {} +} + +async function fix(pr: PR, files: string[]) { + console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`) + const prompt = [ + `Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`, + `Only touch these files: ${files.join(", ")}.`, + "Keep the merge in progress, do not abort the merge, and do not create a commit.", + "When done, leave the working tree with no unmerged files.", + ].join("\n") + + try { + await $`opencode run -m opencode/gpt-5.3-codex ${prompt}` + } catch (err) { + console.log(` opencode failed: ${err}`) + return false + } + + const left = await conflicts() + if (left.length > 0) { + console.log(` Conflicts remain: ${left.join(", ")}`) + return false + } + + console.log(" Conflicts resolved with opencode") + return true +} + async function main() { console.log("Fetching open PRs with beta label...") @@ -69,19 +115,22 @@ async function main() { try { await $`git merge --no-commit --no-ff pr/${pr.number}` } catch { - console.log(" Failed to merge (conflicts)") - try { - await $`git merge --abort` - } catch {} - try { - await $`git checkout -- .` - } catch {} - try { - await $`git clean -fd` - } catch {} - failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) - await commentOnPR(pr.number, "Merge conflicts with dev branch") - continue + const files = await conflicts() + if (files.length > 0) { + console.log(" Failed to merge (conflicts)") + if (!(await fix(pr, files))) { + await cleanup() + failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + await commentOnPR(pr.number, "Merge conflicts with dev branch") + continue + } + } else { + console.log(" Failed to merge") + await cleanup() + failed.push({ number: pr.number, title: pr.title, reason: "Merge failed" }) + await commentOnPR(pr.number, "Merge failed") + continue + } } try { diff --git a/script/publish.ts b/script/publish.ts index 8aa921daa83..b7ed5c8221c 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import { Script } from "@opencode-ai/script" +import { fileURLToPath } from "url" const highlightsTemplate = `