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/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts index 321d96af57a..44efb7f0046 100644 --- a/packages/app/e2e/files/file-tree.spec.ts +++ b/packages/app/e2e/files/file-tree.spec.ts @@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession await tab.click() await expect(tab).toHaveAttribute("aria-selected", "true") - const code = page.locator('[data-component="code"]').first() - await expect(code).toBeVisible() - await expect(code).toContainText("export default function FileTree") + const viewer = page.locator('[data-component="file"][data-mode="text"]').first() + await expect(viewer).toBeVisible() + await expect(viewer).toContainText("export default function FileTree") }) diff --git a/packages/app/e2e/files/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts index b968acc130e..bee67c7d128 100644 --- a/packages/app/e2e/files/file-viewer.spec.ts +++ b/packages/app/e2e/files/file-viewer.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" +import { modKey } from "../utils" test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { await gotoSession() @@ -43,7 +44,60 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession } await expect(tab).toBeVisible() await tab.click() - const code = page.locator('[data-component="code"]').first() - await expect(code).toBeVisible() - await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() + const viewer = page.locator('[data-component="file"][data-mode="text"]').first() + await expect(viewer).toBeVisible() + await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() +}) + +test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => { + await gotoSession() + + await page.locator(promptSelector).click() + await page.keyboard.type("/open") + + const command = page.locator('[data-slash-id="file.open"]').first() + await expect(command).toBeVisible() + await page.keyboard.press("Enter") + + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() + + const input = dialog.getByRole("textbox").first() + await input.fill("package.json") + + const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]') + let index = -1 + await expect + .poll( + async () => { + const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? "")) + index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, ""))) + return index >= 0 + }, + { timeout: 30_000 }, + ) + .toBe(true) + + const item = items.nth(index) + await expect(item).toBeVisible() + await item.click() + + await expect(dialog).toHaveCount(0) + + const tab = page.getByRole("tab", { name: "package.json" }) + await expect(tab).toBeVisible() + await tab.click() + + const viewer = page.locator('[data-component="file"][data-mode="text"]').first() + await expect(viewer).toBeVisible() + + await page.locator(promptSelector).click() + await page.keyboard.press(`${modKey}+f`) + + const findInput = page.getByPlaceholder("Find") + await expect(findInput).toBeVisible() + await expect(findInput).toBeFocused() }) 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/app.tsx b/packages/app/src/app.tsx index 1be9f38d748..4a25e8d9483 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,11 +1,9 @@ import "@/index.css" -import { Code } from "@opencode-ai/ui/code" +import { File } from "@opencode-ai/ui/file" import { I18nProvider } from "@opencode-ai/ui/context" -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 { FileComponentProvider } from "@opencode-ai/ui/context/file" import { MarkedProvider } from "@opencode-ai/ui/context/marked" -import { Diff } from "@opencode-ai/ui/diff" import { Font } from "@opencode-ai/ui/font" import { ThemeProvider } from "@opencode-ai/ui/theme" import { MetaProvider } from "@solidjs/meta" @@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) { }> - - {props.children} - + {props.children} 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..85aa1638489 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { useFile } from "@/context/file" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, @@ -43,6 +43,9 @@ import { canNavigateHistoryAtCursor, navigatePromptHistory, prependHistoryEntry, + type PromptHistoryComment, + type PromptHistoryEntry, + type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" import { createPromptSubmit } from "./prompt-input/submit" @@ -170,12 +173,29 @@ export const PromptInput: Component = (props) => { const focus = { file: item.path, id: item.commentID } comments.setActive(focus) + const queueCommentFocus = (attempts = 6) => { + const schedule = (left: number) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (left <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (!current) return + if (current.file !== focus.file || current.id !== focus.id) return + schedule(left - 1) + }) + }) + } + + schedule(attempts) + } + const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.setTab("changes") tabs().setActive("review") - requestAnimationFrame(() => comments.setFocus(focus)) + queueCommentFocus() return } @@ -183,8 +203,8 @@ export const PromptInput: Component = (props) => { layout.fileTree.setTab("all") const tab = files.tab(item.path) tabs().open(tab) - files.load(item.path) - requestAnimationFrame(() => comments.setFocus(focus)) + tabs().setActive(tab) + Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) } const recent = createMemo(() => { @@ -219,7 +239,7 @@ export const PromptInput: Component = (props) => { const [store, setStore] = createStore<{ popover: "at" | "slash" | null historyIndex: number - savedPrompt: Prompt | null + savedPrompt: PromptHistoryEntry | null placeholder: number draggingType: "image" | "@mention" | null mode: "normal" | "shell" @@ -227,7 +247,7 @@ export const PromptInput: Component = (props) => { }>({ popover: null, historyIndex: -1, - savedPrompt: null, + savedPrompt: null as PromptHistoryEntry | null, placeholder: Math.floor(Math.random() * EXAMPLES.length), draggingType: null, mode: "normal", @@ -256,7 +276,7 @@ export const PromptInput: Component = (props) => { const [history, setHistory] = persisted( Persist.global("prompt-history", ["prompt-history.v1"]), createStore<{ - entries: Prompt[] + entries: PromptHistoryStoredEntry[] }>({ entries: [], }), @@ -264,7 +284,7 @@ export const PromptInput: Component = (props) => { const [shellHistory, setShellHistory] = persisted( Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), createStore<{ - entries: Prompt[] + entries: PromptHistoryStoredEntry[] }>({ entries: [], }), @@ -282,9 +302,66 @@ export const PromptInput: Component = (props) => { }), ) - const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + if (item.type !== "file") return [] + const comment = item.comment?.trim() + if (!comment) return [] + + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ + start: item.selection.startLine, + end: item.selection.endLine, + } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + + const applyHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file" as const, + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { + const p = entry.prompt const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) + applyHistoryComments(entry.comments) prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() @@ -846,7 +923,7 @@ export const PromptInput: Component = (props) => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const next = prependHistoryEntry(currentHistory.entries, prompt) + const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) if (next === currentHistory.entries) return setCurrentHistory("entries", next) } @@ -857,12 +934,13 @@ export const PromptInput: Component = (props) => { entries: store.mode === "shell" ? shellHistory.entries : history.entries, historyIndex: store.historyIndex, currentPrompt: prompt.current(), + currentComments: historyComments(), savedPrompt: store.savedPrompt, }) if (!result.handled) return false setStore("historyIndex", result.historyIndex) setStore("savedPrompt", result.savedPrompt) - applyHistoryPrompt(result.prompt, result.cursor) + applyHistoryPrompt(result.entry, result.cursor) return true } @@ -1048,6 +1126,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 +1316,9 @@ export const PromptInput: Component = (props) => { diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 72bdecc01f3..4c2e2d8bec9 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -35,6 +35,15 @@ describe("buildRequestParts", () => { result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), ).toBe(true) expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) + expect( + result.requestParts.some( + (part) => + part.type === "text" && + part.synthetic && + part.metadata?.opencodeComment && + (part.metadata.opencodeComment as { comment?: string }).comment === "check this", + ), + ).toBe(true) expect(result.optimisticParts).toHaveLength(result.requestParts.length) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 0cc54dc2b78..4146fb4847f 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import { Identifier } from "@/utils/id" +import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note" type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } @@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) => const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" -const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { - const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined - const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined - const range = - start === undefined || end === undefined - ? "this file" - : start === end - ? `line ${start}` - : `lines ${start} through ${end}` - return `The user made the following comment regarding ${range} of ${path}: ${comment}` -} - const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { if (part.type === "text") { return { @@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) { { id: Identifier.ascending("part"), type: "text", - text: commentNote(item.path, item.selection, comment), + text: formatCommentNote({ path: item.path, selection: item.selection, comment }), synthetic: true, + metadata: createCommentMetadata({ + path: item.path, + selection: item.selection, + comment, + preview: item.preview, + origin: item.commentOrigin, + }), } satisfies PromptRequestPart, filePart, ] diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index b7a4f896b88..37b5ce19627 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt" import { canNavigateHistoryAtCursor, clonePromptParts, + normalizePromptHistoryEntry, navigatePromptHistory, prependHistoryEntry, promptLength, + type PromptHistoryComment, } from "./history" const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] +const comment = (id: string, value = "note"): PromptHistoryComment => ({ + id, + path: "src/a.ts", + selection: { start: 2, end: 4 }, + comment: value, + time: 1, + origin: "review", + preview: "const a = 1", +}) describe("prompt-input history", () => { test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => { const first = prependHistoryEntry([], DEFAULT_PROMPT) expect(first).toEqual([]) + const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")]) + expect(commentsOnly).toHaveLength(1) + const withOne = prependHistoryEntry([], text("hello")) expect(withOne).toHaveLength(1) const deduped = prependHistoryEntry(withOne, text("hello")) expect(deduped).toBe(withOne) + + const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")]) + expect(dedupedComments).toBe(commentsOnly) }) test("navigatePromptHistory restores saved prompt when moving down from newest", () => { @@ -31,24 +48,57 @@ describe("prompt-input history", () => { entries, historyIndex: -1, currentPrompt: text("draft"), + currentComments: [comment("draft")], savedPrompt: null, }) expect(up.handled).toBe(true) if (!up.handled) throw new Error("expected handled") expect(up.historyIndex).toBe(0) expect(up.cursor).toBe("start") + expect(up.entry.comments).toEqual([]) const down = navigatePromptHistory({ direction: "down", entries, historyIndex: up.historyIndex, currentPrompt: text("ignored"), + currentComments: [], savedPrompt: up.savedPrompt, }) expect(down.handled).toBe(true) if (!down.handled) throw new Error("expected handled") expect(down.historyIndex).toBe(-1) - expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft") + expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft") + expect(down.entry.comments).toEqual([comment("draft")]) + }) + + test("navigatePromptHistory keeps entry comments when moving through history", () => { + const entries = [ + { + prompt: text("with comment"), + comments: [comment("c1")], + }, + ] + + const up = navigatePromptHistory({ + direction: "up", + entries, + historyIndex: -1, + currentPrompt: text("draft"), + currentComments: [], + savedPrompt: null, + }) + + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment") + expect(up.entry.comments).toEqual([comment("c1")]) + }) + + test("normalizePromptHistoryEntry supports legacy prompt arrays", () => { + const entry = normalizePromptHistoryEntry(text("legacy")) + expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy") + expect(entry.comments).toEqual([]) }) test("helpers clone prompt and count text content length", () => { diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index c279a3ed563..de62653211d 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -1,9 +1,27 @@ import type { Prompt } from "@/context/prompt" +import type { SelectedLineRange } from "@/context/file" const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] export const MAX_HISTORY = 100 +export type PromptHistoryComment = { + id: string + path: string + selection: SelectedLineRange + comment: string + time: number + origin?: "review" | "file" + preview?: string +} + +export type PromptHistoryEntry = { + prompt: Prompt + comments: PromptHistoryComment[] +} + +export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry + export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) { const position = Math.max(0, Math.min(cursor, text.length)) const atStart = position === 0 @@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt { }) } +function cloneSelection(selection: SelectedLineRange): SelectedLineRange { + return { + start: selection.start, + end: selection.end, + ...(selection.side ? { side: selection.side } : {}), + ...(selection.endSide ? { endSide: selection.endSide } : {}), + } +} + +export function clonePromptHistoryComments(comments: PromptHistoryComment[]) { + return comments.map((comment) => ({ + ...comment, + selection: cloneSelection(comment.selection), + })) +} + +export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry { + if (Array.isArray(entry)) { + return { + prompt: clonePromptParts(entry), + comments: [], + } + } + return { + prompt: clonePromptParts(entry.prompt), + comments: clonePromptHistoryComments(entry.comments), + } +} + export function promptLength(prompt: Prompt) { return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) } -export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) { +export function prependHistoryEntry( + entries: PromptHistoryStoredEntry[], + prompt: Prompt, + comments: PromptHistoryComment[] = [], + max = MAX_HISTORY, +) { const text = prompt .map((part) => ("content" in part ? part.content : "")) .join("") .trim() const hasImages = prompt.some((part) => part.type === "image") - if (!text && !hasImages) return entries + const hasComments = comments.some((comment) => !!comment.comment.trim()) + if (!text && !hasImages && !hasComments) return entries - const entry = clonePromptParts(prompt) + const entry = { + prompt: clonePromptParts(prompt), + comments: clonePromptHistoryComments(comments), + } satisfies PromptHistoryEntry const last = entries[0] if (last && isPromptEqual(last, entry)) return entries return [entry, ...entries].slice(0, max) } -function isPromptEqual(promptA: Prompt, promptB: Prompt) { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] +function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) { + return ( + commentA.path === commentB.path && + commentA.comment === commentB.comment && + commentA.origin === commentB.origin && + commentA.preview === commentB.preview && + commentA.selection.start === commentB.selection.start && + commentA.selection.end === commentB.selection.end && + commentA.selection.side === commentB.selection.side && + commentA.selection.endSide === commentB.selection.endSide + ) +} + +function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) { + const entryA = normalizePromptHistoryEntry(promptA) + const entryB = normalizePromptHistoryEntry(promptB) + if (entryA.prompt.length !== entryB.prompt.length) return false + for (let i = 0; i < entryA.prompt.length; i++) { + const partA = entryA.prompt[i] + const partB = entryB.prompt[i] if (partA.type !== partB.type) return false if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false if (partA.type === "file") { @@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) { if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false } + if (entryA.comments.length !== entryB.comments.length) return false + for (let i = 0; i < entryA.comments.length; i++) { + const commentA = entryA.comments[i] + const commentB = entryB.comments[i] + if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false + } return true } type HistoryNavInput = { direction: "up" | "down" - entries: Prompt[] + entries: PromptHistoryStoredEntry[] historyIndex: number currentPrompt: Prompt - savedPrompt: Prompt | null + currentComments: PromptHistoryComment[] + savedPrompt: PromptHistoryEntry | null } type HistoryNavResult = | { handled: false historyIndex: number - savedPrompt: Prompt | null + savedPrompt: PromptHistoryEntry | null } | { handled: true historyIndex: number - savedPrompt: Prompt | null - prompt: Prompt + savedPrompt: PromptHistoryEntry | null + entry: PromptHistoryEntry cursor: "start" | "end" } @@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult } if (input.historyIndex === -1) { + const entry = normalizePromptHistoryEntry(input.entries[0]) return { handled: true, historyIndex: 0, - savedPrompt: clonePromptParts(input.currentPrompt), - prompt: input.entries[0], + savedPrompt: { + prompt: clonePromptParts(input.currentPrompt), + comments: clonePromptHistoryComments(input.currentComments), + }, + entry, cursor: "start", } } if (input.historyIndex < input.entries.length - 1) { const next = input.historyIndex + 1 + const entry = normalizePromptHistoryEntry(input.entries[next]) return { handled: true, historyIndex: next, savedPrompt: input.savedPrompt, - prompt: input.entries[next], + entry, cursor: "start", } } @@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult if (input.historyIndex > 0) { const next = input.historyIndex - 1 + const entry = normalizePromptHistoryEntry(input.entries[next]) return { handled: true, historyIndex: next, savedPrompt: input.savedPrompt, - prompt: input.entries[next], + entry, cursor: "end", } } @@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult handled: true, historyIndex: -1, savedPrompt: null, - prompt: input.savedPrompt, + entry: input.savedPrompt, cursor: "end", } } @@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult handled: true, historyIndex: -1, savedPrompt: null, - prompt: DEFAULT_PROMPT, + entry: { + prompt: DEFAULT_PROMPT, + comments: [], + }, cursor: "end", } } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 1ea97c395c4..582aa33911a 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -9,7 +9,7 @@ import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" -import { Code } from "@opencode-ai/ui/code" +import { File } from "@opencode-ai/ui/file" import { Markdown } from "@opencode-ai/ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" @@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) => }) return ( - 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..dfda91c160a 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -13,13 +13,15 @@ import { useCommand } from "@/context/command" export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return (
- + } + > + + + + + {getFilename(props.path)}
) @@ -37,8 +39,8 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v return }) return ( -
-
+
+
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/comments.test.ts b/packages/app/src/context/comments.test.ts index bee5c7871e0..82fa170f2fc 100644 --- a/packages/app/src/context/comments.test.ts +++ b/packages/app/src/context/comments.test.ts @@ -150,4 +150,37 @@ describe("comments session indexing", () => { dispose() }) }) + + test("update changes only the targeted comment body", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)], + }) + + comments.update("a.ts", "a2", "edited") + + expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"]) + + dispose() + }) + }) + + test("replace swaps comment state and clears focus state", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10)], + }) + + comments.setFocus({ file: "a.ts", id: "a1" }) + comments.setActive({ file: "a.ts", id: "a1" }) + comments.replace([line("b.ts", "b1", 30)]) + + expect(comments.list("a.ts")).toEqual([]) + expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"]) + expect(comments.focus()).toBeNull() + expect(comments.active()).toBeNull() + + dispose() + }) + }) }) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index ecf63e45b64..a97010c0af3 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -44,6 +44,37 @@ function aggregate(comments: Record) { .sort((a, b) => a.time - b.time) } +function cloneSelection(selection: SelectedLineRange): SelectedLineRange { + const next: SelectedLineRange = { + start: selection.start, + end: selection.end, + } + + if (selection.side) next.side = selection.side + if (selection.endSide) next.endSide = selection.endSide + return next +} + +function cloneComment(comment: LineComment): LineComment { + return { + ...comment, + selection: cloneSelection(comment.selection), + } +} + +function group(comments: LineComment[]) { + return comments.reduce>((acc, comment) => { + const list = acc[comment.file] + const next = cloneComment(comment) + if (list) { + list.push(next) + return acc + } + acc[comment.file] = [next] + return acc + }, {}) +} + function createCommentSessionState(store: Store, setStore: SetStoreFunction) { const [state, setState] = createStore({ focus: null as CommentFocus | null, @@ -70,6 +101,7 @@ function createCommentSessionState(store: Store, setStore: SetStor id: uuid(), time: Date.now(), ...input, + selection: cloneSelection(input.selection), } batch(() => { @@ -87,6 +119,23 @@ function createCommentSessionState(store: Store, setStore: SetStor }) } + const update = (file: string, id: string, comment: string) => { + setStore("comments", file, (items) => + (items ?? []).map((item) => { + if (item.id !== id) return item + return { ...item, comment } + }), + ) + } + + const replace = (comments: LineComment[]) => { + batch(() => { + setStore("comments", reconcile(group(comments))) + setFocus(null) + setActive(null) + }) + } + const clear = () => { batch(() => { setStore("comments", reconcile({})) @@ -100,6 +149,8 @@ function createCommentSessionState(store: Store, setStore: SetStor all, add, remove, + update, + replace, clear, focus: () => state.focus, setFocus, @@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) { all: session.all, add: session.add, remove: session.remove, + update: session.update, + replace: session.replace, clear: session.clear, focus: session.focus, setFocus: session.setFocus, @@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont all: () => session().all(), add: (input: Omit) => session().add(input), remove: (file: string, id: string) => session().remove(file, id), + update: (file: string, id: string, comment: string) => session().update(file, id, comment), + replace: (comments: LineComment[]) => session().replace(comments), clear: () => session().clear(), focus: () => session().focus(), setFocus: (focus: CommentFocus | null) => session().setFocus(focus), 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/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 6e8ddf62df8..4c060174ab8 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { - if (range.start <= range.end) return range + if (range.start <= range.end) return { ...range } const startSide = range.side const endSide = range.endSide ?? startSide 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/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts index 2a13e40204f..483be150f66 100644 --- a/packages/app/src/context/layout-scroll.test.ts +++ b/packages/app/src/context/layout-scroll.test.ts @@ -41,4 +41,24 @@ describe("createScrollPersistence", () => { vi.useRealTimers() } }) + + test("reseeds empty cache after persisted snapshot loads", () => { + const snapshot = { + session: {}, + } as Record> + + const scroll = createScrollPersistence({ + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: () => {}, + }) + + expect(scroll.scroll("session", "review")).toBeUndefined() + + snapshot.session = { + review: { x: 12, y: 34 }, + } + + expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 }) + scroll.dispose() + }) }) diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts index 30b0f69044a..ef66eccd904 100644 --- a/packages/app/src/context/layout-scroll.ts +++ b/packages/app/src/context/layout-scroll.ts @@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) { } function seed(sessionKey: string) { - if (cache[sessionKey]) return - setCache(sessionKey, clone(opts.getSnapshot(sessionKey))) + const next = clone(opts.getSnapshot(sessionKey)) + const current = cache[sessionKey] + if (!current) { + setCache(sessionKey, next) + return + } + + if (Object.keys(current).length > 0) return + if (Object.keys(next).length === 0) return + setCache(sessionKey, next) } function scroll(sessionKey: string, tab: string) { 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/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 06489210518..fb822655911 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) { return `${key}:c=${digest.slice(0, 8)}` } +function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + function createPromptActions( setStore: SetStoreFunction<{ prompt: Prompt @@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) { remove(key: string) { setStore("context", "items", (items) => items.filter((x) => x.key !== key)) }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, }, set: actions.set, reset: actions.reset, @@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), remove: (key: string) => session().context.remove(key), + removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), + updateComment: (path: string, commentID: string, next: Partial & { comment?: string }) => + session().context.updateComment(path, commentID, next), + replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), }, set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), reset: () => session().reset(), 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..0d2718efbda 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -107,7 +107,7 @@ export default function Page() { if (desktopReviewOpen()) return `${layout.session.width()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) - const centered = createMemo(() => isDesktop() && !desktopSidePanelOpen()) + const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -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 } @@ -378,11 +379,58 @@ export default function Page() { }) } + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + preview?: string + }) => { + comments.update(input.file, input.id, input.comment) + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(input.preview ? { preview: input.preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } + + const reviewCommentActions = createMemo(() => ({ + moreLabel: language.t("common.moreOptions"), + editLabel: language.t("common.edit"), + deleteLabel: language.t("common.delete"), + saveLabel: language.t("common.save"), + })) + + const isEditableTarget = (target: EventTarget | null | undefined) => { + if (!(target instanceof HTMLElement)) return false + return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable + } + + const deepActiveElement = () => { + let current: Element | null = document.activeElement + while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { + current = current.shadowRoot.activeElement + } + return current instanceof HTMLElement ? current : undefined + } + const handleKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement as HTMLElement | undefined + const path = event.composedPath() + const target = path.find((item): item is HTMLElement => item instanceof HTMLElement) + const activeElement = deepActiveElement() + + const protectedTarget = path.some( + (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null, + ) + if (protectedTarget || isEditableTarget(target)) return + if (activeElement) { const isProtected = activeElement.closest("[data-prevent-autofocus]") - const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable + const isInput = isEditableTarget(activeElement) if (isProtected || isInput) return } if (dialog.active) return @@ -415,7 +463,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) @@ -468,7 +516,8 @@ export default function Page() { } onSelect={(option) => option && setStore("changes", option)} variant="ghost" - size="large" + size="small" + valueClass="text-14-medium" /> ) @@ -498,6 +547,9 @@ export default function Page() { onScrollRef={(el) => setTree("reviewScroll", el)} focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} @@ -519,6 +571,9 @@ export default function Page() { onScrollRef={(el) => setTree("reviewScroll", el)} focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} @@ -547,6 +602,9 @@ export default function Page() { onScrollRef={(el) => setTree("reviewScroll", el)} focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} @@ -699,33 +757,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..e92eee67066 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,15 +1,17 @@ -import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js" +import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useParams } from "@solidjs/router" -import { useCodeComponent } from "@opencode-ai/ui/context/code" +import type { FileSearchHandle } from "@opencode-ai/ui/file" +import { useFileComponent } from "@opencode-ai/ui/context/file" +import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" +import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" import { sampledChecksum } from "@opencode-ai/util/encode" -import { decode64 } from "@/utils/base64" -import { showToast } from "@opencode-ai/ui/toast" -import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" -import { Mark } from "@opencode-ai/ui/logo" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { showToast } from "@opencode-ai/ui/toast" import { useLayout } from "@/context/layout" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" @@ -17,11 +19,37 @@ import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" import { getSessionHandoff } from "@/pages/session/handoff" -const formatCommentLabel = (range: SelectedLineRange) => { - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - if (start === end) return `line ${start}` - return `lines ${start}-${end}` +function FileCommentMenu(props: { + moreLabel: string + editLabel: string + deleteLabel: string + onEdit: VoidFunction + onDelete: VoidFunction +}) { + return ( +
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + + + + + + {props.editLabel} + + + {props.deleteLabel} + + + + +
+ ) } export function FileTabContent(props: { tab: string }) { @@ -31,7 +59,7 @@ export function FileTabContent(props: { tab: string }) { const comments = useComments() const language = useLanguage() const prompt = usePrompt() - const codeComponent = useCodeComponent() + const fileComponent = useFileComponent() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) @@ -41,6 +69,13 @@ export function FileTabContent(props: { tab: string }) { let scrollFrame: number | undefined let pending: { x: number; y: number } | undefined let codeScroll: HTMLElement[] = [] + let find: FileSearchHandle | null = null + + const search = { + register: (handle: FileSearchHandle | null) => { + find = handle + }, + } const path = createMemo(() => file.pathFromTab(props.tab)) const state = createMemo(() => { @@ -50,66 +85,18 @@ export function FileTabContent(props: { tab: string }) { }) const contents = createMemo(() => state()?.content?.content ?? "") const cacheKey = createMemo(() => sampledChecksum(contents())) - const isImage = createMemo(() => { - const c = state()?.content - return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" - }) - const isSvg = createMemo(() => { - const c = state()?.content - return c?.mimeType === "image/svg+xml" - }) - const isBinary = createMemo(() => state()?.content?.type === "binary") - const svgContent = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding !== "base64") return c.content - return decode64(c.content) - }) - - const svgDecodeFailed = createMemo(() => { - if (!isSvg()) return false - const c = state()?.content - if (!c) return false - if (c.encoding !== "base64") return false - return svgContent() === undefined - }) - - const svgToast = { shown: false } - createEffect(() => { - if (!svgDecodeFailed()) return - if (svgToast.shown) return - svgToast.shown = true - showToast({ - variant: "error", - title: language.t("toast.file.loadFailed.title"), - }) - }) - const svgPreviewUrl = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` - }) - const imageDataUrl = createMemo(() => { - if (!isImage()) return - const c = state()?.content - return `data:${c?.mimeType};base64,${c?.content}` - }) - const selectedLines = createMemo(() => { + const selectedLines = createMemo(() => { const p = path() if (!p) return null - if (file.ready()) return file.selectedLines(p) ?? null - return getSessionHandoff(sessionKey())?.files[p] ?? null + if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null + return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null }) const selectionPreview = (source: string, selection: FileSelection) => { - const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) - const end = Math.max(selection.startLine, selection.endLine) - const lines = source.split("\n").slice(start - 1, end) - if (lines.length === 0) return undefined - return lines.slice(0, 2).join("\n") + return previewSelectedLines(source, { + start: selection.startLine, + end: selection.endLine, + }) } const addCommentToContext = (input: { @@ -145,7 +132,25 @@ export function FileTabContent(props: { tab: string }) { }) } - let wrap: HTMLDivElement | undefined + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + }) => { + comments.update(input.file, input.id, input.comment) + const preview = + input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(preview ? { preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } const fileComments = createMemo(() => { const p = path() @@ -153,121 +158,105 @@ export function FileTabContent(props: { tab: string }) { return comments.list(p) }) - const commentLayout = createMemo(() => { - return fileComments() - .map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`) - .join("|") - }) - const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) const [note, setNote] = createStore({ openedComment: null as string | null, commenting: null as SelectedLineRange | null, - draft: "", - positions: {} as Record, - draftTop: undefined as number | undefined, + selected: null as SelectedLineRange | null, }) - const setCommenting = (range: SelectedLineRange | null) => { - setNote("commenting", range) - scheduleComments() - if (!range) return - setNote("draft", "") - } - - const getRoot = () => { - const el = wrap - if (!el) return - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const root = host.shadowRoot - if (!root) return - - return root - } - - const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { - const line = Math.max(range.start, range.end) - const node = root.querySelector(`[data-line="${line}"]`) - if (!(node instanceof HTMLElement)) return - return node - } - - const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { - const wrapperRect = wrapper.getBoundingClientRect() - const rect = marker.getBoundingClientRect() - return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) + const syncSelected = (range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null) } - const updateComments = () => { - const el = wrap - const root = getRoot() - if (!el || !root) { - setNote("positions", {}) - setNote("draftTop", undefined) - return - } - - const estimateTop = (range: SelectedLineRange) => { - const line = Math.max(range.start, range.end) - const height = 24 - const offset = 2 - return Math.max(0, (line - 1) * height + offset) - } - - const large = contents().length > 500_000 - - const next: Record = {} - for (const comment of fileComments()) { - const marker = findMarker(root, comment.selection) - if (marker) next[comment.id] = markerTop(el, marker) - else if (large) next[comment.id] = estimateTop(comment.selection) - } + const activeSelection = () => note.selected ?? selectedLines() + + const commentsUi = createLineCommentController({ + comments: fileComments, + label: language.t("ui.lineComment.submit"), + draftKey: () => path() ?? props.tab, + state: { + opened: () => note.openedComment, + setOpened: (id) => setNote("openedComment", id), + selected: () => note.selected, + setSelected: (range) => setNote("selected", range), + commenting: () => note.commenting, + setCommenting: (range) => setNote("commenting", range), + syncSelected, + hoverSelected: syncSelected, + }, + getHoverSelectedRange: activeSelection, + cancelDraftOnCommentToggle: true, + clearSelectionOnSelectionEndNull: true, + onSubmit: ({ comment, selection }) => { + const p = path() + if (!p) return + addCommentToContext({ file: p, selection, comment, origin: "file" }) + }, + onUpdate: ({ id, comment, selection }) => { + const p = path() + if (!p) return + updateCommentInContext({ id, file: p, selection, comment }) + }, + onDelete: (comment) => { + const p = path() + if (!p) return + removeCommentFromContext({ id: comment.id, file: p }) + }, + editSubmitLabel: language.t("common.save"), + renderCommentActions: (_, controls) => ( + + ), + onDraftPopoverFocusOut: (e: FocusEvent) => { + const current = e.currentTarget as HTMLDivElement + const target = e.relatedTarget + if (target instanceof Node && current.contains(target)) return + + setTimeout(() => { + if (!document.activeElement || !current.contains(document.activeElement)) { + setNote("commenting", null) + } + }, 0) + }, + }) - const removed = Object.keys(note.positions).filter((id) => next[id] === undefined) - const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top) - if (removed.length > 0 || changed.length > 0) { - setNote( - "positions", - produce((draft) => { - for (const id of removed) { - delete draft[id] - } - - for (const [id, top] of changed) { - draft[id] = top - } - }), - ) - } + createEffect(() => { + if (typeof window === "undefined") return - const range = note.commenting - if (!range) { - setNote("draftTop", undefined) - return - } + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + if (tabs().active() !== props.tab) return + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== "f") return - const marker = findMarker(root, range) - if (marker) { - setNote("draftTop", markerTop(el, marker)) - return + event.preventDefault() + event.stopPropagation() + find?.focus() } - setNote("draftTop", large ? estimateTop(range) : undefined) - } - - const scheduleComments = () => { - requestAnimationFrame(updateComments) - } - - createEffect(() => { - commentLayout() - scheduleComments() + window.addEventListener("keydown", onKeyDown, { capture: true }) + onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true })) }) + createEffect( + on( + path, + () => { + commentsUi.note.reset() + }, + { defer: true }, + ), + ) + createEffect(() => { const focus = comments.focus() const p = path() @@ -278,9 +267,7 @@ export function FileTabContent(props: { tab: string }) { const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return - setNote("openedComment", target.id) - setCommenting(null) - file.setSelectedLines(p, target.selection) + commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true }) requestAnimationFrame(() => comments.clearFocus()) }) @@ -371,6 +358,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, @@ -413,99 +406,50 @@ export function FileTabContent(props: { tab: string }) { cancelAnimationFrame(scrollFrame) }) - const renderCode = (source: string, wrapperClass: string) => ( -
{ - wrap = el - scheduleComments() - }} - class={`relative overflow-hidden ${wrapperClass}`} - > + const renderFile = (source: string) => ( +
{ requestAnimationFrame(restoreScroll) - requestAnimationFrame(scheduleComments) }} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderHoverUtility={commentsUi.renderHoverUtility} onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - if (!range) setCommenting(null) + commentsUi.onLineSelected(range) }} + onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} onLineSelectionEnd={(range: SelectedLineRange | null) => { - if (!range) { - setCommenting(null) - return - } - - setNote("openedComment", null) - setCommenting(range) + commentsUi.onLineSelectionEnd(range) }} + search={search} overflow="scroll" class="select-text" + media={{ + mode: "auto", + path: path(), + current: state()?.content, + onLoad: () => requestAnimationFrame(restoreScroll), + onError: (args: { kind: "image" | "audio" | "svg" }) => { + if (args.kind !== "svg") return + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + }) + }, + }} /> - - {(comment) => ( - { - const p = path() - if (!p) return - file.setSelectedLines(p, comment.selection) - }} - onClick={() => { - const p = path() - if (!p) return - setCommenting(null) - setNote("openedComment", (current) => (current === comment.id ? null : comment.id)) - file.setSelectedLines(p, comment.selection) - }} - /> - )} - - - {(range) => ( - - setNote("draft", value)} - onCancel={() => setCommenting(null)} - onSubmit={(value) => { - const p = path() - if (!p) return - addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" }) - setCommenting(null) - }} - onPopoverFocusOut={(e: FocusEvent) => { - const current = e.currentTarget as HTMLDivElement - const target = e.relatedTarget - if (target instanceof Node && current.contains(target)) return - - setTimeout(() => { - if (!document.activeElement || !current.contains(document.activeElement)) { - setCommenting(null) - } - }, 0) - }} - /> - - )} -
) @@ -520,36 +464,7 @@ export function FileTabContent(props: { tab: string }) { onScroll={handleScroll as any} > - -
- {path()} requestAnimationFrame(restoreScroll)} - /> -
-
- -
- {renderCode(svgContent() ?? "", "")} - -
- {path()} -
-
-
-
- -
- -
-
{path()?.split("/").pop()}
-
{language.t("session.files.binaryContent")}
-
-
-
- {renderCode(contents(), "pb-40")} + {renderFile(contents())}
{language.t("common.loading")}...
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..8215f31bade 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "so import { createStore, produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" +import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -9,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -18,6 +20,35 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" + +type MessageComment = { + path: string + comment: string + selection?: { + startLine: number + endLine: number + } +} + +const messageComments = (parts: Part[]): MessageComment[] => + parts.flatMap((part) => { + if (part.type !== "text" || !(part as TextPart).synthetic) return [] + const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) + if (!next) return [] + return [ + { + path: next.path, + comment: next.comment, + selection: next.selection + ? { + startLine: next.selection.startLine, + endLine: next.selection.endLine, + } + : undefined, + }, + ] + }) const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined @@ -376,6 +407,7 @@ export function MessageTimeline(props: { >
- {(message) => ( -
{ - props.onRegisterMessage(el, message.id) - onCleanup(() => props.onUnregisterMessage(message.id)) - }} - classList={{ - "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, - }} - > - { + const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? [])) + return ( +
{ + props.onRegisterMessage(el, message.id) + onCleanup(() => props.onUnregisterMessage(message.id)) }} - /> -
- )} + classList={{ + "min-w-0 w-full max-w-full": true, + "md:max-w-200 2xl:max-w-[1000px]": props.centered, + }} + > + 0}> +
+
+
+ + {(comment) => ( +
+
+ + {getFilename(comment.path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {comment.comment} +
+
+ )} +
+
+
+
+
+ +
+ ) + }}
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 9349e993768..7f90ff5acd4 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,7 +1,11 @@ import { createEffect, on, onCleanup, type JSX } from "solid-js" -import { createStore } from "solid-js/store" import type { FileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/ui/session-review" +import type { + SessionReviewCommentActions, + SessionReviewCommentDelete, + SessionReviewCommentUpdate, +} from "@opencode-ai/ui/session-review" import type { SelectedLineRange } from "@/context/file" import { useSDK } from "@/context/sdk" import { useLayout } from "@/context/layout" @@ -18,6 +22,9 @@ export interface SessionReviewTabProps { onDiffStyleChange?: (style: DiffStyle) => void onViewFile?: (file: string) => void onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void + onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void + lineCommentActions?: SessionReviewCommentActions comments?: LineComment[] focusedComment?: { file: string; id: string } | null onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void @@ -31,38 +38,8 @@ export interface SessionReviewTabProps { } export function StickyAddButton(props: { children: JSX.Element }) { - const [state, setState] = createStore({ stuck: false }) - let button: HTMLDivElement | undefined - - createEffect(() => { - 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}
) @@ -70,10 +47,11 @@ export function StickyAddButton(props: { children: JSX.Element }) { export function SessionReviewTab(props: SessionReviewTabProps) { let scroll: HTMLDivElement | undefined - let frame: number | undefined - let pending: { x: number; y: number } | undefined + let restoreFrame: number | undefined + let userInteracted = false const sdk = useSDK() + const layout = useLayout() const readFile = async (path: string) => { return sdk.client.file @@ -85,48 +63,81 @@ export function SessionReviewTab(props: SessionReviewTabProps) { }) } - const restoreScroll = () => { + const handleInteraction = () => { + userInteracted = true + } + + const doRestore = () => { + restoreFrame = undefined const el = scroll - if (!el) return + if (!el || !layout.ready() || userInteracted) return + if (el.clientHeight === 0 || el.clientWidth === 0) return const s = props.view().scroll("review") - if (!s) return + if (!s || (s.x === 0 && s.y === 0)) return + + const maxY = Math.max(0, el.scrollHeight - el.clientHeight) + const maxX = Math.max(0, el.scrollWidth - el.clientWidth) + + const targetY = Math.min(s.y, maxY) + const targetX = Math.min(s.x, maxX) - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x + if (el.scrollTop !== targetY) el.scrollTop = targetY + if (el.scrollLeft !== targetX) el.scrollLeft = targetX } - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - } - if (frame !== undefined) return + const queueRestore = () => { + if (userInteracted || restoreFrame !== undefined) return + restoreFrame = requestAnimationFrame(doRestore) + } - frame = requestAnimationFrame(() => { - frame = undefined + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (!layout.ready() || !userInteracted) return - const next = pending - pending = undefined - if (!next) return + const el = event.currentTarget + if (el.clientHeight === 0 || el.clientWidth === 0) return - props.view().setScroll("review", next) + props.view().setScroll("review", { + x: el.scrollLeft, + y: el.scrollTop, }) } createEffect( on( () => props.diffs().length, - () => { - requestAnimationFrame(restoreScroll) + () => queueRestore(), + { defer: true }, + ), + ) + + createEffect( + on( + () => props.diffStyle, + () => queueRestore(), + { defer: true }, + ), + ) + + createEffect( + on( + () => layout.ready(), + (ready) => { + if (!ready) return + queueRestore() }, { defer: true }, ), ) onCleanup(() => { - if (frame === undefined) return - cancelAnimationFrame(frame) + if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) + if (scroll) { + scroll.removeEventListener("wheel", handleInteraction) + scroll.removeEventListener("pointerdown", handleInteraction) + scroll.removeEventListener("touchstart", handleInteraction) + scroll.removeEventListener("keydown", handleInteraction) + } }) return ( @@ -135,11 +146,15 @@ export function SessionReviewTab(props: SessionReviewTabProps) { empty={props.empty} scrollRef={(el) => { scroll = el + el.addEventListener("wheel", handleInteraction, { passive: true, capture: true }) + el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true }) + el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true }) + el.addEventListener("keydown", handleInteraction, { passive: true, capture: true }) props.onScrollRef?.(el) - restoreScroll() + queueRestore() }} onScroll={handleScroll} - onDiffRendered={() => requestAnimationFrame(restoreScroll)} + onDiffRendered={queueRestore} open={props.view().review.open()} onOpenChange={props.view().review.setOpen} classes={{ @@ -154,6 +169,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) { focusedFile={props.focusedFile} readFile={readFile} onLineComment={props.onLineComment} + onLineCommentUpdate={props.onLineCommentUpdate} + onLineCommentDelete={props.onLineCommentDelete} + lineCommentActions={props.lineCommentActions} comments={props.comments} focusedComment={props.focusedComment} onFocusedCommentChange={props.onFocusedCommentChange} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 07b18f3146d..ad802d15d18 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -4,7 +4,7 @@ import { createMediaQuery } from "@solid-primitives/media" import { useParams } from "@solidjs/router" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" @@ -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] ?? []) : [])) @@ -145,8 +145,17 @@ export function SessionSidePanel(props: { const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, + fileTreeScrolled: false, }) + let changesEl: HTMLDivElement | undefined + let allEl: HTMLDivElement | undefined + + const syncFileTreeScrolled = (el?: HTMLDivElement) => { + const next = (el?.scrollTop ?? 0) > 0 + setStore("fileTreeScrolled", (current) => (current === next ? current : next)) + } + const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -167,6 +176,11 @@ export function SessionSidePanel(props: { setStore("activeDraggable", undefined) } + createEffect(() => { + if (!layout.fileTree.opened()) return + syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl) + }) + createEffect(() => { if (!file.ready()) return @@ -202,133 +216,128 @@ 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) => } +
+ ) + }} +
+
+
@@ -345,7 +354,7 @@ export function SessionSidePanel(props: { class="h-full" data-scope="filetree" > - + {reviewCount()}{" "} {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} @@ -354,7 +363,12 @@ export function SessionSidePanel(props: { {language.t("session.files.all")} - + (changesEl = el)} + onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)} + class="bg-background-stronger px-3 py-0" + > - + (allEl = el)} + onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)} + class="bg-background-stronger px-3 py-0" + > { + 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/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index eb830e4a643..ada543b7dc0 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionReview } from "@opencode-ai/ui/session-review" import { DataProvider } from "@opencode-ai/ui/context" -import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" -import { CodeComponentProvider } from "@opencode-ai/ui/context/code" +import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool" import { createAsync, query, useParams } from "@solidjs/router" import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js" @@ -22,14 +21,12 @@ import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" -import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr" +import { FileSSR } from "@opencode-ai/ui/file-ssr" import { clientOnly } from "@solidjs/start" import { type IconName } from "@opencode-ai/ui/icons/provider" import { Meta, Title } from "@solidjs/meta" import { Base64 } from "js-base64" -const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff }))) -const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code }))) const ClientOnlyWorkerPoolProvider = clientOnly(() => import("@opencode-ai/ui/pierre/worker").then((m) => ({ default: (props: { children: any }) => ( @@ -218,252 +215,244 @@ export default function () { - - - - {iife(() => { - const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - }) - const messages = createMemo(() => - data().sessionID - ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( - (a, b) => a.time.created - b.time.created, - ) - : [], - ) - const firstUserMessage = createMemo(() => messages().at(0)) - const activeMessage = createMemo( - () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), - ) - function setActiveMessage(message: UserMessage | undefined) { - if (message) { - setStore("messageId", message.id) - } else { - setStore("messageId", undefined) - } + + + {iife(() => { + const [store, setStore] = createStore({ + messageId: undefined as string | undefined, + }) + const messages = createMemo(() => + data().sessionID + ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( + (a, b) => a.time.created - b.time.created, + ) + : [], + ) + const firstUserMessage = createMemo(() => messages().at(0)) + const activeMessage = createMemo( + () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), + ) + function setActiveMessage(message: UserMessage | undefined) { + if (message) { + setStore("messageId", message.id) + } else { + setStore("messageId", undefined) } - const provider = createMemo(() => activeMessage()?.model?.providerID) - const modelID = createMemo(() => activeMessage()?.model?.modelID) - const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) - const splitDiffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) + } + const provider = createMemo(() => activeMessage()?.model?.providerID) + const modelID = createMemo(() => activeMessage()?.model?.modelID) + const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) + const diffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) + const splitDiffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) - const title = () => ( -
-
-
- -
v{info().version}
+ const title = () => ( +
+
+
+ +
v{info().version}
+
+
+
+ +
{model()?.name ?? modelID()}
-
-
- -
{model()?.name ?? modelID()}
-
-
- {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} -
+
+ {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
-
{info().title}
- ) +
{info().title}
+
+ ) - const turns = () => ( -
-
{title()}
-
- - {(message) => ( - - )} - -
-
- -
+ const turns = () => ( +
+
{title()}
+
+ + {(message) => ( + + )} +
- ) +
+ +
+
+ ) - const wide = createMemo(() => diffs().length === 0) + const wide = createMemo(() => diffs().length === 0) - return ( -
-
-
- - - -
-
- - -
-
-
+ return ( +
+
+
+ + + +
+
+ + +
+
+
+
-
+
+ 1}> + + + - {title()} -
-
- 1}> - - - -
- -
-
-
+
+ +
+
- 0}> - -
+
+ 0}> +
+ +
+
+
+ + 0}> + + + + Session + + + {diffs().length} Files Changed + + + + {turns()} + +
- - 0}> - - - - Session - - - {diffs().length} Files Changed - - - - {turns()} - - - - - -
- {turns()} -
-
-
-
+ + + + +
+ {turns()} +
+
+
- ) - })} - - - +
+ ) + })} + + ) 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/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 269ed7ae0bd..9e6910ba67f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -18,7 +18,7 @@ import type { ProviderAuthMethod, VcsInfo, } from "@opencode-ai/sdk/v2" -import { createStore, produce, reconcile } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" @@ -134,7 +134,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } const match = Binary.search(requests, request.id, (r) => r.id) if (match.found) { - setStore("permission", request.sessionID, match.index, reconcile(request)) + setStore("permission", request.sessionID, match.index, request) break } setStore( @@ -172,7 +172,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } const match = Binary.search(requests, request.id, (r) => r.id) if (match.found) { - setStore("question", request.sessionID, match.index, reconcile(request)) + setStore("question", request.sessionID, match.index, request) break } setStore( @@ -208,7 +208,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) + setStore("session", result.index, event.properties.info) break } setStore( @@ -233,7 +233,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } 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)) + setStore("message", event.properties.info.sessionID, result.index, event.properties.info) break } setStore( @@ -286,7 +286,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } 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)) + setStore("part", event.properties.part.messageID, result.index, event.properties.part) break } setStore( @@ -307,12 +307,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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 - }), + result.index, + event.properties.field as any, + (prev: string) => (prev ?? "") + event.properties.delta, ) break } @@ -388,12 +385,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sessions = responses[4] batch(() => { - setStore("provider", reconcile(providers.providers)) - setStore("provider_default", reconcile(providers.default)) - setStore("provider_next", reconcile(providerList)) - setStore("agent", reconcile(agents)) - setStore("config", reconcile(config)) - if (sessions !== undefined) setStore("session", reconcile(sessions)) + setStore("provider", providers.providers) + setStore("provider_default", providers.default) + setStore("provider_next", providerList) + setStore("agent", agents) + setStore("config", config) + if (sessions !== undefined) setStore("session", sessions) }) }) }) @@ -401,18 +398,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (store.status !== "complete") setStore("status", "partial") // non-blocking Promise.all([ - ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), - sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), - sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), - sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), - sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), - sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), + ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", sessions))]), + sdk.client.command.list().then((x) => setStore("command", x.data ?? [])), + sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)), + sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), + sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", x.data ?? {})), + sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), sdk.client.session.status().then((x) => { - setStore("session_status", reconcile(x.data!)) + setStore("session_status", x.data!) }), - sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), - sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), + sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})), + sdk.client.vcs.get().then((x) => setStore("vcs", x.data)), + sdk.client.path.get().then((x) => setStore("path", x.data!)), ]).then(() => { setStore("status", "complete") }) 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/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/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..f29aac18d16 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<{ 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/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 07e86ea97b6..44858a0ed27 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -98,7 +98,7 @@ describe("pty", () => { }) }) - test("does not leak output when socket data mutates in-place", async () => { + test("treats in-place socket data mutation as the same connection", async () => { await using dir = await tmpdir({ git: true }) await Instance.provide({ @@ -106,15 +106,14 @@ describe("pty", () => { fn: async () => { const a = await Pty.create({ command: "cat", title: "a" }) try { - const outA: string[] = [] - const outB: string[] = [] + const out: string[] = [] const ctx = { connId: 1 } const ws = { readyState: 1, data: ctx, send: (data: unknown) => { - outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) }, close: () => { // no-op @@ -122,19 +121,16 @@ describe("pty", () => { } Pty.connect(a.id, ws as any) - outA.length = 0 + out.length = 0 - // Simulate the runtime mutating per-connection data without - // swapping the reference (ws.data stays the same object). + // Mutating fields on ws.data should not look like a new + // connection lifecycle when the object identity stays stable. ctx.connId = 2 - ws.send = (data: unknown) => { - outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - } Pty.write(a.id, "AAA\n") await Bun.sleep(100) - expect(outB.join("")).not.toContain("AAA") + expect(out.join("")).toContain("AAA") } finally { await Pty.remove(a.id) } 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.css b/packages/ui/src/components/code.css deleted file mode 100644 index 671b40512de..00000000000 --- a/packages/ui/src/components/code.css +++ /dev/null @@ -1,4 +0,0 @@ -[data-component="code"] { - content-visibility: auto; - overflow: hidden; -} 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/code.tsx b/packages/ui/src/components/code.tsx deleted file mode 100644 index 837cc533764..00000000000 --- a/packages/ui/src/components/code.tsx +++ /dev/null @@ -1,1097 +0,0 @@ -import { - DEFAULT_VIRTUAL_FILE_METRICS, - type FileContents, - File, - FileOptions, - LineAnnotation, - type SelectedLineRange, - type VirtualFileMetrics, - VirtualizedFile, - Virtualizer, -} from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" -import { Portal } from "solid-js/web" -import { createDefaultOptions, styleVariables } from "../pierre" -import { getWorkerPool } from "../pierre/worker" -import { Icon } from "./icon" - -const VIRTUALIZE_BYTES = 500_000 -const codeMetrics = { - ...DEFAULT_VIRTUAL_FILE_METRICS, - lineHeight: 24, - fileGap: 0, -} satisfies Partial - -type SelectionSide = "additions" | "deletions" - -export type CodeProps = FileOptions & { - file: FileContents - annotations?: LineAnnotation[] - selectedLines?: SelectedLineRange | null - commentedLines?: SelectedLineRange[] - onRendered?: () => void - onLineSelectionEnd?: (selection: SelectedLineRange | null) => void - class?: string - classList?: ComponentProps<"div">["classList"] -} - -function findElement(node: Node | null): HTMLElement | undefined { - if (!node) return - if (node instanceof HTMLElement) return node - return node.parentElement ?? undefined -} - -function findLineNumber(node: Node | null): number | undefined { - const element = findElement(node) - if (!element) return - - const line = element.closest("[data-line]") - if (!(line instanceof HTMLElement)) return - - const value = parseInt(line.dataset.line ?? "", 10) - if (Number.isNaN(value)) return - - return value -} - -function findSide(node: Node | null): SelectionSide | undefined { - const element = findElement(node) - if (!element) return - - const code = element.closest("[data-code]") - if (!(code instanceof HTMLElement)) return - - if (code.hasAttribute("data-deletions")) return "deletions" - return "additions" -} - -type FindHost = { - element: () => HTMLElement | undefined - open: () => void - close: () => void - next: (dir: 1 | -1) => void - isOpen: () => boolean -} - -const findHosts = new Set() -let findTarget: FindHost | undefined -let findCurrent: FindHost | undefined -let findInstalled = false - -function isEditable(node: unknown): boolean { - if (!(node instanceof HTMLElement)) return false - if (node.closest("[data-prevent-autofocus]")) return true - if (node.isContentEditable) return true - return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName) -} - -function hostForNode(node: unknown): FindHost | undefined { - if (!(node instanceof Node)) return - for (const host of findHosts) { - const el = host.element() - if (el && el.isConnected && el.contains(node)) return host - } -} - -function installFindShortcuts() { - if (findInstalled) return - if (typeof window === "undefined") return - findInstalled = true - - window.addEventListener( - "keydown", - (event) => { - if (event.defaultPrevented) return - - const mod = event.metaKey || event.ctrlKey - if (!mod) return - - const key = event.key.toLowerCase() - - if (key === "g") { - const host = findCurrent - if (!host || !host.isOpen()) return - event.preventDefault() - event.stopPropagation() - host.next(event.shiftKey ? -1 : 1) - return - } - - if (key !== "f") return - - const current = findCurrent - if (current && current.isOpen()) { - event.preventDefault() - event.stopPropagation() - current.open() - return - } - - const host = - hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0] - if (!host) return - - event.preventDefault() - event.stopPropagation() - host.open() - }, - { capture: true }, - ) -} - -export function Code(props: CodeProps) { - let wrapper!: HTMLDivElement - let container!: HTMLDivElement - let findInput: HTMLInputElement | undefined - let findOverlay!: HTMLDivElement - let findOverlayFrame: number | undefined - let findOverlayScroll: HTMLElement[] = [] - let observer: MutationObserver | undefined - let renderToken = 0 - let selectionFrame: number | undefined - let dragFrame: number | undefined - let dragStart: number | undefined - let dragEnd: number | undefined - let dragMoved = false - let lastSelection: SelectedLineRange | null = null - let pendingSelectionEnd = false - - const [local, others] = splitProps(props, [ - "file", - "class", - "classList", - "annotations", - "selectedLines", - "commentedLines", - "onRendered", - ]) - - const [rendered, setRendered] = createSignal(0) - - const [findOpen, setFindOpen] = createSignal(false) - const [findQuery, setFindQuery] = createSignal("") - const [findIndex, setFindIndex] = createSignal(0) - const [findCount, setFindCount] = createSignal(0) - let findMode: "highlights" | "overlay" = "overlay" - let findHits: Range[] = [] - - const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 }) - - let instance: File | VirtualizedFile | undefined - let virtualizer: Virtualizer | undefined - let virtualRoot: Document | HTMLElement | undefined - - const bytes = createMemo(() => { - const value = local.file.contents as unknown - if (typeof value === "string") return value.length - if (Array.isArray(value)) { - return value.reduce( - (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1), - 0, - ) - } - if (value == null) return 0 - return String(value).length - }) - const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES) - - const options = createMemo(() => ({ - ...createDefaultOptions("unified"), - ...others, - })) - - const getRoot = () => { - const host = container.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const root = host.shadowRoot - if (!root) return - - return root - } - - const applyScheme = () => { - const host = container.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const scheme = document.documentElement.dataset.colorScheme - if (scheme === "dark" || scheme === "light") { - host.dataset.colorScheme = scheme - return - } - - host.removeAttribute("data-color-scheme") - } - - const supportsHighlights = () => { - const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown } - return typeof g.Highlight === "function" && g.CSS?.highlights != null - } - - const clearHighlightFind = () => { - const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights - if (!api) return - api.delete("opencode-find") - api.delete("opencode-find-current") - } - - const clearOverlayScroll = () => { - for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay) - findOverlayScroll = [] - } - - const clearOverlay = () => { - if (findOverlayFrame !== undefined) { - cancelAnimationFrame(findOverlayFrame) - findOverlayFrame = undefined - } - findOverlay.innerHTML = "" - } - - const renderOverlay = () => { - if (findMode !== "overlay") { - clearOverlay() - return - } - - clearOverlay() - if (findHits.length === 0) return - - const base = wrapper.getBoundingClientRect() - const current = findIndex() - - const frag = document.createDocumentFragment() - for (let i = 0; i < findHits.length; i++) { - const range = findHits[i] - const active = i === current - - for (const rect of Array.from(range.getClientRects())) { - if (!rect.width || !rect.height) continue - - const el = document.createElement("div") - el.style.position = "absolute" - el.style.left = `${Math.round(rect.left - base.left)}px` - el.style.top = `${Math.round(rect.top - base.top)}px` - el.style.width = `${Math.round(rect.width)}px` - el.style.height = `${Math.round(rect.height)}px` - el.style.borderRadius = "2px" - el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)" - el.style.opacity = active ? "0.55" : "0.35" - if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)" - frag.appendChild(el) - } - } - - findOverlay.appendChild(frag) - } - - function scheduleOverlay() { - if (findMode !== "overlay") return - if (!findOpen()) return - if (findOverlayFrame !== undefined) return - - findOverlayFrame = requestAnimationFrame(() => { - findOverlayFrame = undefined - renderOverlay() - }) - } - - const syncOverlayScroll = () => { - if (findMode !== "overlay") return - const root = getRoot() - - const next = root - ? Array.from(root.querySelectorAll("[data-code]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - : [] - if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return - - clearOverlayScroll() - findOverlayScroll = next - for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true }) - } - - const clearFind = () => { - clearHighlightFind() - clearOverlay() - clearOverlayScroll() - findHits = [] - setFindCount(0) - setFindIndex(0) - } - - const getScrollParent = (el: HTMLElement): HTMLElement | undefined => { - let parent = el.parentElement - while (parent) { - const style = getComputedStyle(parent) - if (style.overflowY === "auto" || style.overflowY === "scroll") return parent - parent = parent.parentElement - } - } - - const positionFindBar = () => { - if (typeof window === "undefined") return - - const root = getScrollParent(wrapper) ?? wrapper - const rect = root.getBoundingClientRect() - const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) - const header = Number.isNaN(title) ? 0 : title - setFindPos({ - top: Math.round(rect.top) + header - 4, - right: Math.round(window.innerWidth - rect.right) + 8, - }) - } - - const scanFind = (root: ShadowRoot, query: string) => { - const needle = query.toLowerCase() - const out: Range[] = [] - - const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - for (const col of cols) { - const text = col.textContent - if (!text) continue - - const hay = text.toLowerCase() - let idx = hay.indexOf(needle) - if (idx === -1) continue - - const nodes: Text[] = [] - const ends: number[] = [] - const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT) - let node = walker.nextNode() - let pos = 0 - - while (node) { - if (node instanceof Text) { - pos += node.data.length - nodes.push(node) - ends.push(pos) - } - node = walker.nextNode() - } - - if (nodes.length === 0) continue - - const locate = (at: number) => { - let lo = 0 - let hi = ends.length - 1 - while (lo < hi) { - const mid = (lo + hi) >> 1 - if (ends[mid] >= at) hi = mid - else lo = mid + 1 - } - const prev = lo === 0 ? 0 : ends[lo - 1] - return { node: nodes[lo], offset: at - prev } - } - - while (idx !== -1) { - const start = locate(idx) - const end = locate(idx + query.length) - const range = document.createRange() - range.setStart(start.node, start.offset) - range.setEnd(end.node, end.offset) - out.push(range) - idx = hay.indexOf(needle, idx + query.length) - } - } - - return out - } - - const scrollToRange = (range: Range) => { - const start = range.startContainer - const el = start instanceof Element ? start : start.parentElement - el?.scrollIntoView({ block: "center", inline: "center" }) - } - - const setHighlights = (ranges: Range[], index: number) => { - const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights - const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight - if (!api || typeof Highlight !== "function") return false - - api.delete("opencode-find") - api.delete("opencode-find-current") - - const active = ranges[index] - if (active) api.set("opencode-find-current", new Highlight(active)) - - const rest = ranges.filter((_, i) => i !== index) - if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) - return true - } - - const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => { - if (!findOpen()) return - - const query = findQuery().trim() - if (!query) { - clearFind() - return - } - - const root = getRoot() - if (!root) return - - findMode = supportsHighlights() ? "highlights" : "overlay" - - const ranges = scanFind(root, query) - const total = ranges.length - const desired = opts?.reset ? 0 : findIndex() - const index = total ? Math.min(desired, total - 1) : 0 - - findHits = ranges - setFindCount(total) - setFindIndex(index) - - const active = ranges[index] - if (findMode === "highlights") { - clearOverlay() - clearOverlayScroll() - if (!setHighlights(ranges, index)) { - findMode = "overlay" - clearHighlightFind() - syncOverlayScroll() - scheduleOverlay() - } - if (opts?.scroll && active) { - scrollToRange(active) - } - return - } - - clearHighlightFind() - syncOverlayScroll() - if (opts?.scroll && active) { - scrollToRange(active) - } - scheduleOverlay() - } - - const closeFind = () => { - setFindOpen(false) - clearFind() - if (findCurrent === host) findCurrent = undefined - } - - const stepFind = (dir: 1 | -1) => { - if (!findOpen()) return - const total = findCount() - if (total <= 0) return - - const index = (findIndex() + dir + total) % total - setFindIndex(index) - - const active = findHits[index] - if (!active) return - - if (findMode === "highlights") { - if (!setHighlights(findHits, index)) { - findMode = "overlay" - applyFind({ reset: true, scroll: true }) - return - } - scrollToRange(active) - return - } - - clearHighlightFind() - syncOverlayScroll() - scrollToRange(active) - scheduleOverlay() - } - - const host: FindHost = { - element: () => wrapper, - isOpen: () => findOpen(), - next: stepFind, - open: () => { - if (findCurrent && findCurrent !== host) findCurrent.close() - findCurrent = host - findTarget = host - - if (!findOpen()) setFindOpen(true) - requestAnimationFrame(() => { - applyFind({ scroll: true }) - findInput?.focus() - findInput?.select() - }) - }, - close: closeFind, - } - - onMount(() => { - findMode = supportsHighlights() ? "highlights" : "overlay" - installFindShortcuts() - findHosts.add(host) - if (!findTarget) findTarget = host - - onCleanup(() => { - findHosts.delete(host) - if (findCurrent === host) { - findCurrent = undefined - clearHighlightFind() - } - if (findTarget === host) findTarget = undefined - }) - }) - - createEffect(() => { - if (!findOpen()) return - - const update = () => positionFindBar() - requestAnimationFrame(update) - window.addEventListener("resize", update, { passive: true }) - - const root = getScrollParent(wrapper) ?? wrapper - const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update()) - observer?.observe(root) - - onCleanup(() => { - window.removeEventListener("resize", update) - observer?.disconnect() - }) - }) - - const applyCommentedLines = (ranges: SelectedLineRange[]) => { - const root = getRoot() - if (!root) return - - const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) - for (const node of existing) { - if (!(node instanceof HTMLElement)) continue - node.removeAttribute("data-comment-selected") - } - - const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - for (const range of ranges) { - const start = Math.max(1, Math.min(range.start, range.end)) - const end = Math.max(range.start, range.end) - - for (let line = start; line <= end; line++) { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`)) - for (const node of nodes) { - if (!(node instanceof HTMLElement)) continue - node.setAttribute("data-comment-selected", "") - } - } - - for (const annotation of annotations) { - const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) - if (Number.isNaN(line)) continue - if (line < start || line > end) continue - annotation.setAttribute("data-comment-selected", "") - } - } - } - - const text = () => { - const value = local.file.contents as unknown - if (typeof value === "string") return value - if (Array.isArray(value)) return value.join("\n") - if (value == null) return "" - return String(value) - } - - const lineCount = () => { - const value = text() - const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0) - return Math.max(1, total) - } - - const applySelection = (range: SelectedLineRange | null) => { - const current = instance - if (!current) return false - - if (virtual()) { - current.setSelectedLines(range) - return true - } - - const root = getRoot() - if (!root) return false - - const lines = lineCount() - if (root.querySelectorAll("[data-line]").length < lines) return false - - if (!range) { - current.setSelectedLines(null) - return true - } - - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - - if (start < 1 || end > lines) { - current.setSelectedLines(null) - return true - } - - if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) { - current.setSelectedLines(null) - return true - } - - const normalized = (() => { - if (range.endSide != null) return { start: range.start, end: range.end } - if (range.side !== "deletions") return range - if (root.querySelector("[data-deletions]") != null) return range - return { start: range.start, end: range.end } - })() - - current.setSelectedLines(normalized) - return true - } - - const notifyRendered = () => { - observer?.disconnect() - observer = undefined - renderToken++ - - const token = renderToken - - const lines = virtual() ? undefined : lineCount() - - const isReady = (root: ShadowRoot) => - virtual() - ? root.querySelector("[data-line]") != null - : root.querySelectorAll("[data-line]").length >= (lines ?? 0) - - const notify = () => { - if (token !== renderToken) return - - observer?.disconnect() - observer = undefined - requestAnimationFrame(() => { - if (token !== renderToken) return - applySelection(lastSelection) - applyFind({ reset: true }) - local.onRendered?.() - }) - } - - const root = getRoot() - if (root && isReady(root)) { - notify() - return - } - - if (typeof MutationObserver === "undefined") return - - const observeRoot = (root: ShadowRoot) => { - if (isReady(root)) { - notify() - return - } - - observer?.disconnect() - observer = new MutationObserver(() => { - if (token !== renderToken) return - if (!isReady(root)) return - - notify() - }) - - observer.observe(root, { childList: true, subtree: true }) - } - - if (root) { - observeRoot(root) - return - } - - observer = new MutationObserver(() => { - if (token !== renderToken) return - - const root = getRoot() - if (!root) return - - observeRoot(root) - }) - - observer.observe(container, { childList: true, subtree: true }) - } - - const updateSelection = () => { - const root = getRoot() - if (!root) return - - const selection = - (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() - if (!selection || selection.isCollapsed) return - - const domRange = - ( - selection as unknown as { - getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] - } - ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? - (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) - - const startNode = domRange?.startContainer ?? selection.anchorNode - const endNode = domRange?.endContainer ?? selection.focusNode - if (!startNode || !endNode) return - - if (!root.contains(startNode) || !root.contains(endNode)) return - - const start = findLineNumber(startNode) - const end = findLineNumber(endNode) - if (start === undefined || end === undefined) return - - const startSide = findSide(startNode) - const endSide = findSide(endNode) - const side = startSide ?? endSide - - const selected: SelectedLineRange = { - start, - end, - } - - if (side) selected.side = side - if (endSide && side && endSide !== side) selected.endSide = endSide - - setSelectedLines(selected) - } - - const setSelectedLines = (range: SelectedLineRange | null) => { - lastSelection = range - applySelection(range) - } - - const scheduleSelectionUpdate = () => { - if (selectionFrame !== undefined) return - - selectionFrame = requestAnimationFrame(() => { - selectionFrame = undefined - updateSelection() - - if (!pendingSelectionEnd) return - pendingSelectionEnd = false - props.onLineSelectionEnd?.(lastSelection) - }) - } - - const updateDragSelection = () => { - if (dragStart === undefined || dragEnd === undefined) return - - const start = Math.min(dragStart, dragEnd) - const end = Math.max(dragStart, dragEnd) - - setSelectedLines({ start, end }) - } - - const scheduleDragUpdate = () => { - if (dragFrame !== undefined) return - - dragFrame = requestAnimationFrame(() => { - dragFrame = undefined - updateDragSelection() - }) - } - - const lineFromMouseEvent = (event: MouseEvent) => { - const path = event.composedPath() - - let numberColumn = false - let line: number | undefined - - for (const item of path) { - if (!(item instanceof HTMLElement)) continue - - numberColumn = numberColumn || item.dataset.columnNumber != null - - if (line === undefined && item.dataset.line) { - const parsed = parseInt(item.dataset.line, 10) - if (!Number.isNaN(parsed)) line = parsed - } - - if (numberColumn && line !== undefined) break - } - - return { line, numberColumn } - } - - const handleMouseDown = (event: MouseEvent) => { - if (props.enableLineSelection !== true) return - if (event.button !== 0) return - - const { line, numberColumn } = lineFromMouseEvent(event) - if (numberColumn) return - if (line === undefined) return - - dragStart = line - dragEnd = line - dragMoved = false - } - - const handleMouseMove = (event: MouseEvent) => { - if (props.enableLineSelection !== true) return - if (dragStart === undefined) return - - if ((event.buttons & 1) === 0) { - dragStart = undefined - dragEnd = undefined - dragMoved = false - return - } - - const { line } = lineFromMouseEvent(event) - if (line === undefined) return - - dragEnd = line - dragMoved = true - scheduleDragUpdate() - } - - const handleMouseUp = () => { - if (props.enableLineSelection !== true) return - if (dragStart === undefined) return - - if (!dragMoved) { - pendingSelectionEnd = false - const line = dragStart - setSelectedLines({ start: line, end: line }) - props.onLineSelectionEnd?.(lastSelection) - dragStart = undefined - dragEnd = undefined - dragMoved = false - return - } - - pendingSelectionEnd = true - scheduleDragUpdate() - scheduleSelectionUpdate() - - dragStart = undefined - dragEnd = undefined - dragMoved = false - } - - const handleSelectionChange = () => { - if (props.enableLineSelection !== true) return - if (dragStart === undefined) return - - const selection = window.getSelection() - if (!selection || selection.isCollapsed) return - - scheduleSelectionUpdate() - } - - createEffect(() => { - const opts = options() - const workerPool = getWorkerPool("unified") - const isVirtual = virtual() - - observer?.disconnect() - observer = undefined - - instance?.cleanUp() - instance = undefined - - if (!isVirtual && virtualizer) { - virtualizer.cleanUp() - virtualizer = undefined - virtualRoot = undefined - } - - const v = (() => { - if (!isVirtual) return - if (typeof document === "undefined") return - - const root = getScrollParent(wrapper) ?? document - if (virtualizer && virtualRoot === root) return virtualizer - - virtualizer?.cleanUp() - virtualizer = new Virtualizer() - virtualRoot = root - virtualizer.setup(root, root instanceof Document ? undefined : wrapper) - return virtualizer - })() - - instance = isVirtual && v ? new VirtualizedFile(opts, v, codeMetrics, workerPool) : new File(opts, workerPool) - - container.innerHTML = "" - const value = text() - instance.render({ - file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value }, - lineAnnotations: local.annotations, - containerWrapper: container, - }) - - applyScheme() - - setRendered((value) => value + 1) - notifyRendered() - }) - - createEffect(() => { - if (typeof document === "undefined") return - if (typeof MutationObserver === "undefined") return - - const root = document.documentElement - const monitor = new MutationObserver(() => applyScheme()) - monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] }) - applyScheme() - - onCleanup(() => monitor.disconnect()) - }) - - createEffect(() => { - rendered() - const ranges = local.commentedLines ?? [] - requestAnimationFrame(() => applyCommentedLines(ranges)) - }) - - createEffect(() => { - setSelectedLines(local.selectedLines ?? null) - }) - - createEffect(() => { - if (props.enableLineSelection !== true) return - - container.addEventListener("mousedown", handleMouseDown) - container.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - document.addEventListener("selectionchange", handleSelectionChange) - - onCleanup(() => { - container.removeEventListener("mousedown", handleMouseDown) - container.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - document.removeEventListener("selectionchange", handleSelectionChange) - }) - }) - - onCleanup(() => { - observer?.disconnect() - - instance?.cleanUp() - instance = undefined - - virtualizer?.cleanUp() - virtualizer = undefined - virtualRoot = undefined - - clearOverlayScroll() - clearOverlay() - if (findCurrent === host) { - findCurrent = undefined - clearHighlightFind() - } - - if (selectionFrame !== undefined) { - cancelAnimationFrame(selectionFrame) - selectionFrame = undefined - } - - if (dragFrame !== undefined) { - cancelAnimationFrame(dragFrame) - dragFrame = undefined - } - - dragStart = undefined - dragEnd = undefined - dragMoved = false - lastSelection = null - pendingSelectionEnd = false - }) - - const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => ( -
e.stopPropagation()}> - - { - setFindQuery(e.currentTarget.value) - setFindIndex(0) - applyFind({ reset: true, scroll: true }) - }} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault() - closeFind() - return - } - if (e.key !== "Enter") return - e.preventDefault() - stepFind(e.shiftKey ? -1 : 1) - }} - /> -
- {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} -
-
- - -
- -
- ) - - return ( -
{ - findTarget = host - wrapper.focus({ preventScroll: true }) - }} - onFocus={() => { - findTarget = host - }} - > - - - - - -
-
-
- ) -} 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-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx deleted file mode 100644 index e739afc16d8..00000000000 --- a/packages/ui/src/components/diff-ssr.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" -import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" -import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" -import { Dynamic, isServer } from "solid-js/web" -import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" -import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" -import { useWorkerPool } from "../context/worker-pool" - -export type SSRDiffProps = DiffProps & { - preloadedDiff: PreloadMultiFileDiffResult -} - -export function Diff(props: SSRDiffProps) { - let container!: HTMLDivElement - let fileDiffRef!: HTMLElement - const [local, others] = splitProps(props, [ - "before", - "after", - "class", - "classList", - "annotations", - "selectedLines", - "commentedLines", - ]) - const workerPool = useWorkerPool(props.diffStyle) - - let fileDiffInstance: FileDiff | undefined - let sharedVirtualizer: NonNullable> | undefined - const cleanupFunctions: Array<() => void> = [] - - const getRoot = () => fileDiffRef?.shadowRoot ?? undefined - - const getVirtualizer = () => { - if (sharedVirtualizer) return sharedVirtualizer.virtualizer - - const result = acquireVirtualizer(container) - if (!result) return - - sharedVirtualizer = result - return result.virtualizer - } - - const applyScheme = () => { - const scheme = document.documentElement.dataset.colorScheme - if (scheme === "dark" || scheme === "light") { - fileDiffRef.dataset.colorScheme = scheme - return - } - - fileDiffRef.removeAttribute("data-color-scheme") - } - - const lineIndex = (split: boolean, element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(split, node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node) - } - } - - const fixSelection = (range: SelectedLineRange | null) => { - if (!range) return range - const root = getRoot() - if (!root) return - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const start = rowIndex(root, split, range.start, range.side) - const end = rowIndex(root, split, range.end, range.endSide ?? range.side) - - if (start === undefined || end === undefined) { - if (root.querySelector("[data-line], [data-alt-line]") == null) return - return null - } - if (start <= end) return range - - const side = range.endSide ?? range.side - const swapped: SelectedLineRange = { - start: range.end, - end: range.start, - } - if (side) swapped.side = side - if (range.endSide && range.side) swapped.endSide = range.side - - return swapped - } - - const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => { - const diff = fileDiffInstance - if (!diff) return - - const fixed = fixSelection(range) - if (fixed === undefined) { - if (attempt >= 120) return - requestAnimationFrame(() => setSelectedLines(range, attempt + 1)) - return - } - - diff.setSelectedLines(fixed) - } - - const findSide = (element: HTMLElement): "additions" | "deletions" => { - const line = element.closest("[data-line], [data-alt-line]") - if (line instanceof HTMLElement) { - const type = line.dataset.lineType - if (type === "change-deletion") return "deletions" - if (type === "change-addition" || type === "change-additions") return "additions" - } - - const code = element.closest("[data-code]") - if (!(code instanceof HTMLElement)) return "additions" - return code.hasAttribute("data-deletions") ? "deletions" : "additions" - } - - const applyCommentedLines = (ranges: SelectedLineRange[]) => { - const root = getRoot() - if (!root) return - - const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) - for (const node of existing) { - if (!(node instanceof HTMLElement)) continue - node.removeAttribute("data-comment-selected") - } - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (rows.length === 0) return - - const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - const lineIndex = (element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node) - } - } - - for (const range of ranges) { - const start = rowIndex(range.start, range.side) - if (start === undefined) continue - - const end = (() => { - const same = range.end === range.start && (range.endSide == null || range.endSide === range.side) - if (same) return start - return rowIndex(range.end, range.endSide ?? range.side) - })() - if (end === undefined) continue - - const first = Math.min(start, end) - const last = Math.max(start, end) - - for (const row of rows) { - const idx = lineIndex(row) - if (idx === undefined) continue - if (idx < first || idx > last) continue - row.setAttribute("data-comment-selected", "") - } - - for (const annotation of annotations) { - const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) - if (Number.isNaN(idx)) continue - if (idx < first || idx > last) continue - annotation.setAttribute("data-comment-selected", "") - } - } - } - - onMount(() => { - if (isServer || !props.preloadedDiff) return - - applyScheme() - - if (typeof MutationObserver !== "undefined") { - const root = document.documentElement - const monitor = new MutationObserver(() => applyScheme()) - monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] }) - onCleanup(() => monitor.disconnect()) - } - - const virtualizer = getVirtualizer() - - fileDiffInstance = virtualizer - ? new VirtualizedFileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - virtualizer, - virtualMetrics, - workerPool, - ) - : new FileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - workerPool, - ) - // @ts-expect-error - fileContainer is private but needed for SSR hydration - fileDiffInstance.fileContainer = fileDiffRef - fileDiffInstance.hydrate({ - oldFile: local.before, - newFile: local.after, - lineAnnotations: local.annotations, - fileContainer: fileDiffRef, - containerWrapper: container, - }) - - setSelectedLines(local.selectedLines ?? null) - - createEffect(() => { - fileDiffInstance?.setLineAnnotations(local.annotations ?? []) - }) - - createEffect(() => { - setSelectedLines(local.selectedLines ?? null) - }) - - createEffect(() => { - const ranges = local.commentedLines ?? [] - requestAnimationFrame(() => applyCommentedLines(ranges)) - }) - - // Hydrate annotation slots with interactive SolidJS components - // if (props.annotations.length > 0 && props.renderAnnotation != null) { - // for (const annotation of props.annotations) { - // const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`; - // const slotElement = fileDiffRef.querySelector( - // `[slot="${slotName}"]` - // ) as HTMLElement; - // - // if (slotElement != null) { - // // Clear the static server-rendered content from the slot - // slotElement.innerHTML = ''; - // - // // Mount a fresh SolidJS component into this slot using render(). - // // This enables full SolidJS reactivity (signals, effects, etc.) - // const dispose = render( - // () => props.renderAnnotation!(annotation), - // slotElement - // ); - // cleanupFunctions.push(dispose); - // } - // } - // } - }) - - onCleanup(() => { - // Clean up FileDiff event handlers and dispose SolidJS components - fileDiffInstance?.cleanUp() - cleanupFunctions.forEach((dispose) => dispose()) - sharedVirtualizer?.release() - sharedVirtualizer = undefined - }) - - return ( -
- - -