Compare commits

..

1 Commits

Author SHA1 Message Date
James Long 4778e6e031 fix(core): stream shell progress tail 2026-07-16 20:25:00 +00:00
358 changed files with 3332 additions and 8409 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot at the top of the session view.
-7
View File
@@ -1,7 +0,0 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
"@opencode-ai/protocol": patch
---
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot above the session composer.
-56
View File
@@ -121,55 +121,6 @@ jobs:
outputs:
version: ${{ needs.version.outputs.version }}
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
settings:
- target: linux-arm64
host: blacksmith-4vcpu-ubuntu-2404-arm
- target: linux-x64
host: blacksmith-4vcpu-ubuntu-2404
- target: darwin-arm64
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
with:
install-flags: --os=* --cpu=*
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- name: Verify service lifecycle
if: matrix.settings.target != 'windows-arm64'
working-directory: packages/cli
run: bun run script/service-smoke.ts --node
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-cli-${{ matrix.settings.target }}
path: packages/cli/dist/node/cli-node-*
if-no-files-found: error
sign-cli-windows:
needs:
- build-cli
@@ -462,7 +413,6 @@ jobs:
needs:
- version
- build-cli
- build-node-cli
- sign-cli-windows
- build-electron
if: always() && !failure() && !cancelled()
@@ -511,12 +461,6 @@ jobs:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
merge-multiple: true
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
with:
-14
View File
@@ -78,20 +78,6 @@ jobs:
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: always()
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: always()
timeout-minutes: 15
working-directory: packages/cli
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
-2
View File
@@ -11,7 +11,6 @@ node_modules
playground
tmp
dist
dist-node
ts-dist
.turbo
.typecheck-profiles
@@ -26,7 +25,6 @@ Session.vim
a.out
target
.scripts
.cache
.direnv/
# Local dev files
+62 -104
View File
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -119,7 +119,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.3",
"version": "1.18.2",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -145,28 +145,14 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
"ws": "8.21.0",
},
"devDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
},
"packages/client": {
@@ -185,7 +171,7 @@
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.98",
"effect": "4.0.0-beta.83",
},
"optionalPeers": [
"effect",
@@ -193,7 +179,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -207,7 +193,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -243,7 +229,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -270,7 +256,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -292,7 +278,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -316,7 +302,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -336,7 +322,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.3",
"version": "1.18.2",
"bin": {
"opencode": "./bin/opencode",
},
@@ -401,7 +387,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
@@ -433,7 +418,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -493,7 +478,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -507,7 +492,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"effect": "catalog:",
},
@@ -519,7 +504,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -551,7 +536,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -567,9 +552,9 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.98",
"@effect/platform-node-shared": "4.0.0-beta.83",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -599,7 +584,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.3",
"version": "1.18.2",
"bin": {
"opencode": "./bin/opencode",
},
@@ -732,7 +717,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
@@ -754,9 +739,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": ">=0.4.3",
"@opentui/keymap": ">=0.4.3",
"@opentui/solid": ">=0.4.3",
},
"optionalPeers": [
"@opentui/core",
@@ -821,7 +806,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -836,7 +821,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -853,7 +838,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -914,7 +899,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -927,7 +912,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -961,7 +946,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -980,7 +965,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -1021,7 +1006,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
@@ -1036,12 +1021,10 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"string-width": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
},
@@ -1053,7 +1036,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1104,7 +1087,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.3",
"version": "1.18.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1180,12 +1163,12 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1197,9 +1180,9 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1207,9 +1190,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1235,9 +1218,8 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.98",
"effect": "4.0.0-beta.83",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
@@ -1246,13 +1228,11 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.0",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"sst": "4.13.1",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
@@ -1639,13 +1619,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.98", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.98" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ITfK8xhcl+9GXOvPwzADWkOQ+dgUGZrJNefT3r2+uLFmzjyKRLtHzhLOl6lZaLSsf5io13+nmt8adfMRQPq+oA=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.98", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.98", "", { "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-cc41uLhYBqexdbTNu4dlui+31E8hcVLEapLySa0C8d60FmBY8IEAV/RD3oF+6pqPslKEZ9p1+XVLdDm0iflw5Q=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2293,27 +2273,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
"@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
"@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
"@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -4037,7 +4017,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="],
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -4213,7 +4193,7 @@
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
@@ -5077,7 +5057,7 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
@@ -5085,7 +5065,7 @@
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
@@ -5615,8 +5595,6 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
@@ -5985,7 +5963,7 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
"toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
@@ -6071,7 +6049,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@8.7.0", "", {}, "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ=="],
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -6153,7 +6131,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="],
@@ -8363,26 +8341,6 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"@standard-community/standard-json/effect/msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"@standard-community/standard-json/effect/multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
"@standard-community/standard-json/effect/toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
"@standard-community/standard-json/effect/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"@standard-community/standard-openapi/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"@standard-community/standard-openapi/effect/msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"@standard-community/standard-openapi/effect/multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
"@standard-community/standard-openapi/effect/toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
"@standard-community/standard-openapi/effect/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+8 -11
View File
@@ -37,18 +37,18 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
@@ -69,13 +69,12 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.98",
"effect": "4.0.0-beta.83",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
@@ -86,11 +85,9 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
@@ -166,7 +163,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status
Last reviewed: 2026-07-17
Last reviewed: 2026-07-16
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
@@ -20,7 +20,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. |
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
-12
View File
@@ -161,18 +161,6 @@ const PROVIDERS: ReadonlyArray<Provider> = [
vars: [{ name: "TOGETHER_AI_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
},
{
id: "minimax",
label: "MiniMax",
tier: "compatible",
note: "Anthropic-compatible Messages text/tool recorded tests",
vars: [{ name: "MINIMAX_API_KEY" }],
validate: (env) =>
HttpClientRequest.get("https://api.minimax.io/anthropic/v1/models").pipe(
HttpClientRequest.setHeader("x-api-key", Redacted.value(Redacted.make(env.MINIMAX_API_KEY))),
executeRequest,
),
},
{
id: "mistral",
label: "Mistral",
+6 -39
View File
@@ -75,8 +75,6 @@ const OpenAIChatMessage = Schema.Union([
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
@@ -147,8 +145,6 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
@@ -170,7 +166,6 @@ export interface ParserState {
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
}
// =============================================================================
@@ -213,12 +208,6 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
return "reasoning_content"
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
@@ -259,20 +248,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
}
const text = reasoning.map((part) => part.text).join("")
const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
return {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content:
reasoning.length === 0
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
: field === "reasoning_content"
? text
: undefined,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning.length > 0
? reasoning.map((part) => part.text).join("")
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
}
})
@@ -417,12 +400,6 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
})
}
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
}
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
const events: LLMEvent[] = []
@@ -435,12 +412,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta)
const reasoningField = state.reasoningField ?? reasoning?.field
if (reasoning)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
openai: { reasoningField: reasoningField ?? reasoning.field },
})
if (delta?.reasoning_content)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
@@ -477,7 +450,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
usage,
finishReason,
lifecycle,
reasoningField,
},
events,
] as const
@@ -510,12 +482,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({
tools: ToolStream.empty<number>(),
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningField: undefined,
}),
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
step,
onHalt: finishEvents,
},
@@ -1,40 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "minimax",
"protocol": "anthropic-messages",
"route": "anthropic-messages",
"transport": "http",
"model": "MiniMax-M3",
"tags": [
"prefix:anthropic-compatible-messages",
"provider:minimax",
"protocol:anthropic-messages",
"text",
"golden"
],
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-text",
"recordedAt": "2026-07-18T03:42:22.893Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"1a0b363d0882af316faebcec4d4855a8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":53,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":53,\"output_tokens\":2,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -1,41 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "minimax",
"protocol": "anthropic-messages",
"route": "anthropic-messages",
"transport": "http",
"model": "MiniMax-M3",
"tags": [
"prefix:anthropic-compatible-messages",
"provider:minimax",
"protocol:anthropic-messages",
"tool",
"tool-call",
"golden"
],
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-call",
"recordedAt": "2026-07-18T03:42:23.876Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6731ecc323233459d1792df9a733dd98\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":404,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_vkxtif4epmvm_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":290,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -1,60 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "minimax",
"protocol": "anthropic-messages",
"route": "anthropic-messages",
"transport": "http",
"model": "MiniMax-M3",
"tags": [
"prefix:anthropic-compatible-messages",
"provider:minimax",
"protocol:anthropic-messages",
"tool",
"tool-loop",
"golden"
],
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-loop",
"recordedAt": "2026-07-18T03:42:25.248Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"3807fa12f9ecb9357df511e099da6da0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":417,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":303,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.minimax.io/anthropic/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_function_yr64rwmre4gr_1\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"92f8a1e86f29946eb2699d40a088fc08\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":41,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":41,\"output_tokens\":4,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
@@ -1,34 +0,0 @@
{
"version": 1,
"metadata": {
"model": "anthropic/claude-sonnet-4.6",
"tags": [
"prefix:openai-compatible-chat",
"provider:openrouter",
"protocol:openai-chat",
"reasoning"
],
"name": "openrouter-reasoning",
"recordedAt": "2026-07-18T11:28:39.267Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
import * as Anthropic from "../../src/providers/anthropic"
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
import * as Google from "../../src/providers/google"
import * as OpenAI from "../../src/providers/openai"
@@ -18,11 +17,6 @@ const anthropic = Anthropic.configure({
})
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
const anthropicOpus = anthropic.model("claude-opus-4-7")
const minimax = AnthropicCompatible.configure({
apiKey: process.env.MINIMAX_API_KEY ?? "fixture",
baseURL: "https://api.minimax.io/anthropic/v1",
provider: "minimax",
}).model("MiniMax-M3")
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
const gemini = google.model("gemini-2.5-flash")
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
@@ -114,15 +108,6 @@ describeRecordedGoldenScenarios([
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
name: "MiniMax M3 Anthropic-compatible",
prefix: "anthropic-compatible-messages",
protocol: "anthropic-messages",
model: minimax,
requires: ["MINIMAX_API_KEY"],
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
scenarios: ["text", "tool-call", "tool-loop"],
},
{
name: "Gemini 2.5 Flash",
prefix: "gemini",
@@ -1,67 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter"
import { LLMClient } from "../../src/route"
import { recordedTests } from "../recorded-test"
const cases = [
{
name: "OpenRouter",
model: OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning",
},
{
name: "Vercel AI Gateway",
model: OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6"),
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning",
},
] as const
for (const item of cases) {
const recorded = recordedTests({
prefix: "openai-compatible-chat",
provider: item.model.provider,
protocol: "openai-chat",
requires: item.requires,
tags: ["reasoning"],
metadata: { model: item.model.id },
})
describe(`${item.name} reasoning recorded`, () => {
recorded.effect.with(
"streams scalar reasoning",
{ cassette: item.cassette },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: item.model,
system: "Think through the arithmetic, then reply with only the final integer.",
prompt: "What is 173 multiplied by 219?",
generation: { maxTokens: 1536, temperature: 0 },
}),
)
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning" },
})
}),
30_000,
)
})
}
+20 -24
View File
@@ -540,33 +540,29 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
for (const field of fields) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { [field]: "thinking" } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
),
),
),
)
const body = sseEvents(
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
)
expect(response.reasoning).toBe("thinking")
expect(response.text).toBe("Hello")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: field },
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({ model, messages: [response.message] }),
)
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
}
expect(response.reasoning).toBe("thinking")
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-end", id: "text-0" },
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
])
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.3",
"version": "1.18.2",
"description": "",
"type": "module",
"exports": {
+136 -90
View File
@@ -1,18 +1,17 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
@@ -25,8 +24,6 @@ export type CommandPaletteEntry = {
path?: string
directory?: string
sessionID?: string
server?: ServerConnection.Key
project?: LocalProject
archived?: number
updated?: number
}
@@ -78,25 +75,28 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const global = useGlobal()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverCtx = global.ensureServerCtx(serverSDK.server)
const appTabs = useTabs()
const { tabs: sessionTabs } = useSessionLayout()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return commandPaletteOptions(command.options)
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
return allowedCommands().map((option) => createCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
return sorted.map((option) => createCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs: sessionTabs,
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
@@ -140,12 +140,36 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
.map((path) => createCommandPaletteFileEntry(path, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -167,17 +191,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
return
}
if (item.type === "session") {
if (!item.sessionID || !item.server) return
const directory = item.project?.worktree ?? item.directory
if (directory) {
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
}
const tab = appTabs.addSessionTab({
server: item.server,
sessionId: item.sessionID,
})
appTabs.select(tab)
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
@@ -203,7 +218,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
}
}
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
@@ -215,65 +230,96 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
}
}
export function createServerSessionEntries(props: {
server: ServerConnection.Key
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
let abort: AbortController | undefined
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
onCleanup(() => abort?.abort())
return async (text: string): Promise<CommandPaletteEntry[]> => {
const search = text.trim()
if (!search) {
abort?.abort()
return []
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
}
abort?.abort()
const current = new AbortController()
abort = current
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 100)
current.signal.addEventListener(
"abort",
() => {
clearTimeout(timer)
resolve()
},
{ once: true },
)
})
if (current.signal.aborted) return []
const opened = props.opened()
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
return props
.load(search, current.signal)
.then((result) =>
(result.data ?? [])
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
category: props.category(),
directory: session.directory,
sessionID: session.id,
server: props.server,
project,
updated: session.time.updated,
}
}),
)
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -5,20 +5,13 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry,
createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
@@ -37,6 +30,9 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
@@ -45,105 +41,16 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions,
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
return (
<CommandPaletteView
placeholder={palette.language.t("palette.search.placeholder")}
loadItems={loadItems}
highlight={palette.highlight}
select={palette.select}
close={palette.close}
/>
)
}
export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
highlight={highlight}
select={select}
close={() => dialog.close()}
/>
)
}
function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
highlight: (item: CommandPaletteEntry | undefined) => void
select: (item: CommandPaletteEntry | undefined) => void
close: () => void
}) {
const language = useLanguage()
const tabs = useTabs()
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => {
query()
@@ -152,7 +59,7 @@ function CommandPaletteView(props: {
})
createEffect(() => {
props.highlight(activeEntry())
palette.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
@@ -179,12 +86,12 @@ function CommandPaletteView(props: {
}
if (event.key === "Enter") {
event.preventDefault()
props.select(activeEntry())
palette.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
props.close()
palette.close()
}
}
@@ -198,7 +105,7 @@ function CommandPaletteView(props: {
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={props.placeholder}
placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
@@ -210,7 +117,7 @@ function CommandPaletteView(props: {
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div>
}
>
@@ -225,14 +132,9 @@ function CommandPaletteView(props: {
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={language}
sessionOpen={
item.server && item.sessionID
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
language={palette.language}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => props.select(item)}
onSelect={() => palette.select(item)}
/>
)}
</For>
@@ -251,19 +153,13 @@ function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
sessionOpen: boolean
onActive: () => void
onSelect: () => void
}) {
const session = () =>
props.item.server && props.item.directory && props.item.sessionID
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
: undefined
return (
<button
type="button"
class="command-palette-v2-row group"
class="command-palette-v2-row"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
@@ -301,25 +197,7 @@ function PaletteRow(props: {
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<div class="relative shrink-0">
<Show when={props.sessionOpen}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<Show when={session()}>
{(session) => (
<SessionTabAvatar
project={props.item.project}
directory={session().directory}
sessionId={session().sessionID}
server={session().server}
/>
)}
</Show>
</div>
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
+41 -1
View File
@@ -8,9 +8,49 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
export function HelpButton() {
const platform = usePlatform()
return (
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
@@ -21,7 +61,7 @@ export function TabsInfoPopup() {
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
@@ -20,6 +20,7 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
+1 -15
View File
@@ -1,19 +1,5 @@
import { describe, expect, test } from "bun:test"
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" },
{ id: "session.undo", title: "Undo" },
{ id: "file.open", title: "Open file" },
{ id: "hidden", title: "Hidden", hidden: true },
{ id: "disabled", title: "Disabled", disabled: true },
]
describe("commandPaletteOptions", () => {
test("keeps visible enabled commands", () => {
expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
})
})
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
+2 -9
View File
@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
@@ -87,13 +87,6 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void
}
export function commandPaletteOptions(options: CommandOption[]) {
return options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
)
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
}
@@ -382,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const showPalette = () => {
run(PALETTE_ID, "palette")
run("file.open", "palette")
}
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1,29 +0,0 @@
import { describe, expect, test } from "bun:test"
import { hasCustomAgent, resolveAgent } from "./local-agent"
describe("hasCustomAgent", () => {
test("detects explicitly custom agents", () => {
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
})
test("ignores built-in and unclassified agents", () => {
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
})
})
describe("resolveAgent", () => {
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
test("uses the requested available agent", () => {
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
})
test("defaults to build", () => {
expect(resolveAgent(agents)?.name).toBe("build")
expect(resolveAgent(agents, "missing")?.name).toBe("build")
})
test("uses the first agent when build is unavailable", () => {
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
})
})
-7
View File
@@ -1,7 +0,0 @@
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
return items.some((item) => item.native === false)
}
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
}
+4 -7
View File
@@ -4,10 +4,8 @@ import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -64,11 +62,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
@@ -111,7 +107,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const pickAgent = (name: string | undefined) => {
return resolveAgent(list(), name)
const items = list()
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
}
createEffect(() => {
@@ -182,9 +180,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
return pickAgent(scope()?.agent ?? store.current)
},
set(name: string | undefined) {
const item = pickAgent(name)
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر",
"palette.group.files": "الملفات",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos",
"palette.group.files": "Arquivos",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
"palette.search.placeholder": "Pretraži datoteke, komande i sesije",
"palette.search.placeholder.home": "Pretraži komande i sesije",
"palette.empty": "Nema rezultata",
"palette.group.commands": "Komande",
"palette.group.files": "Datoteke",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Stop med at dele denne session",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.search.placeholder.home": "Søg i kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
-1
View File
@@ -90,7 +90,6 @@ export const dict = {
"command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden",
"palette.group.commands": "Befehle",
"palette.group.files": "Dateien",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
"palette.empty": "No results found",
"palette.group.commands": "Commands",
"palette.group.files": "Files",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Dejar de compartir esta sesión",
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
"palette.search.placeholder.home": "Buscar comandos y sesiones",
"palette.empty": "No se encontraron resultados",
"palette.group.commands": "Comandos",
"palette.group.files": "Archivos",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette session",
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
"palette.search.placeholder.home": "Rechercher des commandes et des sessions",
"palette.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes",
"palette.group.files": "Fichiers",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "セッションの共有を停止",
"command.session.unshare.description": "このセッションの共有を停止",
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
"palette.search.placeholder.home": "コマンドとセッションを検索",
"palette.empty": "結果が見つかりません",
"palette.group.commands": "コマンド",
"palette.group.files": "ファイル",
-1
View File
@@ -82,7 +82,6 @@ export const dict = {
"command.session.unshare": "세션 공유 중지",
"command.session.unshare.description": "이 세션 공유 중지",
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
"palette.search.placeholder.home": "명령어 및 세션 검색",
"palette.empty": "결과 없음",
"palette.group.commands": "명령어",
"palette.group.files": "파일",
-1
View File
@@ -92,7 +92,6 @@ export const dict = {
"command.session.unshare.description": "Slutt å dele denne sesjonen",
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
"palette.search.placeholder.home": "Søk i kommandoer og sesjoner",
"palette.empty": "Ingen resultater funnet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Przestań udostępniać sesję",
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
"palette.search.placeholder": "Szukaj plików, poleceń i sesji",
"palette.search.placeholder.home": "Szukaj poleceń i sesji",
"palette.empty": "Brak wyników",
"palette.group.commands": "Polecenia",
"palette.group.files": "Pliki",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Прекратить публикацию сессии",
"palette.search.placeholder": "Поиск файлов, команд и сессий",
"palette.search.placeholder.home": "Поиск команд и сессий",
"palette.empty": "Ничего не найдено",
"palette.group.commands": "Команды",
"palette.group.files": "Файлы",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
"palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน",
"palette.empty": "ไม่พบผลลัพธ์",
"palette.group.commands": "คำสั่ง",
"palette.group.files": "ไฟล์",
-1
View File
@@ -97,7 +97,6 @@ export const dict = {
"command.session.unshare.description": "Bu oturumun paylaşımını durdur",
"palette.search.placeholder": "Dosya, komut ve oturum ara",
"palette.search.placeholder.home": "Komut ve oturum ara",
"palette.empty": "Sonuç bulunamadı",
"palette.group.commands": "Komutlar",
"palette.group.files": "Dosyalar",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Припинити поширення цієї сесії",
"palette.search.placeholder": "Пошук файлів, команд і сесій",
"palette.search.placeholder.home": "Пошук команд і сесій",
"palette.empty": "Результатів не знайдено",
"palette.group.commands": "Команди",
"palette.group.files": "Файли",
-1
View File
@@ -120,7 +120,6 @@ export const dict = {
"command.session.unshare.description": "停止分享此会话",
"palette.search.placeholder": "搜索文件、命令和会话",
"palette.search.placeholder.home": "搜索命令和会话",
"palette.empty": "未找到结果",
"palette.group.commands": "命令",
"palette.group.files": "文件",
-1
View File
@@ -97,7 +97,6 @@ export const dict = {
"command.session.unshare.description": "停止分享此工作階段",
"palette.search.placeholder": "搜尋檔案、命令和工作階段",
"palette.search.placeholder.home": "搜尋命令和工作階段",
"palette.empty": "找不到結果",
"palette.group.commands": "命令",
"palette.group.files": "檔案",
+119 -212
View File
@@ -5,7 +5,6 @@ import {
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
on,
@@ -75,23 +74,6 @@ const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@@ -162,7 +144,6 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
@@ -221,13 +202,6 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((el) => el !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
@@ -243,7 +217,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
@@ -297,9 +271,6 @@ export function NewHome() {
const marked = useMarked()
const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({
search: "",
searchFocused: false,
@@ -431,34 +402,6 @@ export function NewHome() {
}
command.register("home", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const conn = focusedServer()
if (!conn) return
const ctx = global.ensureServerCtx(conn)
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => (
<DialogHomeCommandPaletteV2
server={conn}
onSelectSession={(entry) => {
if (!entry.sessionID || !entry.directory || !entry.server) return
const sessionID = entry.sessionID
const server = entry.server
const directory = entry.project?.worktree ?? entry.directory
ctx.projects.open(directory)
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
tabs.select(tab)
})
}}
/>
))
},
},
{
id: "home.sessions.search.focus",
title: searchPlaceholder(),
@@ -598,158 +541,127 @@ export function NewHome() {
}
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<ScrollView
class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack}
thumbHoverTarget={sessionHoverTarget}
viewportRef={(el) => {
sessionViewport = el
sessionHeaderOpacity.setViewport(el)
}}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
onWheel={(event) => {
if (!sessionViewport) return
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
containHomeWheel(event, sessionViewport)
}}
>
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
/>
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section
ref={setSessionHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={language.t("sidebar.project.recentSessions")}
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
>
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
</div>
</Show>
</div>
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={setSessionThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</ScrollView>
</Show>
</ScrollView>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</div>
)
}
@@ -771,7 +683,6 @@ function HomeProjectColumn(props: {
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) {
const global = useGlobal()
const dialog = useDialog()
@@ -788,12 +699,8 @@ function HomeProjectColumn(props: {
return (
<aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@@ -1511,7 +1418,7 @@ function HomeSessionGroupHeader(props: {
return (
<div
ref={props.ref}
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
+2 -1
View File
@@ -1,7 +1,7 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
@@ -38,6 +38,7 @@ export default function NewLayout(props: ParentProps) {
</main>
{import.meta.env.DEV && <DebugBar inline />}
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2 />
</div>
)
+2 -1
View File
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
@@ -2396,6 +2396,7 @@ export default function LegacyLayout(props: ParentProps) {
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2={false} />
</div>
)
-11
View File
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session"
@@ -52,7 +51,6 @@ export default function NewSessionPage() {
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const dialog = useDialog()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
@@ -79,15 +77,6 @@ export default function NewSessionPage() {
})
command.register("new-session", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogSelectFile } = await import("@/components/dialog-select-file")
void dialog.show(() => <DialogSelectFile />)
},
},
{
id: "input.focus",
title: language.t("command.input.focus"),
-10
View File
@@ -41,7 +41,6 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -363,7 +362,6 @@ export default function Page() {
const platform = usePlatform()
const prompt = usePrompt()
const comments = useComments()
const command = useCommand()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
@@ -1100,14 +1098,6 @@ export default function Page() {
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
command.register("session-palette", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: () => command.trigger("file.open", "palette"),
},
])
const openReviewFile = createOpenReviewFile({
showAllFiles,
@@ -41,7 +41,7 @@ export function createPromptInputController(input: {
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: local.agent.visible(),
visible: settings.visibility.customAgents(),
select: local.agent.set,
},
model: {
@@ -1,6 +1,7 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
@@ -18,6 +19,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const settings = useSettings()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
@@ -68,7 +70,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
disabled: !local.agent.visible(),
disabled: !settings.visibility.customAgents(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -76,7 +78,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
disabled: !local.agent.visible(),
disabled: !settings.visibility.customAgents(),
onSelect: () => local.agent.move(-1),
}),
])
@@ -468,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
@@ -1,76 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { createRoot } from "solid-js"
import { createServerSessionEntries } from "@/components/command-palette"
import type { LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
const stored: Project = {
id: "project-1",
name: "Palette project",
worktree: "/workspace/project",
sandboxes: [],
time: { created: 1, updated: 1 },
}
const session: GlobalSession = {
id: "session-1",
slug: "session-1",
projectID: stored.id,
directory: stored.worktree,
title: "Palette session",
version: "1",
time: { created: 1, updated: 2 },
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
}
describe("command palette sessions", () => {
test("uses the home project avatar and cancels superseded searches", async () => {
const server = ServerConnection.Key.make("selected-server")
const opened: LocalProject = {
...stored,
icon: { override: "home-project-avatar" },
expanded: true,
}
const searches: string[] = []
const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
(resolve, reject) => {
createRoot((dispose) => {
const search = createServerSessionEntries({
server,
opened: () => [opened],
stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }],
load: async (text) => {
searches.push(text)
return {
data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
}
},
untitled: () => "Untitled",
category: () => "Sessions",
})
const first = search("palette")
const second = search("palette session")
Promise.all([first, second])
.then(([cancelled, entries]) => {
expect(cancelled).toEqual([])
resolve(entries)
})
.catch(reject)
.finally(dispose)
})
},
)
expect(searches).toEqual(["palette session"])
expect(result).toHaveLength(1)
expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
expect(result[0]).toMatchObject({
server,
sessionID: session.id,
description: stored.name,
project: { id: stored.id, icon: opened.icon },
})
})
})
+4 -7
View File
@@ -31,13 +31,11 @@ function run(target) {
const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename))
const command = path.basename(__filename).replace(/\.cjs$/, "")
const nodeBuild = command === "opencode2-node"
const cached = path.join(scriptDir, `.${command}`)
const cached = path.join(scriptDir, ".opencode2")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = `@opencode-ai/cli${nodeBuild ? "-node" : ""}-` + platform + "-" + arch
const binary = platform === "windows" ? `${command}.exe` : command
const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
function supportsAvx2() {
if (arch !== "x64") return false
@@ -79,7 +77,6 @@ function supportsAvx2() {
}
const names = (() => {
if (nodeBuild) return [base]
const baseline = arch === "x64" && !supportsAvx2()
if (platform === "linux") {
const musl = (() => {
@@ -124,7 +121,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
console.error(
`It seems that your package manager failed to install the right ${command} CLI package. Try manually installing ` +
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
names.map((name) => `"${name}"`).join(" or ") +
" package",
)
+3 -18
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.3",
"version": "1.18.2",
"type": "module",
"license": "MIT",
"bin": {
@@ -26,7 +26,6 @@
},
"scripts": {
"build": "bun run script/build.ts",
"build:node": "bun run script/build-node.ts",
"dev": "bun run src/index.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
@@ -52,8 +51,7 @@
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
"ws": "8.21.0"
"uqr": "0.1.3"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
@@ -61,19 +59,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"vite": "catalog:",
"vite-plugin-solid": "catalog:"
"@typescript/native-preview": "catalog:"
}
}
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { build } from "vite"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
const outdir = path.resolve(
dir,
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
)
if (outdir === dir) throw new Error("--outdir must not be the package directory")
if (outdir === path.join(dir, "dist-node")) {
throw new Error("--outdir must not be dist-node because it contains temporary files")
}
const bundleOnly = process.argv.includes("--bundle-only")
const single = process.argv.includes("--single")
const skipInstall = process.argv.includes("--skip-install")
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
const allTargets = [
nodeTarget("linux", "arm64"),
nodeTarget("linux", "x64"),
nodeTarget("darwin", "arm64"),
nodeTarget("win32", "arm64"),
nodeTarget("win32", "x64"),
]
const targets = requested
? allTargets.filter((target) => targetName(target) === requested)
: single || bundleOnly
? [nodeTarget(process.platform, process.arch)]
: allTargets
if (targets.length === 0) {
if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64")
throw new Error(`Unknown Node target: ${requested}`)
}
if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) {
throw new Error("Node 26.4 SEA does not support macOS x64")
}
process.chdir(dir)
if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
if (!bundleOnly) await rm(outdir, { recursive: true, force: true })
const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await build(mainConfig(input))
await copyNodeAssets(assets)
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
if (!builder) throw new Error("Node SEA builder is unavailable")
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--version"])
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--help"])
}
if (bundleOnly) continue
const name = `cli-node-${targetName(target)}`
const binary = target.platform === "win32" ? "opencode2-node.exe" : "opencode2-node"
const output = path.join(outdir, name, "bin", binary)
if (!builder) throw new Error("Node SEA builder is unavailable")
await mkdir(path.dirname(output), { recursive: true })
const config = {
main: "dist-node/opencode.mjs",
mainFormat: "module",
executable: await resolveTargetNode(target, builder),
output: path.relative(dir, output),
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: false,
execArgv: nodeExecArgv,
execArgvExtension: "none",
assets: await seaAssetMap(),
}
await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`)
run(builder, ["--build-sea", "dist-node/sea.json"])
if (target.platform !== "win32") await chmod(output, 0o755)
if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output])
if (target.platform === "darwin" && process.platform !== "darwin") {
console.warn(`${output} must be signed on macOS before it can run`)
}
await writeFile(
path.join(outdir, name, "package.json"),
`${JSON.stringify(
{
name: `@opencode-ai/${name}`,
version: Script.version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: [target.platform],
cpu: [target.arch],
},
null,
2,
)}\n`,
)
if (host) await smoke(output)
}
async function resolveHostNode() {
const candidates = [process.env.NODE_BIN, "node"].filter((item): item is string => Boolean(item))
for (const candidate of candidates) {
const result = spawnSync(
candidate,
["-p", "JSON.stringify({version:process.versions.node,path:process.execPath})"],
{
encoding: "utf8",
},
)
if (result.status !== 0) continue
const info = JSON.parse(result.stdout) as { version: string; path: string }
if (info.version === NODE_VERSION) return realpath(info.path)
}
return resolveTargetNode(nodeTarget(process.platform, process.arch))
}
async function resolveTargetNode(target: NodeTarget, host?: string) {
if (host && target.platform === process.platform && target.arch === process.arch) return host
const cache = path.resolve(dir, ".cache", "node")
const platform = target.platform === "win32" ? "win" : target.platform
const archiveName = `node-v${NODE_VERSION}-${platform}-${target.arch}`
const targetDirectory = path.join(cache, archiveName)
const executable = path.join(targetDirectory, target.platform === "win32" ? "node.exe" : "bin/node")
if (
(await stat(executable).then(
() => true,
() => false,
)) &&
(await stat(path.join(targetDirectory, ".verified")).then(
() => true,
() => false,
))
)
return realpath(executable)
await mkdir(cache, { recursive: true })
const extension = target.platform === "win32" ? "zip" : "tar.gz"
const filename = `${archiveName}.${extension}`
const archive = path.join(cache, filename)
const base = `https://nodejs.org/dist/v${NODE_VERSION}`
const [response, sums] = await Promise.all([fetch(`${base}/${filename}`), fetch(`${base}/SHASUMS256.txt`)])
if (!response.ok) throw new Error(`Failed to download Node ${NODE_VERSION}: ${response.status}`)
if (!sums.ok) throw new Error(`Failed to download Node ${NODE_VERSION} checksums: ${sums.status}`)
const data = new Uint8Array(await response.arrayBuffer())
const expected = (await sums.text())
.split("\n")
.find((line) => line.endsWith(` ${filename}`))
?.split(/\s+/)[0]
if (!expected) throw new Error(`Missing checksum for ${filename}`)
if (createHash("sha256").update(data).digest("hex") !== expected) throw new Error(`Checksum mismatch for ${filename}`)
await writeFile(archive, data)
const temporary = path.join(cache, `${archiveName}.${process.pid}.tmp`)
await rm(temporary, { recursive: true, force: true })
await mkdir(temporary)
if (target.platform !== "win32") run("tar", ["-xzf", archive, "-C", temporary])
if (target.platform === "win32" && process.platform === "win32") {
run(path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe"), ["-xf", archive, "-C", temporary])
}
if (target.platform === "win32" && process.platform !== "win32") run("unzip", ["-q", archive, "-d", temporary])
await rm(targetDirectory, { recursive: true, force: true })
await rename(path.join(temporary, archiveName), targetDirectory)
await writeFile(path.join(targetDirectory, ".verified"), `${expected}\n`)
await rm(temporary, { recursive: true, force: true })
await rm(archive, { force: true })
return realpath(executable)
}
async function smoke(output: string) {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-node-smoke-"))
const executable = path.join(root, path.basename(output))
await copyFile(output, executable)
if (process.platform !== "win32") await chmod(executable, 0o755)
run(executable, ["--version"], root)
run(executable, ["--help"], root)
await rm(root, { recursive: true, force: true })
}
function targetName(target: NodeTarget) {
return `${target.platform === "win32" ? "windows" : target.platform}-${target.arch}`
}
function run(command: string, args: readonly string[], cwd = dir) {
const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
+13 -9
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
@@ -10,14 +11,9 @@ import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
const outdir = path.resolve(
dir,
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
)
if (outdir === dir) throw new Error("--outdir must not be the package directory")
process.chdir(dir)
await rm(outdir, { recursive: true, force: true })
await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
@@ -54,6 +50,10 @@ const targets = singleFlag
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
for (const item of targets) {
const target = [
binary,
@@ -67,7 +67,7 @@ for (const item of targets) {
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
entrypoints: ["./src/index.ts", parserWorker],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
@@ -81,7 +81,7 @@ for (const item of targets) {
autoloadTsconfig: true,
autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
outfile: path.join(outdir, name, "bin", binary),
outfile: `./dist/${name}/bin/${binary}`,
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
@@ -93,6 +93,10 @@ for (const item of targets) {
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
'"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
},
})
@@ -103,7 +107,7 @@ for (const item of targets) {
}
await Bun.write(
path.join(outdir, name, "package.json"),
`./dist/${name}/package.json`,
JSON.stringify(
{
name: `@opencode-ai/${name}`,
+1 -3
View File
@@ -1,9 +1,7 @@
import { readFile } from "node:fs/promises"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")
-83
View File
@@ -1,83 +0,0 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
// Bun's compiler discovers file imports and embeds them in its virtual filesystem. Vite only bundles the JavaScript
// portion of the Node executable, while SEA embeds only the assets explicitly listed in its build configuration.
// Collect and stage those files under stable keys so the SEA prelude can extract them to real paths at startup;
// native addons, helper executables, and other path-based consumers cannot use assets directly from SEA memory.
export type NodeAsset = {
readonly key: string
readonly source: string
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
}),
)
).flat()
}
export async function collectNodeAssets(target: NodeTarget) {
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
...getNodeAssets({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
}),
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{
key: photonWasmAsset,
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
},
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(await files(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
key: `${target.nodePtyPackage}/${relative}`,
source: path.join(ptyRoot, relative),
})),
]
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
const hash = createHash("sha256")
for (const asset of assets.toSorted((left, right) => left.key.localeCompare(right.key))) {
hash.update(asset.key)
hash.update(await readFile(asset.source))
}
return hash.digest("hex").slice(0, 16)
}
export async function copyNodeAssets(assets: readonly NodeAsset[]) {
const root = path.join(dir, "dist-node", "assets")
await Promise.all(
assets.map(async (asset) => {
const target = path.join(root, asset.key)
await mkdir(path.dirname(target), { recursive: true })
await copyFile(asset.source, target)
}),
)
}
export async function seaAssetMap() {
const root = path.join(dir, "dist-node", "assets")
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
}
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env node
import childProcess from "node:child_process"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { createRequire } from "node:module"
import { fileURLToPath } from "node:url"
const directory = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
const command = Object.keys(packageJson.bin ?? {})[0]
if (!command) throw new Error("OpenCode package does not declare a binary")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
const sourceBinary = platform === "windows" ? `${command}.exe` : command
const targetBinary = path.resolve(directory, packageJson.bin[command])
const dependencies = packageJson.optionalDependencies ?? {}
const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`))
if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`)
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
return result.status === 0 && (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const script =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const output = (result.stdout || "").trim().toLowerCase()
if (output === "true" || output === "1") return true
if (output === "false" || output === "0") return false
} catch {
continue
}
}
}
return false
}
function isMusl() {
if (platform !== "linux") return false
try {
if (fs.existsSync("/etc/alpine-release")) return true
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
} catch {
return false
}
}
function packageNames() {
const baseline = arch === "x64" && !supportsAvx2()
const names =
platform === "linux"
? isMusl()
? arch === "x64"
? baseline
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
: [`${base}-musl`, base]
: arch === "x64"
? baseline
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
: [base, `${base}-musl`]
: arch === "x64"
? baseline
? [`${base}-baseline`, base]
: [base, `${base}-baseline`]
: [base]
return names.filter((name) => dependencies[name])
}
function copyBinary(source) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
try {
fs.linkSync(source, targetBinary)
} catch {
fs.copyFileSync(source, targetBinary)
}
fs.chmodSync(targetBinary, 0o755)
}
function resolveBinary(name) {
const packagePath = require.resolve(`${name}/package.json`)
return path.join(path.dirname(packagePath), "bin", sourceBinary)
}
function installPackage(name) {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
try {
const result = childProcess.spawnSync(
"npm",
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
{ stdio: "inherit", windowsHide: true },
)
if (result.status !== 0) return false
copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary))
return true
} finally {
fs.rmSync(temp, { recursive: true, force: true })
}
}
function verifyBinary() {
return (
childProcess.spawnSync(targetBinary, ["--version"], {
stdio: "ignore",
windowsHide: true,
}).status === 0
)
}
function main() {
const names = packageNames()
for (const name of names) {
try {
copyBinary(resolveBinary(name))
if (verifyBinary()) return
} catch {
if (installPackage(name) && verifyBinary()) return
}
}
throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
}
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
+32 -61
View File
@@ -18,66 +18,37 @@ async function publish(dir: string, name: string, version: string) {
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
const item = await Bun.file(`${input.root}/${filepath}`).json()
if (!item.name.startsWith(input.packagePrefix)) continue
binaries[item.name] = item.version
}
console.log(input.name, "binaries", binaries)
const versions = new Set(Object.values(binaries))
if (versions.size > 1) throw new Error(`Binary package versions do not match for ${input.name}`)
const version = versions.values().next().value
if (!version) throw new Error(`No binary packages found for ${input.name}`)
await $`mkdir -p ${input.root}/${input.name}/bin`
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write(
[
`echo "Error: ${input.name}'s postinstall script was not run." >&2`,
'echo "" >&2',
'echo "This occurs when installation scripts are disabled." >&2',
'echo "Run the package postinstall script or reinstall with scripts enabled." >&2',
"exit 1",
"",
].join("\n"),
)
await Bun.file(`${input.root}/${input.name}/package.json`).write(
JSON.stringify(
{
name: input.name,
bin: { [input.binary]: `./bin/${input.binary}.exe` },
scripts: { postinstall: "node ./postinstall.mjs" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
2,
),
)
await Promise.all(
Object.entries(binaries).map(([name, version]) =>
publish(`${input.root}/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`${input.root}/${input.name}`, input.name, version)
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const item = await Bun.file(`./dist/${filepath}`).json()
binaries[item.name] = item.version
}
console.log("binaries", binaries)
const version = Object.values(binaries)[0]
const name = pkg.name
await publishDistribution({
root: "./dist",
name: pkg.name,
binary: "opencode2",
packagePrefix: "@opencode-ai/cli-",
})
await publishDistribution({
root: "./dist/node",
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
})
await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${name}/package.json`).write(
JSON.stringify(
{
name,
bin: { opencode2: "./bin/opencode2" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
2,
),
)
await Promise.all(
Object.entries(binaries).map(([name, version]) =>
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`./dist/${name}`, name, version)
+3 -4
View File
@@ -7,10 +7,9 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const nodeBuild = process.argv.includes("--node")
const target = `cli${nodeBuild ? "-node" : ""}-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["node"] : []), target, "bin")
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
-4
View File
@@ -114,10 +114,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
],
}),
Spec.make("plugin", {
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
@@ -1,4 +1,4 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
@@ -46,7 +46,6 @@ export default Runtime.handler(Commands, (input) =>
yield* run({
server: {
endpoint: server.endpoint,
connect: (url, signal) => runServicePromise(ServerConnection.connect(url), { signal }),
service: service
? {
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
@@ -76,6 +75,6 @@ export default Runtime.handler(Commands, (input) =>
: Effect.logInfo(message, tags)
runFork(effect)
},
}).pipe(Effect.provide(LayerNode.compile(Global.node)))
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}),
)
+5 -14
View File
@@ -1,6 +1,5 @@
import { EOL } from "node:os"
import path from "node:path"
import { readFile, stat, writeFile } from "node:fs/promises"
import { Effect, Option } from "effect"
import { applyEdits, modify } from "jsonc-parser"
import { Global } from "@opencode-ai/core/global"
@@ -36,7 +35,7 @@ export default Runtime.handler(
}),
)
export async function resolveConfigPath(directory: string) {
async function resolveConfigPath(directory: string) {
const candidates = [
path.join(directory, "opencode.json"),
path.join(directory, "opencode.jsonc"),
@@ -44,24 +43,16 @@ export async function resolveConfigPath(directory: string) {
path.join(directory, ".opencode", "opencode.jsonc"),
]
for (const candidate of candidates) {
if (
await stat(candidate).then(
(info) => info.isFile(),
() => false,
)
)
return candidate
if (await Bun.file(candidate).exists()) return candidate
}
return candidates[0]
}
async function write(configPath: string, name: string, server: unknown) {
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
throw error
})
const file = Bun.file(configPath)
const text = (await file.exists()) ? await file.text() : "{}"
const edits = modify(text, ["mcp", "servers", name], server, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
await writeFile(configPath, applyEdits(text, edits))
await Bun.write(configPath, applyEdits(text, edits))
}
@@ -1,24 +0,0 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
return
}
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
}),
)
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.serve,
Effect.fnUntraced(function* (input) {
Effect.fn("cli.serve")(function* (input) {
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
return yield* ServerProcess.run({
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
+2 -4
View File
@@ -7,6 +7,7 @@ import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/core/observability"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
@@ -31,9 +32,6 @@ const Handlers = Runtime.handlers(Commands, {
auth: () => import("./commands/handlers/mcp/auth"),
logout: () => import("./commands/handlers/mcp/logout"),
},
plugin: {
list: () => import("./commands/handlers/plugin/list"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
@@ -60,7 +58,7 @@ Effect.logInfo("cli starting", {
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(Observability.layer),
Effect.provide(NodeServices.layer),
Effect.scoped,
-6
View File
@@ -633,12 +633,6 @@ export function RunSubagentSelectBody(props: {
return
}
if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
event.preventDefault()
props.onClose()
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
+3 -4
View File
@@ -4,7 +4,6 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme"
import { Locale } from "@opencode-ai/tui/util/locale"
import { stringWidth } from "@opencode-ai/tui/util/string-width"
export const FOOTER_MENU_ROWS = 8
@@ -197,7 +196,7 @@ export function RunFooterMenu(props: {
...props
.items()
.filter((item) => item.description)
.map((item) => stringWidth(item.display)),
.map((item) => Bun.stringWidth(item.display)),
)
return width === 0 ? 0 : width + 2
})
@@ -206,14 +205,14 @@ export function RunFooterMenu(props: {
return ""
}
return " ".repeat(Math.max(1, descriptionColumn() - stringWidth(item.display)))
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
}
const descriptionText = (item: RunFooterMenuItem) => {
if (!item.description) {
return
}
const footerWidth = item.footer ? stringWidth(item.footer) + 1 : 0
const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0
const available =
term().width -
(border() ? 1 : 0) -
+76 -54
View File
@@ -5,15 +5,14 @@
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
import fuzzysort from "fuzzysort"
import path from "path"
import { pathToFileURL } from "node:url"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { Locale } from "@opencode-ai/tui/util/locale"
import { stringWidth } from "@opencode-ai/tui/util/string-width"
import {
createPromptHistory,
displayCharAt,
@@ -24,7 +23,7 @@ import {
movePromptHistory,
pushPromptHistory,
} from "./prompt.shared"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
@@ -603,7 +602,7 @@ export function createPromptState(input: PromptInput): PromptState {
})
}
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
draft = clonePrompt(value)
setShell(value.mode === "shell")
if (!area || area.isDestroyed) {
@@ -613,7 +612,7 @@ export function createPromptState(input: PromptInput): PromptState {
hide()
area.setText(value.text)
restoreParts(value.parts)
area.cursorOffset = Math.min(cursor, stringWidth(area.plainText))
area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText))
scheduleRows()
area.focus()
}
@@ -644,7 +643,7 @@ export function createPromptState(input: PromptInput): PromptState {
area.setText(text)
clearParts()
draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] }
area.cursorOffset = Math.min(stringWidth(text), stringWidth(area.plainText))
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
scheduleRows()
area.focus()
}
@@ -778,7 +777,7 @@ export function createPromptState(input: PromptInput): PromptState {
if (move(dir, event)) return
if (!area || area.isDestroyed) return false
const endOffset = stringWidth(area.plainText)
const endOffset = Bun.stringWidth(area.plainText)
if (dir === -1) {
if (area.cursorOffset === 0) return false
if (area.visualCursor.visualRow === 0) {
@@ -887,12 +886,16 @@ export function createPromptState(input: PromptInput): PromptState {
area.cursorOffset = 0
const start = area.logicalCursor
area.cursorOffset =
shell() || !head ? cursor : local ? stringWidth(area.plainText) : stringWidth(area.plainText.slice(0, head.end))
shell() || !head
? cursor
: local
? Bun.stringWidth(area.plainText)
: Bun.stringWidth(area.plainText.slice(0, head.end))
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText(text)
area.cursorOffset = stringWidth(text)
area.cursorOffset = Bun.stringWidth(text)
hide()
syncDraft()
if (!shell()) {
@@ -917,7 +920,7 @@ export function createPromptState(input: PromptInput): PromptState {
const text = "@" + next.value
const startOffset = at()
const endOffset = startOffset + stringWidth(text)
const endOffset = startOffset + Bun.stringWidth(text)
const part = structuredClone(next.part)
if (part.type === "agent") {
part.source = {
@@ -990,83 +993,93 @@ export function createPromptState(input: PromptInput): PromptState {
return true
}
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: baseBindingsEnabled(),
commands: [
{
id: "prompt.clear",
name: "prompt.clear",
title: "Clear prompt or exit",
group: "Prompt",
category: "Prompt",
run() {
if (requestExit()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt(),
commands: [
{
id: "session.interrupt",
name: "session.interrupt",
title: "Interrupt session",
group: "Session",
category: "Session",
run() {
if (input.onInterrupt()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.editor",
name: "prompt.editor",
title: "Open editor",
group: "Prompt",
category: "Prompt",
run() {
void openEditor()
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
}))
Keymap.createLayer(() => ({
useBindings(() => ({
priority: 1,
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.history.previous",
name: "prompt.history.previous",
title: "Previous prompt history",
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(-1, event)
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(-1, ctx.event)
},
},
{
id: "prompt.history.next",
name: "prompt.history.next",
title: "Next prompt history",
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(1, event)
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(1, ctx.event)
},
},
],
bindings: [
...input.tuiConfig.keybinds.get("prompt.history.previous"),
...input.tuiConfig.keybinds.get("prompt.history.next"),
],
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
bindings: [
{
bind: "!",
title: "Shell mode",
key: "!",
desc: "Shell mode",
group: "Prompt",
run() {
cmd() {
if (shell()) return false
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
@@ -1076,20 +1089,21 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && shell() && !visible(),
commands: [
bindings: [
{
bind: "escape",
title: "Exit shell mode",
key: "escape",
desc: "Exit shell mode",
group: "Prompt",
run: () => setShellMode(false),
cmd: () => setShellMode(false),
},
{
bind: "backspace",
title: "Exit shell mode",
key: "backspace",
desc: "Exit shell mode",
group: "Prompt",
run() {
cmd() {
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
setShellMode(false)
@@ -1098,31 +1112,32 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && visible(),
commands: [
{
id: "prompt.autocomplete.prev",
name: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
group: "Autocomplete",
category: "Autocomplete",
run: () => menu.move(-1),
},
{
id: "prompt.autocomplete.next",
name: "prompt.autocomplete.next",
title: "Next autocomplete item",
group: "Autocomplete",
category: "Autocomplete",
run: () => menu.move(1),
},
{
id: "prompt.autocomplete.hide",
name: "prompt.autocomplete.hide",
title: "Hide autocomplete",
group: "Autocomplete",
category: "Autocomplete",
run: cancelAutocomplete,
},
{
id: "prompt.autocomplete.select",
name: "prompt.autocomplete.select",
title: "Select autocomplete item",
group: "Autocomplete",
category: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@@ -1132,9 +1147,9 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
{
id: "prompt.autocomplete.complete",
name: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
group: "Autocomplete",
category: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@@ -1149,6 +1164,13 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
],
bindings: [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
].flatMap((command) => input.tuiConfig.keybinds.get(command)),
}))
const onKeyDown = (event: KeyEvent) => {
+6 -4
View File
@@ -24,11 +24,12 @@
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
// two-press pattern where the first press shows a hint and the second press
// within 5 seconds actually fires the action.
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
import type { Keymap } from "@opentui/keymap"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
@@ -81,6 +82,7 @@ type RunFooterOptions = {
first: boolean
history?: RunPrompt[]
theme: RunTheme
keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
@@ -303,8 +305,8 @@ export class RunFooter implements FooterApi {
const footer = this
void render(
() =>
createComponent(Keymap.Provider, {
config: options.tuiConfig,
createComponent(OpencodeKeymapProvider, {
keymap: options.keymap,
get children() {
return createComponent(RunFooterView, {
directory: options.directory,
+107 -29
View File
@@ -27,8 +27,14 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question"
import { footerWidthPolicy } from "./footer.width"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import {
OPENCODE_BASE_MODE,
formatKeyBindings,
formatKeySequence,
useBindings,
useKeymapSelector,
type OpenTuiKeymap,
} from "@opencode-ai/tui/keymap"
import type {
FooterPromptRoute,
FooterQueuedPrompt,
@@ -171,15 +177,75 @@ export function RunFooterView(props: RunFooterViewProps) {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
})
const shortcuts = Keymap.useShortcuts()
const command = () => shortcuts.get("command.palette.show") ?? ""
const subagentShortcut = () => shortcuts.get("session.child.first") ?? ""
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? ""
const backgroundShortcut = () => shortcuts.get("session.background") ?? ""
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? ""
const interrupt = () => shortcuts.get("session.interrupt") ?? ""
const variantCycle = () => shortcuts.all("variant.cycle") ?? ""
const clearShortcut = () => shortcuts.get("prompt.clear") ?? ""
const command = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
.get("command.palette.show")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
.get("session.child.first")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const queuedShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
.get("session.queued_prompts")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentInterruptShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
.get("subagent.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
.get("session.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const variantCycle = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
props.tuiConfig,
) ?? "",
)
const clearShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
?.sequence,
props.tuiConfig,
) ?? "",
)
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
@@ -438,62 +504,74 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onRequestExit?.(undefined)
})
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
commands: [
{
id: "command.palette.show",
name: "command.palette.show",
title: "Open command palette",
group: "Prompt",
category: "Prompt",
run: openCommand,
},
{
id: "variant.cycle",
name: "variant.cycle",
title: "Cycle model variant",
group: "Model",
category: "Model",
run: props.onCycle,
},
],
bindings: [
...props.tuiConfig.keybinds.get("command.palette.show"),
...props.tuiConfig.keybinds.get("variant.cycle"),
],
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1,
commands: [
{
id: "session.background",
name: "session.background",
title: "Background subagents",
group: "Session",
category: "Session",
run: () => props.onBackground?.(),
},
],
bindings: props.tuiConfig.keybinds.get("session.background"),
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
commands: [
{
id: "session.child.first",
name: "session.child.first",
title: "View subagents",
group: "Session",
category: "Session",
run: openSubagentMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.child.first"),
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [
{
id: "session.queued_prompts",
name: "session.queued_prompts",
title: "Manage queued prompts",
group: "Session",
category: "Session",
run: openQueuedMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
Keymap.createLayer(() => ({
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled:
active().type === "prompt" &&
route().type === "subagent" &&
@@ -502,10 +580,9 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1,
commands: [
{
id: "subagent.interrupt",
name: "subagent.interrupt",
title: "Interrupt subagent",
group: "Session",
bind: "ctrl+d",
category: "Session",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
@@ -516,6 +593,7 @@ export function RunFooterView(props: RunFooterViewProps) {
},
},
],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
}))
createEffect(() => {
+2 -4
View File
@@ -4,8 +4,6 @@ import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"
import { readStdin } from "../util/io"
import { setTimeout } from "node:timers/promises"
export type MiniCommandInput = {
server: ServerConnection.Resolved
@@ -24,7 +22,7 @@ export type MiniCommandInput = {
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
export async function runMini(input: MiniCommandInput) {
validate(input)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
const runtimeTask = import("./runtime")
const directory = localDirectory()
@@ -125,7 +123,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
return
}
if (agent) return name
await setTimeout(25)
await Bun.sleep(25)
}
if (!agents) {
warning("failed to list agents. Falling back to default agent")
+2 -3
View File
@@ -1,7 +1,6 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
import { UI } from "./ui"
import type { MiniToolPart } from "./types"
@@ -479,11 +478,11 @@ async function prepareFile(file: File) {
if (file.mime !== "text/plain") {
const uri = file.url.startsWith("data:")
? file.url
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
: `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}`
return { attachment: { uri, mime: file.mime, name: file.filename } }
}
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await readFile(new URL(file.url), "utf8")
: await Bun.file(new URL(file.url)).text()
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
+3 -4
View File
@@ -8,7 +8,6 @@
// the current draft is saved and history begins. Arrowing past the end
// restores the draft.
export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display"
import { stringWidth } from "@opencode-ai/tui/util/string-width"
import type { RunPrompt } from "./types"
const HISTORY_LIMIT = 200
@@ -103,7 +102,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
return { state, apply: false }
}
if (dir === 1 && cursor !== stringWidth(text)) {
if (dir === 1 && cursor !== Bun.stringWidth(text)) {
return { state, apply: false }
}
@@ -137,7 +136,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
index: null,
},
text: state.draft,
cursor: stringWidth(state.draft),
cursor: Bun.stringWidth(state.draft),
apply: true,
}
}
@@ -148,7 +147,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
index: idx,
},
text: state.items[idx].text,
cursor: dir === -1 ? 0 : stringWidth(state.items[idx].text),
cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
apply: true,
}
}
+1 -2
View File
@@ -4,7 +4,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
@@ -49,7 +48,7 @@ async function run(input: RunCommandInput) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
const root = process.env.PWD ?? process.cwd()
const directory = localDirectory(root)
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
if (!message?.trim()) fail("You must provide a message")
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
const prepared = { directory, message, files }
+2 -2
View File
@@ -7,8 +7,8 @@
// none block each other.
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config/v1"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { loadRunProviders } from "./catalog.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
@@ -130,7 +130,7 @@ const layer = Layer.effect(
)
const node = makeGlobalNode({ service: Service, layer, deps: [] })
const runtime = makeRuntime(Service, LayerNode.compile(node))
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
// Fetches available variants and context limits for every provider/model pair.
export async function resolveModelInfo(
@@ -10,7 +10,9 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -165,6 +167,8 @@ function queueSplash(
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin()
const footerTask = import("./footer")
let unregisterKeymap: (() => void) | undefined
try {
const renderer = await createCliRenderer({
stdin: source.stdin,
@@ -183,6 +187,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
})
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background)
const keymap = createDefaultOpenTuiKeymap(renderer)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const state: SplashState = {
entry: false,
exit: false,
@@ -227,6 +233,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
history: input.history,
theme,
wrote,
keymap,
tuiConfig,
diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
@@ -326,6 +333,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
unregisterKeymap?.()
shutdown(renderer)
if (!wroteExit) {
process.stdout.write("\n")
@@ -383,6 +391,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
close,
}
} catch (error) {
unregisterKeymap?.()
source.cleanup?.()
throw error
}
+1 -2
View File
@@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises"
import type {
EventSubscribeOutput,
OpenCodeClient,
@@ -162,7 +161,7 @@ async function prepareFile(file: RunFilePart) {
if (file.mime !== "text/plain") return { attachment: { uri: file.url, name: file.filename } }
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await readFile(new URL(file.url), "utf8")
: await Bun.file(new URL(file.url)).text()
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
+2 -1
View File
@@ -8,6 +8,7 @@
// variant and the persisted file.
import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@@ -202,7 +203,7 @@ const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
/** @internal Exported for testing. */
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
const runtime = makeRuntime(Service, LayerNode.compile(node, replacements))
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
return {
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
-9
View File
@@ -1,9 +0,0 @@
import "./plugin-runtime.promise"
import "./plugin-runtime.effect"
process.stdout.on("error", (error) => {
if ("code" in error && error.code === "EPIPE") return
throw error
})
await import("../index")
@@ -1,28 +0,0 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/v2/effect"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
Tool,
}
@@ -1,26 +0,0 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/v2"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
}
-34
View File
@@ -1,34 +0,0 @@
const platforms = ["darwin", "linux", "win32"] as const
export type NodeTarget = ReturnType<typeof nodeTarget>
export function nodeTarget(platform: string, arch: string) {
if (!platforms.includes(platform as (typeof platforms)[number]) || (arch !== "arm64" && arch !== "x64")) {
throw new Error(`Unsupported Node executable target: ${platform}-${arch}`)
}
const targetPlatform = platform as (typeof platforms)[number]
const targetArch = arch as "arm64" | "x64"
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
return {
platform: targetPlatform,
arch: targetArch,
nodePtyPackage,
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
parcelWatcherPackage,
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
}
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
"@opencode-ai/ui/audio/bip-bop-01.mp3",
"@opencode-ai/ui/audio/bip-bop-03.mp3",
"@opencode-ai/ui/audio/staplebops-06.mp3",
"@opencode-ai/ui/audio/nope-03.mp3",
"@opencode-ai/ui/audio/yup-01.mp3",
] as const
+45 -81
View File
@@ -1,11 +1,13 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { Service } from "@opencode-ai/client/effect/service"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { ProcessLock } from "@opencode-ai/core/util/process-lock"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
@@ -22,29 +24,27 @@ export type Options = {
readonly port?: number
}
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer),
)
})
),
)
const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
if (
serviceOptions !== undefined &&
port !== undefined &&
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
)
return
if (serviceOptions !== undefined) {
const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe(
Effect.as(true),
Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)),
)
if (!acquired) return yield* Effect.void
if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void
}
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -52,49 +52,25 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const password =
options.mode === "service"
? config.password || randomBytes(32).toString("base64url")
? yield* ServiceConfig.password()
: environmentPassword
? Redacted.value(environmentPassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const server = yield* start({
hostname,
port: Option.fromNullishOr(port),
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
port: Option.fromNullishOr(options.port ?? config.port),
password,
instanceID,
service:
serviceOptions === undefined
? undefined
: {
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
}).pipe(
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
Effect.flatMap((found) =>
found
? Effect.void
: Effect.fail(
new Error(
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again.",
{ cause: error },
),
),
),
)
}),
)
if (server === undefined) return
: { onListen: (address) => register(address, password, instanceID, serviceOptions.file) },
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
@@ -118,7 +94,6 @@ const register = Effect.fnUntraced(function* (
password: string,
id: string,
file: string,
shutdown: Effect.Effect<void>,
) {
const fs = yield* FileSystem.FileSystem
const temp = file + "." + id + ".tmp"
@@ -131,49 +106,38 @@ const register = Effect.fnUntraced(function* (
password,
}
const encoded = yield* encodeInfo(info)
const publish = fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* publish
const current = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => undefined),
)
const owns = (found: Info | undefined) =>
found?.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.filterOrFail(owns),
const assertRegistration = Effect.gen(function* () {
const found = yield* current
if (
found !== undefined &&
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
)
return
yield* publish
})
yield* Effect.addFinalizer(() =>
current.pipe(
Effect.flatMap((current) => (current?.id === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
yield* assertRegistration.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to reassert service registration", { cause })),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
Effect.ignore,
)
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined),
Effect.retry(Schedule.spaced("100 millis")),
Effect.timeoutOption("15 seconds"),
)
return Option.isSome(found)
})
function serviceURL(hostname: string, port: number) {
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
}
function addressInUse(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false
if ("code" in error && error.code === "EADDRINUSE") return true
return "cause" in error && addressInUse(error.cause)
}
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
+21 -20
View File
@@ -21,7 +21,23 @@ export type Resolved = {
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
if (args.server !== undefined && args.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
if (args.server !== undefined) return { endpoint: yield* connect(args.server) } satisfies Resolved
if (args.server !== undefined) {
const password = yield* Env.password
const endpoint = {
url: args.server,
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== InstallationVersion)
process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
)
return { endpoint } satisfies Resolved
}
if (args.standalone) {
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
@@ -33,24 +49,6 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Resolved
})
export const connect = Effect.fn("cli.server-connection.connect")(function* (url: string) {
const password = yield* Env.password
const endpoint = {
url,
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== InstallationVersion)
process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
)
return endpoint
})
function managedService(options: EnsureOptions) {
const reconnectOptions = { ...options, version: undefined }
return {
@@ -63,7 +61,10 @@ function managedService(options: EnsureOptions) {
}
}
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
const resolveManaged = Effect.fnUntraced(function* (
options: EnsureOptions,
mismatch: NonNullable<Args["mismatch"]>,
) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+4 -8
View File
@@ -5,7 +5,6 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import { selfCommand } from "../util/process"
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
// points the client package's service operations at this CLI: which
@@ -30,12 +29,6 @@ export function filename(channel = InstallationChannel) {
return `service-${Hash.fast(channel)}.json`
}
export function defaultPort(channel = InstallationChannel) {
if (channel === "latest") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
@@ -85,10 +78,13 @@ const paths = Effect.gen(function* () {
export const options = Effect.fnUntraced(function* () {
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
return {
file,
version: InstallationVersion,
command: [...selfCommand(), "serve", "--service"],
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
}
})
+10 -15
View File
@@ -1,10 +1,10 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import { selfCommand } from "../util/process"
import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
@@ -14,7 +14,10 @@ type Options = {
}
function command(password: string, options: Options) {
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"]
if (!executable) throw new Error("Failed to resolve standalone server command")
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
cwd: process.cwd(),
@@ -36,16 +39,8 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password, options))
const readyLine = yield* Deferred.make<string, Error>()
// Keep draining stdout after readiness so later server writes cannot hit EPIPE.
yield* proc.stdout.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Deferred.succeed(readyLine, line)),
Effect.ensuring(Deferred.fail(readyLine, new Error("Standalone server exited before reporting readiness"))),
Effect.forkScoped,
)
const output = yield* Deferred.await(readyLine)
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return {
url: ready.url,
@@ -53,7 +48,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
pid: proc.pid,
} satisfies Endpoint & { readonly pid: number }
},
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
)
export function start(options: Options = {}) {
@@ -7,7 +7,6 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo"
import { setTimeout } from "node:timers/promises"
import {
batch,
createEffect,
@@ -124,7 +123,7 @@ async function open(from?: string): Promise<Session> {
let shownAt = performance.now()
const waitForStage = async () => {
const remaining = stageFloor - (performance.now() - shownAt)
if (remaining > 0) await setTimeout(remaining)
if (remaining > 0) await Bun.sleep(remaining)
}
const advance = async (stage: number) => {
await waitForStage()
@@ -141,11 +140,11 @@ async function open(from?: string): Promise<Session> {
setOutcome(next)
const completed = await Promise.race([
settled.promise.then(() => true),
setTimeout(transitionDuration + 500).then(() => false),
Bun.sleep(transitionDuration + 500).then(() => false),
])
resolveOutcome = undefined
setAnimating(false)
if (completed) await setTimeout(hold)
if (completed) await Bun.sleep(hold)
}
let closing: Promise<void> | undefined
let transferred = false
@@ -155,7 +154,7 @@ async function open(from?: string): Promise<Session> {
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
await Promise.race([renderer.idle(), setTimeout(500)])
await Promise.race([renderer.idle(), Bun.sleep(500)])
renderer.destroy()
})())
let loading: Promise<void> | undefined
@@ -179,7 +178,7 @@ async function open(from?: string): Promise<Session> {
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), setTimeout(500)])
await Promise.race([renderer.idle(), Bun.sleep(500)])
transferred = true
return {
renderer,
+1 -6
View File
@@ -12,16 +12,11 @@ import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import semver from "semver"
declare const OPENCODE_CLI_NAME: string | undefined
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@opencode-ai/cli"
const packageName = "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
-5
View File
@@ -1,5 +0,0 @@
import { text } from "node:stream/consumers"
export function readStdin() {
return text(process.stdin)
}
-26
View File
@@ -1,26 +0,0 @@
import path from "node:path"
export function selfCommand() {
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
if (runtime !== "bun" && runtime !== "node" && runtime !== "nodejs") return [process.execPath]
if (!process.argv[1]) throw new Error("Failed to resolve CLI entrypoint")
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), process.argv[1]]
return [process.execPath, process.argv[1]]
}
function nodeFlags() {
return process.execArgv.flatMap((arg, index, args) => {
if (index > 0 && args[index - 1] === "--conditions") return []
if (arg === "--conditions") return args[index + 1] ? [arg, args[index + 1]] : []
if (arg.startsWith("--conditions=")) return [arg]
if (
arg === "--experimental-ffi" ||
arg === "--use-system-ca" ||
arg === "--enable-source-maps" ||
arg === "--no-addons"
)
return [arg]
if (arg === "--no-warnings" || arg.startsWith("--disable-warning=")) return [arg]
return []
})
}
+50 -39
View File
@@ -1,9 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { resolve } from "@opencode-ai/tui/config/v1"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { createComponent, createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
@@ -41,43 +42,52 @@ test("down opens subagents from an empty prompt", async () => {
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true },
)
let offKeymap: (() => void) | undefined
function Harness() {
return (
<Keymap.Provider config={config}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</Keymap.Provider>
)
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return createComponent(OpencodeKeymapProvider, {
keymap,
get children() {
return (
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
)
},
})
}
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
@@ -90,6 +100,7 @@ test("down opens subagents from an empty prompt", async () => {
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
+29 -369
View File
@@ -1,10 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Service } from "@opencode-ai/client/effect/service"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -18,13 +17,6 @@ import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
@@ -98,80 +90,6 @@ test("preview registration migration never moves stable discovery", async () =>
}
})
test("managed service writes its registration once", async () => {
const service = await startManagedService("opencode-service-once-")
try {
const before = await fs.stat(service.registration)
await Bun.sleep(6_000)
const after = await fs.stat(service.registration)
expect(after.ino).toBe(before.ino)
expect(after.mtimeMs).toBe(before.mtimeMs)
expect(await Bun.file(service.registration).json()).toEqual(service.info)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("deleting a managed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-delete-")
try {
await fs.rm(service.registration)
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).exists()).toBe(false)
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("deleting a failed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-failed-delete-", true)
try {
await waitForFailed(service.info)
await fs.rm(service.registration)
expect(await waitForExit(service.owner)).toBe(true)
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("corrupting a managed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-corrupt-")
try {
await fs.writeFile(service.registration, "not-json")
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).text()).toBe("not-json")
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("replacing a managed service registration stops its owner and preserves the foreign owner", async () => {
const service = await startManagedService("opencode-service-foreign-")
const foreign = { ...service.info, id: "foreign-owner", pid: process.pid }
try {
await fs.writeFile(service.registration, JSON.stringify(foreign))
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).json()).toEqual(foreign)
await expectPortAvailable(service.port)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("clean managed service shutdown removes its registration", async () => {
const service = await startManagedService("opencode-service-clean-")
try {
await Effect.runPromise(Service.stop({ file: service.registration }).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await waitForExit(service.owner)).toBe(true)
expect(await Bun.file(service.registration).exists()).toBe(false)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("concurrent service processes elect one server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
const database = path.join(root, "opencode.db")
@@ -212,34 +130,18 @@ test("concurrent service processes elect one server", async () => {
)
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const registration = path.join(root, "state", "opencode", "service-local.json")
const port = await availablePort()
const config = path.join(root, "config", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(config, JSON.stringify({ port }))
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "pipe" }))
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
try {
const info = await waitForInfo(registration)
const winner = processes.find((process) => process.pid === info.pid)
const losers = processes.filter((process) => process.pid !== info.pid)
const exited = await Promise.all(
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(60_000).then(() => false)])),
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])),
)
expect(exited).toEqual(losers.map(() => true))
const errors = await Promise.all(
losers.map(
async (process) => (await new Response(process.stdout).text()) + (await new Response(process.stderr).text()),
),
)
expect(
losers.map((process) => process.exitCode),
errors.filter(Boolean).join("\n"),
).toEqual(losers.map(() => 0))
expect(winner?.exitCode).toBe(null)
expect(new URL(info.url).port).toBe(String(port))
expect((await Bun.file(config).json()).password).toBe(info.password)
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
expect(
await fetch(new URL("/api/health", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
@@ -249,6 +151,20 @@ test("concurrent service processes elect one server", async () => {
version: info.version,
pid: info.pid,
})
const blockedTemp = registration + "." + info.id + ".tmp"
await fs.mkdir(blockedTemp)
await fs.rm(registration)
await Bun.sleep(6_000)
expect(await Bun.file(registration).exists()).toBe(false)
await fs.rm(blockedTemp, { recursive: true })
const restored = await waitForInfo(registration)
expect(restored.id).toBe(info.id)
expect(restored.pid).toBe(info.pid)
await fs.writeFile(registration, "not-json")
const repaired = await waitForInfo(registration)
expect(repaired.id).toBe(info.id)
expect(repaired.pid).toBe(info.pid)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
const contenderExited = await Promise.race([
@@ -256,7 +172,6 @@ test("concurrent service processes elect one server", async () => {
Bun.sleep(10_000).then(() => false),
])
expect(contenderExited).toBe(true)
expect(contender.exitCode).toBe(0)
expect((await waitForInfo(registration)).id).toBe(info.id)
} finally {
contender.kill("SIGTERM")
@@ -276,202 +191,22 @@ test("concurrent service processes elect one server", async () => {
),
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await Effect.runPromise(
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
)
await winner?.exited
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.map((process) => process.exited))
await fs.rm(root, { recursive: true, force: true })
}
}, 120_000)
test("configured managed service port overrides the channel default", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-port-"))
const port = await availablePort()
const env = serviceEnv(root)
const registration = path.join(root, "state", "opencode", "service-local.json")
const config = path.join(root, "config", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(config, JSON.stringify({ port, password: "" }))
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env,
stderr: "pipe",
stdout: "ignore",
})
try {
const info = await waitForInfo(registration)
expect(new URL(info.url).port).toBe(String(port))
expect(info.password).not.toBe("")
expect((await Bun.file(config).json()).password).toBe(info.password)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("unrelated managed port occupancy reports an actionable conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
const port = listener.port
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "pipe",
})
try {
expect(await contender.exited).not.toBe(0)
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
expect(output).toContain(`Managed service port ${port} on 127.0.0.1 is already in use by another process`)
expect(output).toContain("opencode service set port <port>")
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
listener.stop(true)
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
const recognizing = Promise.withResolvers<void>()
const requests = { count: 0 }
using listener = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return new Promise<Response>(() => {})
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(
path.join(root, "config", "opencode", "service-local.json"),
JSON.stringify({ port: listener.port }),
)
const stale = {
id: "stale",
version: InstallationVersion,
url: "http://127.0.0.1:1",
pid: process.pid,
password: "stale",
}
await fs.writeFile(registration, JSON.stringify(stale))
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "pipe",
})
try {
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
const exitCode = await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])
expect(exitCode).toBe(1)
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
expect(output).toContain(`Managed service port ${listener.port} on 127.0.0.1 is already in use by another process`)
expect(await Bun.file(registration).json()).toEqual(stale)
} finally {
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 45_000)
test("port contender recognizes an incumbent registered during the bind race", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-race-"))
const recognizing = Promise.withResolvers<void>()
const requests = { count: 0 }
using listener = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
const config = path.join(root, "config", "opencode", "service-local.json")
await fs.mkdir(path.dirname(config), { recursive: true })
await fs.writeFile(config, JSON.stringify({ port: listener.port }))
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(
registration,
JSON.stringify({
id: "stale",
version: InstallationVersion,
url: "http://127.0.0.1:1",
pid: 2_147_483_647,
password: "stale",
}),
)
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try {
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
await Bun.sleep(8_000)
const info = {
id: "incumbent",
version: InstallationVersion,
url: `http://127.0.0.1:${listener.port}`,
pid: process.pid,
password: "incumbent",
try {
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
await fs.writeFile(registration, JSON.stringify(info))
expect(await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])).toBe(0)
expect(await Bun.file(registration).json()).toEqual(info)
} finally {
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 45_000)
}, 60_000)
test("stale dead registration is replaced after binding the selected port", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
const port = await availablePort()
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile(
registration,
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
)
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try {
const info = await waitForInfo(registration, (value) => value.id !== "dead")
expect(new URL(info.url).port).toBe(String(port))
expect(info.pid).toBe(owner.pid)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("a failed service stays registered and owns the selected port until stopped", async () => {
test("a failed service stays registered and owns the lock until stopped", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
const database = path.join(root, "database")
await fs.mkdir(database)
@@ -491,12 +226,10 @@ test("a failed service stays registered and owns the selected port until stopped
try {
const info = await waitForInfo(registration)
await waitForFailed(info)
expect(owner.exitCode).toBe(null)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
expect(await Promise.race([contender.exited.then(() => true), Bun.sleep(10_000).then(() => false)])).toBe(true)
expect(contender.exitCode).toBe(0)
expect((await waitForInfo(registration)).id).toBe(info.id)
expect(owner.exitCode).toBe(null)
@@ -538,92 +271,19 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
),
Effect.filterOrFail((rows) => rows.length > 0),
Effect.map((rows) => rows.length),
Effect.retry(Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(200)])),
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))),
)
}),
)
}
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
async function waitForInfo(file: string) {
for (let attempt = 0; attempt < 400; attempt++) {
const value = await Bun.file(file)
.json()
.catch(() => undefined)
if (value !== undefined) {
const info = await Schema.decodeUnknownPromise(Service.Info)(value)
if (accept(info)) return info
}
if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value)
await Bun.sleep(50)
}
throw new Error("Timed out waiting for service registration")
}
async function waitForFailed(info: Info) {
for (let attempt = 0; attempt < 400; attempt++) {
const status = await fetch(new URL("/api/health", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
})
.then((response) => response.status)
.catch(() => undefined)
if (status === 500) return
await Bun.sleep(50)
}
throw new Error("Timed out waiting for service boot failure")
}
async function availablePort() {
const server = Bun.serve({ port: 0, fetch: () => new Response() })
const port = server.port
await server.stop(true)
if (port === undefined) throw new Error("Server did not bind a port")
return port
}
function serviceEnv(root: string) {
return {
...process.env,
HOME: root,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
}
}
async function startManagedService(prefix: string, failBoot = false) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const port = await availablePort()
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
if (failBoot) await fs.mkdir(path.join(root, "database"))
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: failBoot ? { ...serviceEnv(root), OPENCODE_DB: path.join(root, "database") } : serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
const info = await waitForInfo(registration).catch(async (cause) => {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
throw cause
})
return { root, port, registration, owner, info }
}
async function stopManagedService(service: Awaited<ReturnType<typeof startManagedService>>) {
service.owner.kill("SIGTERM")
await service.owner.exited
await fs.rm(service.root, { recursive: true, force: true })
}
function waitForExit(process: Bun.Subprocess, timeout = 10_000) {
return Promise.race([process.exited.then(() => true), Bun.sleep(timeout).then(() => false)])
}
async function expectPortAvailable(port: number) {
const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() })
await server.stop(true)
}
+1 -2
View File
@@ -6,6 +6,5 @@
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"exclude": ["dist", "dist-node"]
}
}
-220
View File
@@ -1,220 +0,0 @@
import path from "node:path"
import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
const dir = import.meta.dirname
function rawTextPlugin(): Plugin {
return {
name: "opencode:raw-text",
async load(id) {
if (!id.endsWith(".md")) return
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
enforce: "pre",
transform(code, id) {
if (!id.endsWith("turndown/lib/turndown.es.js")) return
const transformed = code.replace(" var domino = require('@mixmark-io/domino');", "")
if (transformed === code) this.error("Failed to rewrite Turndown's Domino require")
return `import domino from "@mixmark-io/domino"\n${transformed}`
},
}
}
const resolve = {
alias: [
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
{ find: /^solid-js$/, replacement: "solid-js/dist/solid.js" },
{
find: /^ws$/,
replacement: path.join(path.dirname(createRequire(import.meta.url).resolve("ws/package.json")), "wrapper.mjs"),
},
],
conditions: ["node"],
}
const output = (entryFileNames: string, banner?: string) => ({
format: "esm" as const,
entryFileNames,
inlineDynamicImports: true,
banner,
})
function nodePrelude(input: NodeBuildInput) {
const nodePtySpawnHelper =
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
export const Command = sdk.Command
export const Connection = sdk.Connection
export const Credential = sdk.Credential
export const Integration = sdk.Integration
export const Model = sdk.Model
export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Tool = sdk.Tool
export const Failure = sdk.Tool.Failure
export const RegistrationError = sdk.Tool.RegistrationError
export const make = sdk.Tool.make
export const validateName = sdk.Tool.validateName
export const registrationEntries = sdk.Tool.registrationEntries
export const withPermission = sdk.Tool.withPermission
export const permission = sdk.Tool.permission
export const definition = sdk.Tool.definition
export const settle = sdk.Tool.settle`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
import { tmpdir as __ocTmpdir } from "node:os"
import __ocPath from "node:path"
import { getAssetKeys as __ocAssetKeys, getRawAsset as __ocRawAsset, isSea as __ocIsSea } from "node:sea"
import { fileURLToPath as __ocFileURLToPath } from "node:url"
const __filename = import.meta.filename
const __dirname = import.meta.dirname
const require = __cjs_mod__.createRequire(import.meta.url)
const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
})}
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-v2-plugin": promisePluginModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
})}
__cjs_mod__.registerHooks({
resolve(__ocSpecifier, __ocContext, __ocNextResolve) {
const __ocUrl = __ocPluginModules[__ocSpecifier]
return __ocUrl ? { url: __ocUrl, shortCircuit: true } : __ocNextResolve(__ocSpecifier, __ocContext)
},
load(__ocUrl, __ocContext, __ocNextLoad) {
const __ocSource = __ocPluginSources[__ocUrl]
return __ocSource
? { format: "module", source: __ocSource, shortCircuit: true }
: __ocNextLoad(__ocUrl, __ocContext)
},
})
const __ocUid = typeof process.getuid === "function" ? process.getuid() : undefined
const __ocCacheRoot = __ocPath.join(__ocTmpdir(), \`opencode-node-\${__ocUid ?? "user"}\`)
if (__ocIsSea()) {
try {
__ocMkdir(__ocCacheRoot, { mode: 0o700 })
} catch (__ocError) {
if (!__ocExists(__ocCacheRoot)) throw __ocError
}
const __ocCacheInfo = __ocLstat(__ocCacheRoot)
if (!__ocCacheInfo.isDirectory() || __ocCacheInfo.isSymbolicLink()) throw new Error("Unsafe Node asset cache path")
if (__ocUid !== undefined && __ocCacheInfo.uid !== __ocUid) throw new Error("Node asset cache is owned by another user")
if (__ocUid !== undefined) __ocChmod(__ocCacheRoot, 0o700)
}
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
if (__ocIsSea()) {
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
if (__ocExists(__ocTarget)) continue
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
try {
__ocRename(__ocTemporary, __ocTarget)
} catch (__ocError) {
__ocRm(__ocTemporary, { force: true })
if (!__ocExists(__ocTarget)) throw __ocError
}
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
}
export type NodeBuildInput = {
readonly version: string
readonly channel: string
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
rawTextPlugin(),
runtimeRequirePlugin(),
solid({
solid: {
generate: "universal",
moduleName: "@opentui/solid",
},
}),
],
resolve,
esbuild: { jsx: "automatic" },
define: {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_MODELS_DEV: input.models,
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
},
ssr: { noExternal: true },
build: {
ssr: "src/node/index.ts",
target: "node26",
outDir: "dist-node",
emptyOutDir: false,
minify: true,
rollupOptions: {
external: [/^@opencode-ai\/simulation(?:\/|$)/],
output: output("opencode.mjs", nodePrelude(input)),
},
},
})
}
export default mainConfig({
version: process.env.OPENCODE_VERSION ?? "local",
channel: process.env.OPENCODE_CHANNEL ?? "local",
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
})

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