Merge remote-tracking branch 'origin/dev' into opencode-remote-voice

# Conflicts:
#	packages/opencode/src/server/routes/instance/experimental.ts
#	packages/opencode/src/session/processor.ts
#	packages/opencode/src/session/run-state.ts
#	packages/opencode/src/session/status.ts
This commit is contained in:
Ryan Vogel 2026-04-17 22:38:23 +00:00
commit 4cfe8a8bf8
342 changed files with 28048 additions and 24622 deletions

View file

@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS OPENCODE_DISABLE_DEFAULT_PLUGINS
OPENCODE_DISABLE_FILETIME_CHECK
OPENCODE_DISABLE_LSP_DOWNLOAD OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_PRUNE OPENCODE_DISABLE_PRUNE

View file

@ -1,10 +1,6 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": { "provider": {},
"opencode": {
"options": {},
},
},
"permission": { "permission": {
"edit": { "edit": {
"packages/opencode/migration/*": "deny", "packages/opencode/migration/*": "deny",

View file

@ -10,6 +10,7 @@ This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
- Use Bun APIs when possible, like `Bun.file()` - Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
Reduce total variable count by inlining when a value is only used once. Reduce total variable count by inlining when a value is only used once.

View file

@ -47,7 +47,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -101,7 +101,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@ -135,7 +135,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@ -162,7 +162,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@ -186,7 +186,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@ -210,7 +210,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -243,7 +243,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
@ -286,7 +286,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/shared": "workspace:*", "@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -315,7 +315,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@ -383,7 +383,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.4.6", "version": "1.4.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -392,15 +392,15 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.97", "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.109", "@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.53",
@ -435,8 +435,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99", "@opentui/core": "catalog:",
"@opentui/solid": "0.1.99", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@ -456,7 +456,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2", "gitlab-ai-provider": "6.6.0",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@ -530,23 +530,23 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@opentui/core": "0.1.99", "@opentui/core": "catalog:",
"@opentui/solid": "0.1.99", "@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.1.99", "@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.99", "@opentui/solid": ">=0.1.100",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@ -565,7 +565,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@ -580,7 +580,7 @@
}, },
"packages/shared": { "packages/shared": {
"name": "@opencode-ai/shared", "name": "@opencode-ai/shared",
"version": "1.4.6", "version": "1.4.10",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -588,6 +588,7 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:", "@npmcli/arborist": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.2.5", "minimatch": "10.2.5",
"semver": "catalog:", "semver": "catalog:",
@ -603,7 +604,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@ -638,7 +639,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -687,7 +688,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@ -746,6 +747,8 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
@ -761,7 +764,7 @@
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.158", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
@ -809,7 +812,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@ -829,11 +832,11 @@
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="], "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.97", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ERHmVGX30YKTwxObuHQzNqoOf8Nb5WwYMDBn34e3TGGVn0vLEXwMimo7uRVTbhhi4gfu9WtwYTE4x1+csZok1w=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.109", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/google": "3.0.63", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QzQ+DgOoSYlkU4mK0H+iaCaW1bl5zOimH9X2E2oylcVyUtAdCuduQ959Uw1ygW3l09J2K/ceEDtK8OUPHyOA7g=="], "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@ -1889,7 +1892,7 @@
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
"@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="],
@ -2841,7 +2844,7 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@ -2901,7 +2904,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
@ -3889,7 +3892,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="], "gitlab-ai-provider": ["gitlab-ai-provider@6.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@ -5987,7 +5990,11 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@ -6001,7 +6008,9 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@ -6499,6 +6508,18 @@
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
@ -6701,7 +6722,7 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
@ -7041,7 +7062,7 @@
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="], "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-NJAK+cPjwn+2ojDLyyDmBQyx2pD+rILetp7VCylgjek=", "x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
"aarch64-linux": "sha256-q8NTtFQJoyM7TTvErGA6RtmUscxoZKD/mj9N6S5YhkA=", "aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
"aarch64-darwin": "sha256-/ccoSZNLef6j9j14HzpVqhKCR+czM3mhPKPH51mHO24=", "aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
"x86_64-darwin": "sha256-6Pd10sMHL/5ZoWNvGPwPn4/AIs1TKjt/3gFyrVpBaE0=" "x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
} }
} }

View file

@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
--filter './packages/opencode' \ --filter './packages/opencode' \
--filter './packages/desktop' \ --filter './packages/desktop' \
--filter './packages/app' \ --filter './packages/app' \
--filter './packages/shared' \
--frozen-lockfile \ --frozen-lockfile \
--ignore-scripts \ --ignore-scripts \
--no-progress --no-progress

View file

@ -34,6 +34,8 @@
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
@ -51,7 +53,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48", "effect": "4.0.0-beta.48",
"ai": "6.0.158", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.6", "version": "1.4.10",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {

View file

@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/shared/util/path" import { getFilename } from "@opencode-ai/shared/util/path"
import { createEffect, createMemo, For, Show } from "solid-js" import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers" import { focusTerminalById } from "@/pages/session/helpers"
@ -134,6 +135,7 @@ export function SessionHeader() {
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const settings = useSettings()
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
@ -151,6 +153,11 @@ export function SessionHeader() {
}) })
const hotkey = createMemo(() => command.keybind("file.open")) const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform)) const os = createMemo(() => detectOS(platform))
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true, finder: true,
@ -262,12 +269,16 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err)) .catch((err: unknown) => showRequestError(language, err))
} }
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
setRightMount(document.getElementById("opencode-titlebar-right"))
})
return ( return (
<> <>
<Show when={centerMount()}> <Show when={search() && centerMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<Button <Button
@ -415,24 +426,28 @@ export function SessionHeader() {
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}> <Show when={status()}>
<StatusPopover /> <Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
</Tooltip> <StatusPopover />
<TooltipKeybind </Tooltip>
title={language.t("command.terminal.toggle")} </Show>
keybind={command.keybind("terminal.toggle")} <Show when={term()}>
> <TooltipKeybind
<Button title={language.t("command.terminal.toggle")}
variant="ghost" keybind={command.keybind("terminal.toggle")}
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
> >
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} /> <Button
</Button> variant="ghost"
</TooltipKeybind> class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0"> <div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind <TooltipKeybind
@ -451,30 +466,32 @@ export function SessionHeader() {
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<TooltipKeybind <Show when={tree()}>
title={language.t("command.fileTree.toggle")} <TooltipKeybind
keybind={command.keybind("fileTree.toggle")} title={language.t("command.fileTree.toggle")}
> keybind={command.keybind("fileTree.toggle")}
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
> >
<div class="relative flex items-center justify-center size-4"> <Button
<Icon variant="ghost"
size="small" class="titlebar-icon w-8 h-6 p-0 box-border"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"} onClick={() => layout.fileTree.toggle()}
classList={{ aria-label={language.t("command.fileTree.toggle")}
"text-icon-strong": layout.fileTree.opened(), aria-expanded={layout.fileTree.opened()}
"text-icon-weak": !layout.fileTree.opened(), aria-controls="file-tree-panel"
}} >
/> <div class="relative flex items-center justify-center size-4">
</div> <Icon
</Button> size="small"
</TooltipKeybind> name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div> </div>
</div> </div>
</div> </div>

View file

@ -106,6 +106,7 @@ export const SettingsGeneral: Component = () => {
permission.disableAutoAccept(params.id, value) permission.disableAutoAccept(params.id, value)
} }
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => { const check = () => {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
@ -279,6 +280,74 @@ export const SettingsGeneral: Component = () => {
</div> </div>
) )
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => ( const AppearanceSection = () => (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
@ -527,6 +596,7 @@ export const SettingsGeneral: Component = () => {
</div> </div>
) )
console.log(import.meta.env)
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@ -609,6 +679,10 @@ export const SettingsGeneral: Component = () => {
) )
}} }}
</Show> </Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<AdvancedSection />
</Show>
</div> </div>
</div> </div>
) )

View file

@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = { type TauriDesktopWindow = {
@ -40,6 +41,7 @@ export function Titlebar() {
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@ -78,6 +80,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0) const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1) const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0) const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
const back = () => { const back = () => {
const next = backPath(history) const next = backPath(history)
@ -255,13 +258,12 @@ export function Titlebar() {
<div <div
class="flex items-center shrink-0" class="flex items-center shrink-0"
classList={{ classList={{
"translate-x-0": !layout.sidebar.opened(), "-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(), "duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(), "duration-180 ease-in": layout.sidebar.opened(),
}} }}
> >
<Show when={hasProjects()}> <Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform"> <div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button <Button

View file

@ -204,7 +204,7 @@ function createGlobalSync() {
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient const promise = queryClient
.ensureQueryData({ .fetchQuery({
...loadSessionsQuery(directory), ...loadSessionsQuery(directory),
queryFn: () => queryFn: () =>
loadRootSessionsWithFallback({ loadRootSessionsWithFallback({
@ -264,7 +264,6 @@ function createGlobalSync() {
children.pin(directory) children.pin(directory)
const promise = Promise.resolve().then(async () => { const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory) const cache = children.vcsCache.get(directory)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory) const sdk = sdkFor(directory)

View file

@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
limit: 5, limit: 5,
message: {}, message: {},
part: {}, part: {},
bootstrapPromise: Promise.resolve(),
}) })
children[directory] = child children[directory] = child
disposers.set(directory, dispose) disposers.set(directory, dispose)

View file

@ -72,7 +72,6 @@ export type State = {
part: { part: {
[messageID: string]: Part[] [messageID: string]: Part[]
} }
bootstrapPromise: Promise<void>
} }
export type VcsCache = { export type VcsCache = {

View file

@ -23,6 +23,11 @@ export interface Settings {
autoSave: boolean autoSave: boolean
releaseNotes: boolean releaseNotes: boolean
followup: "queue" | "steer" followup: "queue" | "steer"
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showTerminal: boolean
showReasoningSummaries: boolean showReasoningSummaries: boolean
shellToolPartsExpanded: boolean shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean editToolPartsExpanded: boolean
@ -89,6 +94,11 @@ const defaultSettings: Settings = {
autoSave: true, autoSave: true,
releaseNotes: true, releaseNotes: true,
followup: "steer", followup: "steer",
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showTerminal: false,
showReasoningSummaries: false, showReasoningSummaries: false,
shellToolPartsExpanded: false, shellToolPartsExpanded: false,
editToolPartsExpanded: false, editToolPartsExpanded: false,
@ -162,6 +172,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowup(value: "queue" | "steer") { setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value) setStore("general", "followup", value === "queue" ? "steer" : value)
}, },
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
showReasoningSummaries: withFallback( showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries, () => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries, defaultSettings.general.showReasoningSummaries,

View file

@ -1,16 +1,14 @@
import "solid-js"
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_SERVER_PORT: string
readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod" readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv
} }
declare module "solid-js" { export declare module "solid-js" {
namespace JSX { namespace JSX {
interface Directives { interface Directives {
sortable: true sortable: true

View file

@ -720,6 +720,7 @@ export const dict = {
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance", "settings.general.section.appearance": "Appearance",
"settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "System notifications", "settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates", "settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects", "settings.general.section.sounds": "Sound effects",
@ -742,6 +743,16 @@ export const dict = {
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue", "settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
"settings.general.row.followup.option.queue": "Queue", "settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer", "settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",

View file

@ -13,7 +13,7 @@ import {
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => { const route = createMemo(() => {
const slug = params.dir const slug = params.dir
if (!slug) return { slug, dir: "" } if (!slug) return { slug, dir: "" }
@ -2109,196 +2110,198 @@ export default function Layout(props: ParentProps) {
</Show> </Show>
} }
> >
<> {(project) => (
<div class="shrink-0 pl-1 py-1"> <>
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0"> <div class="shrink-0 pl-1 py-1">
<div class="flex flex-col min-w-0"> <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<InlineEditor <div class="flex flex-col min-w-0">
id={`project:${projectId()}`} <InlineEditor
value={projectName} id={`project:${projectId()}`}
onSave={(next) => { value={projectName}
const item = project() onSave={(next) => {
if (!item) return const item = project()
void renameProject(item, next) if (!item) return
}} void renameProject(item, next)
class="text-14-medium text-text-strong truncate" }}
displayClass="text-14-medium text-text-strong truncate" class="text-14-medium text-text-strong truncate"
stopPropagation displayClass="text-14-medium text-text-strong truncate"
/> stopPropagation
/>
<Tooltip <Tooltip
placement="bottom" placement="bottom"
gutter={2} gutter={2}
value={worktree()} value={worktree()}
class="shrink-0" class="shrink-0"
contentStyle={{ contentStyle={{
"max-width": "640px", "max-width": "640px",
transform: "translate3d(52px, 0, 0)", transform: "translate3d(52px, 0, 0)",
}} }}
> >
<span class="text-12-regular text-text-base truncate select-text"> <span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")} {worktree().replace(homedir(), "~")}
</span> </span>
</Tooltip> </Tooltip>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
</div>
<div class="flex-1 min-h-0 flex flex-col"> <div class="flex-1 min-h-0 flex flex-col">
<Show <Show
when={workspacesEnabled()} when={workspacesEnabled()}
fallback={ fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
</div>
</>
}
>
<> <>
<div class="shrink-0 py-4"> <div class="shrink-0 py-4">
<Button <Button
size="large" size="large"
icon="new-session" icon="plus-small"
class="w-full" class="w-full"
onClick={() => { onClick={() => {
const dir = worktree() const item = project()
if (!dir) return if (!item) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`) void createWorkspace(item)
}} }}
> >
{language.t("command.session.new")} {language.t("workspace.new")}
</Button> </Button>
</div> </div>
<div class="flex-1 min-h-0"> <div class="relative flex-1 min-h-0">
<LocalWorkspace <DragDropProvider
ctx={workspaceSidebarCtx} onDragStart={handleWorkspaceDragStart}
project={project()!} onDragEnd={handleWorkspaceDragEnd}
sortNow={sortNow} onDragOver={handleWorkspaceDragOver}
mobile={panelProps.mobile} collisionDetector={closestCenter}
/> >
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div> </div>
</> </>
} </Show>
> </div>
<> </>
<div class="shrink-0 py-4"> )}
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
void createWorkspace(item)
}}
>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
</Show> </Show>
<div <div
@ -2362,14 +2365,9 @@ export default function Layout(props: ParentProps) {
/> />
) )
const [loading] = createResource(
() => route()?.store?.[0]?.bootstrapPromise,
(p) => p,
)
return ( return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{(autoselecting(), loading()) ?? ""} {autoselecting() ?? ""}
<Titlebar /> <Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex"> <div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">

View file

@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
}) })
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active()) const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0) const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory)) const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false) const loading = () => query.isLoading
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
const touch = createMediaQuery("(hover: none)") const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => { const loadMore = async () => {
@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
mobile={props.mobile} mobile={props.mobile}
ctx={props.ctx} ctx={props.ctx}
showNew={showNew} showNew={showNew}
loading={loading} loading={() => query.isLoading && count() === 0}
sessions={sessions} sessions={sessions}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.project.worktree)) const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0) const count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => query.isLoading && count() === 0
const loadMore = async () => { const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5) workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree) await globalSync.project.loadSessions(props.project.worktree)
@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile} mobile={props.mobile}
ctx={props.ctx} ctx={props.ctx}
showNew={() => false} showNew={() => false}
loading={() => query.isLoading} loading={loading}
sessions={sessions} sessions={sessions}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}

View file

@ -1,6 +1,6 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import { import {
batch, batch,
onCleanup, onCleanup,
@ -324,6 +324,7 @@ export default function Page() {
const local = useLocal() const local = useLocal()
const file = useFile() const file = useFile()
const sync = useSync() const sync = useSync()
const queryClient = useQueryClient()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const sdk = useSDK() const sdk = useSDK()
@ -518,26 +519,6 @@ export default function Page() {
deferRender: false, deferRender: false,
}) })
const [vcs, setVcs] = createStore<{
diff: {
git: VcsFileDiff[]
branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = persisted( const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]), Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{ createStore<{
@ -571,68 +552,6 @@ export default function Page() {
let todoTimer: number | undefined let todoTimer: number | undefined
let diffFrame: number | undefined let diffFrame: number | undefined
let diffTimer: number | undefined let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, list(result.data))
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => { createComputed((prev) => {
const open = desktopReviewOpen() const open = desktopReviewOpen()
@ -663,21 +582,52 @@ export default function Page() {
list.push("turn") list.push("turn")
return list return list
}) })
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => { const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes if (store.changes === "git" || store.changes === "branch") return store.changes
}) })
const reviewDiffs = createMemo(() => { const vcsKey = createMemo(
if (store.changes === "git") return list(vcs.diff.git) () => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
if (store.changes === "branch") return list(vcs.diff.branch) )
const vcsQuery = createQuery(() => {
const mode = vcsMode()
const enabled = wantsReview() && sync.project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60 * 1000,
queryFn: mode
? () =>
sdk.client.vcs
.diff({ mode })
.then((result) => list(result.data))
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
})
: skipToken,
}
})
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs() return turnDiffs()
}) }
const reviewCount = createMemo(() => reviewDiffs().length) const reviewCount = () => reviewDiffs().length
const hasReview = createMemo(() => reviewCount() > 0) const hasReview = () => reviewCount() > 0
const reviewReady = createMemo(() => { const reviewReady = () => {
if (store.changes === "git") return vcs.ready.git if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
if (store.changes === "branch") return vcs.ready.branch
return true return true
}) }
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create" if (store.newSessionWorktree === "create") return "create"
@ -897,27 +847,6 @@ export default function Page() {
), ),
) )
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => { const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return if (evt.details.type !== "file.watcher.updated") return
const props = const props =
@ -1051,13 +980,6 @@ export default function Page() {
} }
} }
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => { createEffect(() => {
const list = changesOptions() const list = changesOptions()
if (list.includes(store.changes)) return if (list.includes(store.changes)) return
@ -1066,22 +988,12 @@ export default function Page() {
setStore("changes", next) setStore("changes", next)
}) })
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect( createEffect(
on( on(
() => sync.data.session_status[params.id ?? ""]?.type, () => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => { (next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true) refreshVcs()
}, },
{ defer: true }, { defer: true },
), ),

View file

@ -19,6 +19,9 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@ -39,6 +42,9 @@ export function SessionSidePanel(props: {
size: Sizing size: Sizing
}) { }) {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform()
const settings = useSettings()
const sync = useSync()
const file = useFile() const file = useFile()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
@ -46,9 +52,15 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view } = useSessionLayout() const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
() =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree(),
)
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen()) const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop()) const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => { const panelWidth = createMemo(() => {
@ -341,98 +353,99 @@ export function SessionSidePanel(props: {
</div> </div>
</div> </div>
<div <Show when={shown()}>
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<div <div
class="h-full flex flex-col overflow-hidden group/filetree" id="file-tree-panel"
classList={{ "border-l border-border-weaker-base": reviewOpen() }} aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
> >
<Tabs <div
variant="pill" class="h-full flex flex-col overflow-hidden group/filetree"
value={fileTreeTab()} classList={{ "border-l border-border-weaker-base": reviewOpen() }}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
> >
<Tabs.List> <Tabs
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> variant="pill"
{props.reviewCount()}{" "} value={fileTreeTab()}
{language.t( onChange={setFileTreeTabValue}
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", class="h-full"
)} data-scope="filetree"
</Tabs.Trigger> >
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> <Tabs.List>
{language.t("session.files.all")} <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
</Tabs.Trigger> {props.reviewCount()}{" "}
</Tabs.List> {language.t(
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0"> props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
<Switch> )}
<Match when={props.hasReview() || !props.diffsReady()}> </Tabs.Trigger>
<Show <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
when={props.diffsReady()} {language.t("session.files.all")}
fallback={ </Tabs.Trigger>
<div class="px-2 py-2 text-12-regular text-text-weak"> </Tabs.List>
{language.t("common.loading")} <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
{language.t("common.loading.ellipsis")} <Switch>
</div> <Match when={props.hasReview() || !props.diffsReady()}>
} <Show
> when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree <FileTree
path="" path=""
class="pt-3" class="pt-3"
allowed={diffFiles()} modified={diffFiles()}
kinds={kinds()} kinds={kinds()}
draggable={false} onFileClick={(node) => openTab(file.tab(node.path))}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/> />
</Show> </Match>
</Match> </Switch>
<Match when={true}>{empty(props.empty())}</Match> </Tabs.Content>
</Switch> </Tabs>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div> </div>
</Show> <Show when={fileOpen()}>
</div> <div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</Show>
</div>
</Show>
</div> </div>
</aside> </aside>
</Show> </Show>

View file

@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage() const language = useLanguage()
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform()
const prompt = usePrompt() const prompt = usePrompt()
const sdk = useSDK() const sdk = useSDK()
const settings = useSettings()
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const layout = useLayout() const layout = useLayout()
@ -66,6 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}) })
const activeFileTab = tabState.activeFileTab const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab const closableTab = tabState.closableTab
const shown = () =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree()
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle const status = () => sync.data.session_status[params.id ?? ""] ?? idle
@ -457,12 +465,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "mod+shift+r", keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(), onSelect: () => view().reviewPanel.toggle(),
}), }),
viewCommand({ ...(shown()
id: "fileTree.toggle", ? [
title: language.t("command.fileTree.toggle"), viewCommand({
keybind: "mod+\\", id: "fileTree.toggle",
onSelect: () => layout.fileTree.toggle(), title: language.t("command.fileTree.toggle"),
}), keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
]
: []),
viewCommand({ viewCommand({
id: "input.focus", id: "input.focus",
title: language.t("command.input.focus"), title: language.t("command.input.focus"),

View file

@ -469,7 +469,7 @@ export function persisted<T>(
state, state,
setState, setState,
init, init,
Object.assign(() => ready() === true, { Object.assign(() => (ready.loading ? false : ready.latest === true), {
promise: init instanceof Promise ? init : undefined, promise: init instanceof Promise ? init : undefined,
}), }),
] ]

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.6", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -45,6 +45,7 @@ import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
import { i18n, type Key } from "~/i18n" import { i18n, type Key } from "~/i18n"
import { localeFromRequest } from "~/lib/language" import { localeFromRequest } from "~/lib/language"
import { createModelTpmLimiter } from "./modelTpmLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>> type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = { type RetryOptions = {
@ -121,6 +122,8 @@ export async function handler(
const authInfo = await authenticate(modelInfo, zenApiKey) const authInfo = await authenticate(modelInfo, zenApiKey)
const billingSource = validateBilling(authInfo, modelInfo) const billingSource = validateBilling(authInfo, modelInfo)
logger.metric({ source: billingSource }) logger.metric({ source: billingSource })
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
const modelTpmLimits = await modelTpmLimiter?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider( const providerInfo = selectProvider(
@ -133,6 +136,7 @@ export async function handler(
trialProviders, trialProviders,
retry, retry,
stickyProvider, stickyProvider,
modelTpmLimits,
) )
validateModelSettings(billingSource, authInfo) validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo) updateProviderKey(authInfo, providerInfo)
@ -229,6 +233,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(json.usage) const usageInfo = providerInfo.normalizeUsage(json.usage)
const costInfo = calculateCost(modelInfo, usageInfo) const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo) await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo) json.cost = calculateOccurredCost(billingSource, costInfo)
@ -278,6 +283,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(usage) const usageInfo = providerInfo.normalizeUsage(usage)
const costInfo = calculateCost(modelInfo, usageInfo) const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo) await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo) const cost = calculateOccurredCost(billingSource, costInfo)
@ -433,12 +439,16 @@ export async function handler(
trialProviders: string[] | undefined, trialProviders: string[] | undefined,
retry: RetryOptions, retry: RetryOptions,
stickyProvider: string | undefined, stickyProvider: string | undefined,
modelTpmLimits: Record<string, number> | undefined,
) { ) {
const modelProvider = (() => { const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it
// instead of using the sticky provider for the same session
if (authInfo?.provider?.credentials) { if (authInfo?.provider?.credentials) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider) return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
} }
// Always use the same provider for the same session
if (stickyProvider) { if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider) const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
if (provider) return provider if (provider) return provider
@ -451,10 +461,20 @@ export async function handler(
} }
if (retry.retryCount !== MAX_FAILOVER_RETRIES) { if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
const providers = modelInfo.providers const allProviders = modelInfo.providers
.filter((provider) => !provider.disabled) .filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id)) .filter((provider) => !retry.excludeProviders.includes(provider.id))
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider)) .filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
const topPriority = Math.min(...allProviders.map((p) => p.priority))
const providers = allProviders
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use the last 4 characters of session ID to select a provider // Use the last 4 characters of session ID to select a provider
const identifier = sessionId.length ? sessionId : ip const identifier = sessionId.length ? sessionId : ip

View file

@ -0,0 +1,51 @@
import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { ModelRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
const keys = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
if (keys.length === 0) return
const yyyyMMddHHmm = new Date(Date.now())
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 12)
return {
check: async () => {
const data = await Database.use((tx) =>
tx
.select()
.from(ModelRateLimitTable)
.where(and(inArray(ModelRateLimitTable.key, keys), eq(ModelRateLimitTable.interval, yyyyMMddHHmm))),
)
// convert to map of model to count
return data.reduce(
(acc, curr) => {
acc[curr.key] = curr.count
return acc
},
{} as Record<string, number>,
)
},
track: async (id: string, model: string, usageInfo: UsageInfo) => {
const key = `${id}/${model}`
if (!keys.includes(key)) return
const usage =
usageInfo.inputTokens +
usageInfo.outputTokens +
(usageInfo.reasoningTokens ?? 0) +
(usageInfo.cacheReadTokens ?? 0) +
(usageInfo.cacheWrite5mTokens ?? 0) +
(usageInfo.cacheWrite1hTokens ?? 0)
if (usage <= 0) return
await Database.use((tx) =>
tx
.insert(ModelRateLimitTable)
.values({ key, interval: yyyyMMddHHmm, count: usage })
.onDuplicateKeyUpdate({ set: { count: sql`${ModelRateLimitTable.count} + ${usage}` } }),
)
},
}
}

View file

@ -0,0 +1,6 @@
CREATE TABLE `model_rate_limit` (
`key` varchar(255) NOT NULL,
`interval` varchar(40) NOT NULL,
`count` int NOT NULL,
CONSTRAINT PRIMARY KEY(`key`,`interval`)
);

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.6", "version": "1.4.10",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -34,6 +34,8 @@ export namespace ZenData {
z.object({ z.object({
id: z.string(), id: z.string(),
model: z.string(), model: z.string(),
priority: z.number().optional(),
tpmLimit: z.number().optional(),
weight: z.number().optional(), weight: z.number().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
storeModel: z.string().optional(), storeModel: z.string().optional(),
@ -123,10 +125,16 @@ export namespace ZenData {
), ),
models: (() => { models: (() => {
const normalize = (model: z.infer<typeof ModelSchema>) => { const normalize = (model: z.infer<typeof ModelSchema>) => {
const composite = model.providers.find((p) => compositeProviders[p.id].length > 1) const providers = model.providers.map((p) => ({
...p,
priority: p.priority ?? Infinity,
weight: p.weight ?? 1,
}))
const composite = providers.find((p) => compositeProviders[p.id].length > 1)
if (!composite) if (!composite)
return { return {
trialProvider: model.trialProvider ? [model.trialProvider] : undefined, trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
providers,
} }
const weightMulti = compositeProviders[composite.id].length const weightMulti = compositeProviders[composite.id].length
@ -137,17 +145,16 @@ export namespace ZenData {
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id) if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
return [model.trialProvider] return [model.trialProvider]
})(), })(),
providers: model.providers.flatMap((p) => providers: providers.flatMap((p) =>
p.id === composite.id p.id === composite.id
? compositeProviders[p.id].map((sub) => ({ ? compositeProviders[p.id].map((sub) => ({
...p, ...p,
id: sub.id, id: sub.id,
weight: p.weight ?? 1,
})) }))
: [ : [
{ {
...p, ...p,
weight: (p.weight ?? 1) * weightMulti, weight: p.weight * weightMulti,
}, },
], ],
), ),

View file

@ -30,3 +30,13 @@ export const KeyRateLimitTable = mysqlTable(
}, },
(table) => [primaryKey({ columns: [table.key, table.interval] })], (table) => [primaryKey({ columns: [table.key, table.interval] })],
) )
export const ModelRateLimitTable = mysqlTable(
"model_rate_limit",
{
key: varchar("key", { length: 255 }).notNull(),
interval: varchar("interval", { length: 40 }).notNull(),
count: int("count").notNull(),
},
(table) => [primaryKey({ columns: [table.key, table.interval] })],
)

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.6", "version": "1.4.10",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.6", "version": "1.4.10",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.4.6", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.4.6", "version": "1.4.10",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.6", "version": "1.4.10",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.4.6" version = "1.4.10"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.6", "version": "1.4.10",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -9,6 +9,63 @@
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`. - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`). - **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
# Module shape
Do not use `export namespace Foo { ... }` for module organization. It is not
standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
runner. Use flat top-level exports combined with a self-reexport at the bottom
of the file:
```ts
// src/foo/foo.ts
export interface Interface { ... }
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(Service, ...)
export const defaultLayer = layer.pipe(...)
export * as Foo from "./foo"
```
Consumers import the namespace projection:
```ts
import { Foo } from "@/foo/foo"
yield * Foo.Service
Foo.layer
Foo.defaultLayer
```
Namespace-private helpers stay as non-exported top-level declarations in the
same file — they remain inaccessible to consumers (they are not projected by
`export * as`) but are usable by the file's own code.
## When the file is an `index.ts`
If the module is `foo/index.ts` (single-namespace directory), use `"."` for
the self-reexport source rather than `"./index"`:
```ts
// src/foo/index.ts
export const thing = ...
export * as Foo from "."
```
## Multi-sibling directories
For directories with several independent modules (e.g. `src/session/`,
`src/config/`), keep each sibling as its own file with its own self-reexport,
and do not add a barrel `index.ts`. Consumers import the specific sibling:
```ts
import { SessionRetry } from "@/session/retry"
import { SessionStatus } from "@/session/status"
```
Barrels in multi-sibling directories force every import through the barrel to
evaluate every sibling, which defeats tree-shaking and slows module load.
# opencode Effect rules # opencode Effect rules
Use these rules when writing or migrating Effect code. Use these rules when writing or migrating Effect code.
@ -23,6 +80,10 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
- Use `Effect.callback` for callback-based APIs. - Use `Effect.callback` for callback-based APIs.
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
## Module conventions
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
## Schemas and errors ## Schemas and errors
- Use `Schema.Class` for multi-field data. - Use `Schema.Class` for multi-field data.

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.4.6", "version": "1.4.10",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -80,15 +80,15 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.97", "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.109", "@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.53",
@ -123,8 +123,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99", "@opentui/core": "catalog:",
"@opentui/solid": "0.1.99", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@ -144,7 +144,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2", "gitlab-ai-provider": "6.6.0",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",

View file

@ -1,3 +1,5 @@
#!/usr/bin/env bun
import path from "path" import path from "path"
const toDynamicallyImport = path.join(process.cwd(), process.argv[2]) const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
await import(toDynamicallyImport) await import(toDynamicallyImport)

View file

@ -1,305 +0,0 @@
#!/usr/bin/env bun
/**
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
*
* Usage:
* bun script/unwrap-namespace.ts src/bus/index.ts
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
*
* What it does:
* 1. Reads the file and finds the `export namespace Foo { ... }` block
* (uses ast-grep for accurate AST-based boundary detection)
* 2. Removes the namespace wrapper and dedents the body
* 3. Fixes self-references (e.g. Config.PermissionAction PermissionAction)
* 4. If the file is index.ts, renames it to <lowercase-name>.ts
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
* 6. Rewrites import paths across src/, test/, and script/
* 7. Fixes sibling imports within the same directory
*
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
*/
import path from "path"
import fs from "fs"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const nameFlag = args.find((a, i) => args[i - 1] === "--name")
const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
if (!filePath) {
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
process.exit(1)
}
const absPath = path.resolve(filePath)
if (!fs.existsSync(absPath)) {
console.error(`File not found: ${absPath}`)
process.exit(1)
}
const src = fs.readFileSync(absPath, "utf-8")
const lines = src.split("\n")
// Use ast-grep to find the namespace boundaries accurately.
// This avoids false matches from braces in strings, templates, comments, etc.
const astResult = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" },
)
if (astResult.exitCode !== 0) {
console.error("ast-grep failed:", astResult.stderr.toString())
process.exit(1)
}
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
text: string
range: { start: { line: number; column: number }; end: { line: number; column: number } }
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
}>
if (matches.length === 0) {
console.error("No `export namespace Foo { ... }` found in file")
process.exit(1)
}
if (matches.length > 1) {
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
console.error("Namespaces found:")
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
process.exit(1)
}
const match = matches[0]
const nsName = match.metaVariables.single.NAME.text
const nsLine = match.range.start.line // 0-indexed
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
console.log(`Found: export namespace ${nsName} { ... }`)
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
// Build the new file content:
// 1. Everything before the namespace declaration (imports, etc.)
// 2. The namespace body, dedented by one level (2 spaces)
// 3. Everything after the closing brace (rare, but possible)
const before = lines.slice(0, nsLine)
const body = lines.slice(nsLine + 1, closeLine)
const after = lines.slice(closeLine + 1)
// Dedent: remove exactly 2 leading spaces from each line
const dedented = body.map((line) => {
if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2)
return line
})
let newContent = [...before, ...dedented, ...after].join("\n")
// --- Fix self-references ---
// After unwrapping, references like `Config.PermissionAction` inside the same file
// need to become just `PermissionAction`. Only fix code positions, not strings.
const exportedNames = new Set<string>()
const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
for (const line of dedented) {
for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
}
const reExportRegex = /export\s*\{\s*([^}]+)\}/g
for (const line of dedented) {
for (const m of line.matchAll(reExportRegex)) {
for (const name of m[1].split(",")) {
const trimmed = name
.trim()
.split(/\s+as\s+/)
.pop()!
.trim()
if (trimmed) exportedNames.add(trimmed)
}
}
}
let selfRefCount = 0
if (exportedNames.size > 0) {
const fixedLines = newContent.split("\n").map((line) => {
// Split line into string-literal and code segments to avoid replacing inside strings
const segments: Array<{ text: string; isString: boolean }> = []
let i = 0
let current = ""
let inString: string | null = null
while (i < line.length) {
const ch = line[i]
if (inString) {
current += ch
if (ch === "\\" && i + 1 < line.length) {
current += line[i + 1]
i += 2
continue
}
if (ch === inString) {
segments.push({ text: current, isString: true })
current = ""
inString = null
}
i++
continue
}
if (ch === '"' || ch === "'" || ch === "`") {
if (current) segments.push({ text: current, isString: false })
current = ch
inString = ch
i++
continue
}
if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
current += line.slice(i)
segments.push({ text: current, isString: true })
current = ""
i = line.length
continue
}
current += ch
i++
}
if (current) segments.push({ text: current, isString: !!inString })
return segments
.map((seg) => {
if (seg.isString) return seg.text
let result = seg.text
for (const name of exportedNames) {
const pattern = `${nsName}.${name}`
while (result.includes(pattern)) {
const idx = result.indexOf(pattern)
const charBefore = idx > 0 ? result[idx - 1] : " "
const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
selfRefCount++
}
}
return result
})
.join("")
})
newContent = fixedLines.join("\n")
}
// Figure out file naming
const dir = path.dirname(absPath)
const basename = path.basename(absPath, ".ts")
const isIndex = basename === "index"
const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
const implFile = path.join(dir, `${implName}.ts`)
const indexFile = path.join(dir, "index.ts")
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
console.log("")
if (isIndex) {
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
} else {
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
}
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
console.log("")
if (dryRun) {
console.log("--- DRY RUN ---")
console.log("")
console.log(`=== ${implName}.ts (first 30 lines) ===`)
newContent
.split("\n")
.slice(0, 30)
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
console.log(" ...")
console.log("")
console.log(`=== index.ts ===`)
console.log(` ${barrelLine.trim()}`)
console.log("")
if (!isIndex) {
const relDir = path.relative(path.resolve("src"), dir)
console.log(`=== Import rewrites (would apply) ===`)
console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
} else {
console.log("No import rewrites needed (was index.ts)")
}
} else {
if (isIndex) {
fs.writeFileSync(implFile, newContent)
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
console.log(`Wrote index.ts (barrel)`)
} else {
fs.writeFileSync(absPath, newContent)
if (fs.existsSync(indexFile)) {
const existing = fs.readFileSync(indexFile, "utf-8")
if (!existing.includes(`export * as ${nsName}`)) {
fs.appendFileSync(indexFile, barrelLine)
console.log(`Appended to existing index.ts`)
} else {
console.log(`index.ts already has ${nsName} export`)
}
} else {
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote index.ts (barrel)`)
}
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
}
// --- Rewrite import paths across src/, test/, script/ ---
const relDir = path.relative(path.resolve("src"), dir)
if (!isIndex) {
const oldTail = `${relDir}/${basename}`
const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
stdout: "pipe",
stderr: "pipe",
})
const filesToRewrite = rgResult.stdout
.toString()
.trim()
.split("\n")
.filter((f) => f.length > 0)
if (filesToRewrite.length > 0) {
console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
for (const file of filesToRewrite) {
const content = fs.readFileSync(file, "utf-8")
fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
}
console.log(` Done: ${oldTail}" → ${relDir}"`)
} else {
console.log("\nNo import rewrites needed")
}
} else {
console.log("\nNo import rewrites needed (was index.ts)")
}
// --- Fix sibling imports within the same directory ---
const siblingFiles = fs.readdirSync(dir).filter((f) => {
if (!f.endsWith(".ts")) return false
if (f === "index.ts" || f === `${implName}.ts`) return false
return true
})
let siblingFixCount = 0
for (const sibFile of siblingFiles) {
const sibPath = path.join(dir, sibFile)
const content = fs.readFileSync(sibPath, "utf-8")
const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
if (pattern.test(content)) {
fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
siblingFixCount++
}
}
if (siblingFixCount > 0) {
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
}
}
console.log("")
console.log("=== Verify ===")
console.log("")
console.log("bunx --bun tsgo --noEmit # typecheck")
console.log("bun run test # run tests")

View file

@ -189,10 +189,46 @@ Ordering for a route-group migration:
SDK shape rule: SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte - every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema - if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging ### Schema.Class vs Schema.Struct
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
When to use each:
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
Temporary exception: Temporary exception:
@ -365,17 +401,16 @@ Current instance route inventory:
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject` endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged` - `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply` endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial) - `provider` - `bridged`
bridged endpoint: `GET /provider/auth` endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
not yet ported: `GET /provider`, OAuth mutations - `config` - `bridged` (partial)
- `config` - `next` bridged endpoint: `GET /config/providers`
best next endpoint: `GET /config/providers`
later endpoint: `GET /config` later endpoint: `GET /config`
defer `PATCH /config` for now defer `PATCH /config` for now
- `project` - `later` - `project` - `bridged` (partial)
best small reads: `GET /project`, `GET /project/current` bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first defer git-init mutation first
- `workspace` - `later` - `workspace` - `next`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status` best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first defer create/remove mutations first
- `file` - `later` - `file` - `later`
@ -393,12 +428,12 @@ Current instance route inventory:
- `tui` - `defer` - `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence after the first spike: Recommended near-term sequence:
1. `provider` auth read endpoint 1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `config` providers read endpoint 2. `config` full read endpoint (`GET /config`)
3. `project` read endpoints 3. `file` JSON read endpoints
4. `workspace` read endpoints 4. `mcp` JSON read endpoints
## Checklist ## Checklist
@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag - [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel - [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes - [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations) - [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints - [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [ ] port `workspace` read endpoints
- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default - [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb ## Rule of thumb

View file

@ -9,7 +9,7 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`. Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree - Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs - Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
@ -195,7 +195,6 @@ This checklist is only about the service shape migration. Many of these services
- [x] `Config``config/config.ts` - [x] `Config``config/config.ts`
- [x] `Discovery``skill/discovery.ts` (dependency-only layer, no standalone runtime) - [x] `Discovery``skill/discovery.ts` (dependency-only layer, no standalone runtime)
- [x] `File``file/index.ts` - [x] `File``file/index.ts`
- [x] `FileTime``file/time.ts`
- [x] `FileWatcher``file/watcher.ts` - [x] `FileWatcher``file/watcher.ts`
- [x] `Format``format/index.ts` - [x] `Format``format/index.ts`
- [x] `Installation``installation/index.ts` - [x] `Installation``installation/index.ts`
@ -301,7 +300,6 @@ For each service, the migration is roughly:
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed. - `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. - `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed. - `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed. - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed. - `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed. - `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.

View file

@ -1,499 +0,0 @@
# Namespace → flat export migration
Migrate `export namespace` to the `export * as` / flat-export pattern used by
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
conventions, LLM-friendliness for future migrations.
## What changes and what doesn't
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
What changes is **how** the namespace is constructed — the TypeScript
`export namespace` keyword is replaced by `export * as` in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import `{ Provider }` don't notice.
Import paths actually get **nicer**. Today most consumers import from the
explicit file (`"../provider/provider"`). After the migration, each module has a
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
```ts
// BEFORE — points at the file directly
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
```
## Why this matters right now
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript `export namespace` bodies.
### The problem in one sentence
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
`google-auth-library`, and every other top-level import in that 1709-line file.
### Why `export namespace` defeats tree-shaking
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
```js
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
```
This is **opaque to static analysis**. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every `import` statement at the top of
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
### What `export * as` does differently
`export * as Provider from "./provider"` compiles to a static re-export. The
bundler knows the exact shape of `Provider` at compile time — it's the named
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
### Concrete impact
The worst import chain in the codebase:
```
src/index.ts (entry point)
└── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines)
│ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers
│ ├── google-auth-library
│ ├── gitlab-ai-provider, venice-ai-sdk-provider
│ └── fuzzysort, remeda, etc.
├── { Config } from config/config.ts (1663 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
```
All of this gets pulled in to check `.isInstance()` on 6 error classes — code
that needs maybe 200 bytes total. This inflates the binary, increases startup
memory, and slows down initial module evaluation.
### Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
Google's auth library allocates objects, closures, and prototype chains that
persist for the lifetime of the process. Most CLI commands never use a provider
at all.
## What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
### 1. Each module is a separate file with flat named exports
```ts
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
```
### 2. Barrel file uses `export * as` (not `export namespace`)
```ts
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
bundler knows the **exact shape** at compile time — it's the static export list
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
nothing can be dropped.
### 3. `sideEffects: []` and deep imports
```jsonc
// package.json
{ "sideEffects": [] }
```
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
### 4. Errors as flat exports, not class declarations
```ts
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
```
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
## The plan
The core migration is **Phase 1** — convert `export namespace` to
`export * as`. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
lower-risk warmup step that can be done before or after the main conversion, and
it provides extra resilience against bundler edge cases. But the big win comes
from Phase 1.
### Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
2. In the namespace file, re-export from the errors file to maintain backward
compatibility:
```ts
// provider/provider.ts — inside the namespace
export { ModelNotFoundError, InitError } from "./errors"
```
3. Update `cli/error.ts` (and any other light consumers) to import directly:
```ts
// BEFORE
import { Provider } from "../provider/provider"
Provider.ModelNotFoundError.isInstance(input)
// AFTER
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
ProviderModelNotFoundError.isInstance(input)
```
**Files to split (Phase 0):**
| Current file | New errors file | Errors to extract |
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
### Phase 1: The real migration — `export namespace``export * as`
This is the phase that actually fixes tree-shaking. For each module:
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
keep all the members as top-level `export const` / `export function` / etc.
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts`
`bus/bus.ts`), so the barrel can take `index.ts`.
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
The file structure change for a module that's currently a single file:
```
# BEFORE
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
```
And the code change is purely removing the wrapper:
```ts
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter:
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
`Provider.layer` works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
**Once this is done, you don't need to break anything into subfiles for
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
depends on `NamedError` + `zod` + the schema file, and drops
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
`export * as` gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
**Order of conversion** (by risk / size, do small modules first):
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
### Phase 2: Build configuration
After the module structure supports tree-shaking:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
`"sideEffects": false`) — this is safe because our services use explicit
layer composition, not import-time side effects.
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
insufficient, evaluate whether the compiled binary path needs an esbuild
pre-pass.
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
— these are factory functions that return classes, and bundlers may not know
they're side-effect-free without the annotation.
## Automation
The transformation is scripted. From `packages/opencode`:
```bash
bun script/unwrap-namespace.ts <file> [--dry-run]
```
The script uses ast-grep for accurate AST-based namespace boundary detection
(no false matches from braces in strings/templates/comments), then:
1. Removes the `export namespace Foo {` line and its closing `}`
2. Dedents the body by one indent level (2 spaces)
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new
`index.ts` barrel
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts`
5. Prints the exact commands to find and rewrite import paths
### Walkthrough: converting a module
Using `Provider` as an example:
```bash
# 1. Preview what will change
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
# 2. Apply the transformation
bun script/unwrap-namespace.ts src/provider/provider.ts
# 3. Rewrite import paths (script prints the exact command)
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
# 4. Verify
bun typecheck
bun run test
```
**What changes on disk:**
```
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
```
**What changes in consumer code:**
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
```
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.)
stays identical.
### Two cases the script handles
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`)
- Rewrites the file in place (unwrap + dedent)
- Creates `provider/index.ts` as the barrel
- Import paths change: `"../provider/provider"``"../provider"`
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
- Renames `index.ts``bus.ts` (kebab-case of namespace name)
- Creates new `index.ts` as the barrel
- **No import rewrites needed**`"@/bus"` already resolves to `bus/index.ts`
## Do I need to split errors/schemas into subfiles?
**No.** Once you do the `export * as` conversion, the bundler can tree-shake
individual exports within the file. If `cli/error.ts` only accesses
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
doesn't reference `createAnthropic` and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
organization** — smaller files are easier to read and review. But it's not
required for tree-shaking. The `export * as` conversion alone is sufficient.
The one case where subfile splitting provides extra tree-shake value is if an
imported package has module-level side effects that the bundler can't prove are
unused. In practice this is rare — most npm packages are side-effect-free — and
adding `"sideEffects": []` to package.json handles the common cases.
## Scope
| Metric | Count |
| ----------------------------------------------- | --------------- |
| Files with `export namespace` | 106 |
| Total namespace declarations | 118 (12 nested) |
| Files with `NamedError.create` inside namespace | 15 |
| Total error classes to extract | ~30 |
| Files using `export * as` today | 0 |
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
## Rules for new code
Going forward:
- **No new `export namespace`**. Use a file with flat named exports and
`export * as` in the barrel.
- Keep the service, layer, errors, schemas, and runtime wiring together in one
file if you want — that's fine now. The `export * as` barrel makes everything
individually shakeable regardless of file structure.
- If a file grows large enough that it's hard to navigate, split by concern
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
bundler handles that.
## Circular import rules
Barrel files (`index.ts` with `export * as`) introduce circular import risks.
These cause `ReferenceError: Cannot access 'X' before initialization` at
runtime — not caught by the type checker.
### Rule 1: Sibling files never import through their own barrel
Files in the same directory must import directly from the source file, never
through `"."` or `"@/<own-dir>"`:
```ts
// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
import { Sibling } from "."
// GOOD — direct, no cycle
import * as Sibling from "./sibling"
```
### Rule 2: Cross-directory imports must not form cycles through barrels
If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
```
lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
```
Fix by importing the specific file, breaking the cycle:
```ts
// In config/config.ts — import directly, not through the lsp barrel
import * as LSPServer from "../lsp/server"
```
### Why the type checker doesn't catch this
TypeScript resolves types lazily — it doesn't evaluate module-scope
expressions. The `ReferenceError` only happens at runtime when a module-scope
`const` or function call accesses a value from a circular dependency that
hasn't finished initializing. The SDK build step (`bun run --conditions=browser
./src/index.ts generate`) is the reliable way to catch these because it
evaluates all modules eagerly.
### How to verify
After any namespace conversion, run:
```bash
cd packages/opencode
bun run --conditions=browser ./src/index.ts generate
```
If this completes without `ReferenceError`, the module graph is safe.

View file

@ -181,10 +181,10 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const repo = yield* AccountRepo const repo = yield* AccountRepo.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http) const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http) const httpOk = HttpClient.filterStatusOk(http)
@ -452,3 +452,5 @@ export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpCli
) )
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export * as Account from "./account"

View file

@ -1,24 +0,0 @@
export * as Account from "./account"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccountTransportError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
type PollResult,
} from "./schema"
export type { AccountOrgs, ActiveOrg } from "./account"

View file

@ -13,154 +13,154 @@ type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
const ACCOUNT_STATE_ID = 1 const ACCOUNT_STATE_ID = 1
export namespace AccountRepo { export interface Interface {
export interface Service { readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError> readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly list: () => Effect.Effect<Info[], AccountRepoError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError> readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError> readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError> readonly persistToken: (input: {
readonly persistToken: (input: { accountID: AccountID
accountID: AccountID accessToken: AccessToken
accessToken: AccessToken refreshToken: RefreshToken
refreshToken: RefreshToken expiry: Option.Option<number>
expiry: Option.Option<number> }) => Effect.Effect<void, AccountRepoError>
}) => Effect.Effect<void, AccountRepoError> readonly persistAccount: (input: {
readonly persistAccount: (input: { id: AccountID
id: AccountID email: string
email: string url: string
url: string accessToken: AccessToken
accessToken: AccessToken refreshToken: RefreshToken
refreshToken: RefreshToken expiry: number
expiry: number orgID: Option.Option<OrgID>
orgID: Option.Option<OrgID> }) => Effect.Effect<void, AccountRepoError>
}) => Effect.Effect<void, AccountRepoError>
}
} }
export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") { export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: DbTransactionCallback<A>) => export const layer: Layer.Layer<Service> = Layer.effect(
Effect.try({ Service,
try: () => Database.use(f), Effect.gen(function* () {
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }), const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
const id = Option.getOrNull(orgID)
return db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: id },
}) })
.run()
}
const tx = <A>(f: DbTransactionCallback<A>) => const active = Effect.fn("AccountRepo.active")(() =>
Effect.try({ query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
try: () => Database.transaction(f), )
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => { const list = Effect.fn("AccountRepo.list")(() =>
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() query((db) =>
if (!state?.active_account_id) return db
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() .select()
if (!account) return .from(AccountTable)
return { ...account, active_org_id: state.active_org_id ?? null } .all()
} .map((row: AccountRow) => decode({ ...row, active_org_id: null })),
),
)
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => { const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
const id = Option.getOrNull(orgID) tx((db) => {
return db db.update(AccountStateTable)
.insert(AccountStateTable) .set({ active_account_id: null, active_org_id: null })
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id }) .where(eq(AccountStateTable.active_account_id, accountID))
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: id },
})
.run() .run()
} db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}).pipe(Effect.asVoid),
)
const active = Effect.fn("AccountRepo.active")(() => const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
) )
const list = Effect.fn("AccountRepo.list")(() => const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
query((db) => query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
db Effect.map(Option.fromNullishOr),
.select() ),
.from(AccountTable) )
.all()
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
),
)
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
tx((db) => { query((db) =>
db.update(AccountStateTable) db
.set({ active_account_id: null, active_org_id: null }) .update(AccountTable)
.where(eq(AccountStateTable.active_account_id, accountID)) .set({
.run() access_token: input.accessToken,
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run() refresh_token: input.refreshToken,
}).pipe(Effect.asVoid), token_expiry: Option.getOrNull(input.expiry),
) })
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
)
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) => const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid), tx((db) => {
) const url = normalizeServerUrl(input.url)
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => db.insert(AccountTable)
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( .values({
Effect.map(Option.fromNullishOr), id: input.id,
), email: input.email,
) url,
access_token: input.accessToken,
const persistToken = Effect.fn("AccountRepo.persistToken")((input) => refresh_token: input.refreshToken,
query((db) => token_expiry: input.expiry,
db })
.update(AccountTable) .onConflictDoUpdate({
.set({ target: AccountTable.id,
access_token: input.accessToken, set: {
refresh_token: input.refreshToken,
token_expiry: Option.getOrNull(input.expiry),
})
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
)
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
tx((db) => {
const url = normalizeServerUrl(input.url)
db.insert(AccountTable)
.values({
id: input.id,
email: input.email, email: input.email,
url, url,
access_token: input.accessToken, access_token: input.accessToken,
refresh_token: input.refreshToken, refresh_token: input.refreshToken,
token_expiry: input.expiry, token_expiry: input.expiry,
}) },
.onConflictDoUpdate({ })
target: AccountTable.id, .run()
set: { void state(db, input.id, input.orgID)
email: input.email, }).pipe(Effect.asVoid),
url, )
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
void state(db, input.id, input.orgID)
}).pipe(Effect.asVoid),
)
return AccountRepo.of({ return Service.of({
active, active,
list, list,
remove, remove,
use, use,
getRow, getRow,
persistToken, persistToken,
persistAccount, persistAccount,
}) })
}), }),
) )
}
export * as AccountRepo from "./repo"

File diff suppressed because it is too large Load diff

View file

@ -24,389 +24,388 @@ import { InstanceState } from "@/effect"
import * as Option from "effect/Option" import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer" import * as OtelTracer from "@effect/opentelemetry/Tracer"
export namespace Agent { export const Info = z
export const Info = z .object({
.object({ name: z.string(),
name: z.string(), description: z.string().optional(),
description: z.string().optional(), mode: z.enum(["subagent", "primary", "all"]),
mode: z.enum(["subagent", "primary", "all"]), native: z.boolean().optional(),
native: z.boolean().optional(), hidden: z.boolean().optional(),
hidden: z.boolean().optional(), topP: z.number().optional(),
topP: z.number().optional(), temperature: z.number().optional(),
temperature: z.number().optional(), color: z.string().optional(),
color: z.string().optional(), permission: Permission.Ruleset.zod,
permission: Permission.Ruleset.zod, model: z
model: z .object({
.object({ modelID: ModelID.zod,
modelID: ModelID.zod, providerID: ProviderID.zod,
providerID: ProviderID.zod,
})
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Agent.Info>
readonly list: () => Effect.Effect<Agent.Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[agent]
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config.get()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config.get()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
)
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())
const resolved = yield* provider.getModel(model.providerID, model.modelID)
const language = yield* provider.getLanguage(resolved)
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
const system = [PROMPT_GENERATE]
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
tracer,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...(isOpenaiOauth
? []
: system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
)),
{
role: "user",
content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
if (isOpenaiOauth) {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
}) })
}), .optional(),
) variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
export const defaultLayer = layer.pipe( export interface Interface {
Layer.provide(Plugin.defaultLayer), readonly get: (agent: string) => Effect.Effect<Info>
Layer.provide(Provider.defaultLayer), readonly list: () => Effect.Effect<Info[]>
Layer.provide(Auth.defaultLayer), readonly defaultAgent: () => Effect.Effect<string>
Layer.provide(Config.defaultLayer), readonly generate: (input: {
Layer.provide(Skill.defaultLayer), description: string
) model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
} }
type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[agent]
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config.get()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config.get()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
)
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())
const resolved = yield* provider.getModel(model.providerID, model.modelID)
const language = yield* provider.getLanguage(resolved)
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
const system = [PROMPT_GENERATE]
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
tracer,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...(isOpenaiOauth
? []
: system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
)),
{
role: "user",
content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
if (isOpenaiOauth) {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
export * as Agent from "./agent"

View file

@ -1,89 +0,0 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

View file

@ -1,2 +1,97 @@
export * as Auth from "./auth" import path from "path"
export { OAUTH_DUMMY_KEY } from "./auth" import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
} catch (err) {}
}
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export * as Auth from "."

View file

@ -1,33 +1,33 @@
import z from "zod" import z from "zod"
import type { ZodType } from "zod" import type { ZodType } from "zod"
export namespace BusEvent { export type Definition = ReturnType<typeof define>
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>() const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) { export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = { const result = {
type, type,
properties, properties,
}
registry.set(type, result)
return result
}
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
} }
registry.set(type, result)
return result
} }
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
}
export * as BusEvent from "./bus-event"

View file

@ -1,191 +0,0 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}

View file

@ -1 +1,193 @@
export * as Bus from "./bus" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}
export * as Bus from "."

View file

@ -1,8 +1,8 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account" import { Account } from "@/account/account"
import { type AccountError } from "@/account/schema" import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"

View file

@ -8,6 +8,7 @@ import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth" import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider" import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigMCP } from "../../config/mcp"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
@ -43,7 +44,7 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
type McpEntry = NonNullable<Config.Info["mcp"]>[string] type McpEntry = NonNullable<Config.Info["mcp"]>[string]
type McpConfigured = Config.Mcp type McpConfigured = ConfigMCP.Info
function isMcpConfigured(config: McpEntry): config is McpConfigured { function isMcpConfigured(config: McpEntry): config is McpConfigured {
return typeof config === "object" && config !== null && "type" in config return typeof config === "object" && config !== null && "type" in config
} }
@ -426,7 +427,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
return candidates[0] return candidates[0]
} }
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) { async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
let text = "{}" let text = "{}"
if (await Filesystem.exists(configPath)) { if (await Filesystem.exists(configPath)) {
text = await Filesystem.readText(configPath) text = await Filesystem.readText(configPath)
@ -514,7 +515,7 @@ export const McpAddCommand = cmd({
}) })
if (prompts.isCancel(command)) throw new UI.CancelledError() if (prompts.isCancel(command)) throw new UI.CancelledError()
const mcpConfig: Config.Mcp = { const mcpConfig: ConfigMCP.Info = {
type: "local", type: "local",
command: command.split(" "), command: command.split(" "),
} }
@ -544,7 +545,7 @@ export const McpAddCommand = cmd({
}) })
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: Config.Mcp let mcpConfig: ConfigMCP.Info
if (useOAuth) { if (useOAuth) {
const hasClientId = await prompts.confirm({ const hasClientId = await prompts.confirm({

View file

@ -297,7 +297,9 @@ export const ProvidersLoginCommand = cmd({
prompts.intro("Add credential") prompts.intro("Add credential")
if (args.url) { if (args.url) {
const url = args.url.replace(/\/+$/, "") const url = args.url.replace(/\/+$/, "")
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, { const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe", stdout: "pipe",

View file

@ -149,7 +149,16 @@ export function tui(input: {
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}> <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider> <KVProvider>
<ToastProvider> <ToastProvider>
<RouteProvider> <RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}> <TuiConfigProvider config={input.config}>
<SDKProvider <SDKProvider
url={input.url} url={input.url}
@ -334,7 +343,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
local.model.set({ providerID, modelID }, { recent: true }) local.model.set({ providerID, modelID }, { recent: true })
} }
// Handle --session without --fork immediately (fork is handled in createEffect below)
if (args.sessionID && !args.fork) { if (args.sessionID && !args.fork) {
route.navigate({ route.navigate({
type: "session", type: "session",
@ -421,12 +429,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
aliases: ["clear"], aliases: ["clear"],
}, },
onSelect: () => { onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
route.navigate({ route.navigate({
type: "home", type: "home",
initialPrompt: currentPrompt,
}) })
dialog.clear() dialog.clear()
}, },
@ -603,7 +607,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System", category: "System",
}, },
{ {
title: "Toggle theme mode", title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode", value: "theme.switch_mode",
onSelect: (dialog) => { onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark") setMode(mode() === "dark" ? "light" : "dark")

View file

@ -0,0 +1,130 @@
import { BoxRenderable, RGBA } from "@opentui/core"
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
import { tint, useTheme } from "@tui/context/theme"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
const PHASE_OFFSET = 0.29
export type BgPulseMask = {
x: number
y: number
width: number
height: number
pad?: number
strength?: number
}
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
const { theme } = useTheme()
const [now, setNow] = createSignal(performance.now())
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
let box: BoxRenderable | undefined
const timer = setInterval(() => setNow(performance.now()), 50)
onCleanup(() => clearInterval(timer))
const sync = () => {
if (!box) return
setSize({ width: box.width, height: box.height })
}
onMount(() => {
sync()
box?.on("resize", sync)
})
onCleanup(() => {
box?.off("resize", sync)
})
const grid = createMemo(() => {
const t = now()
const w = size().width
const h = size().height
if (w === 0 || h === 0) return [] as RGBA[][]
const cxv = props.centerX ?? w / 2
const cyv = props.centerY ?? h / 2
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
const ringStates = Array.from({ length: RINGS }, (_, i) => {
const offset = i / RINGS
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
return {
head: phase * reach,
eased,
}
})
const normalizedMasks = props.masks?.map((m) => {
const pad = m.pad ?? 2
return {
left: m.x - pad,
right: m.x + m.width + pad,
top: m.y - pad,
bottom: m.y + m.height + pad,
pad,
strength: m.strength ?? 0.85,
}
})
const rows = [] as RGBA[][]
for (let y = 0; y < h; y++) {
const row = [] as RGBA[]
for (let x = 0; x < w; x++) {
const dx = x + 0.5 - cxv
const dy = (y + 0.5 - cyv) * 2
const dist = Math.hypot(dx, dy)
let level = 0
for (const ring of ringStates) {
const delta = dist - ring.head
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
level += (crest * AMP + tail * TAIL_AMP) * ring.eased
}
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
let maskAtten = 1
if (normalizedMasks) {
for (const m of normalizedMasks) {
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
const inX = Math.min(x - m.left, m.right - x)
const inY = Math.min(y - m.top, m.bottom - y)
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
const eased = edge * edge * (3 - 2 * edge)
const reduce = 1 - m.strength * eased
if (reduce < maskAtten) maskAtten = reduce
}
}
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
}
rows.push(row)
}
return rows
})
return (
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
<For each={grid()}>
{(row) => (
<box flexDirection="row">
<For each={row}>
{(color) => (
<text bg={color} fg={color} selectable={false}>
{" "}
</text>
)}
</For>
</box>
)}
</For>
</box>
)
}

View file

@ -63,6 +63,7 @@ function init() {
useKeyboard((evt) => { useKeyboard((evt) => {
if (suspended()) return if (suspended()) return
if (dialog.stack.length > 0) return if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) { for (const option of entries()) {
if (!isEnabled(option)) continue if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) { if (option.keybind && keybind.match(option.keybind, evt)) {

View file

@ -1,12 +1,16 @@
import { RGBA, TextAttributes } from "@opentui/core" import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import open from "open" import open from "open"
import { createSignal } from "solid-js" import { createSignal, onCleanup, onMount } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link" import { Link } from "@tui/ui/link"
import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
const GO_URL = "https://opencode.ai/go" const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
export type DialogGoUpsellProps = { export type DialogGoUpsellProps = {
onClose?: (dontShowAgain?: boolean) => void onClose?: (dontShowAgain?: boolean) => void
@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const fg = selectedForeground(theme) const fg = selectedForeground(theme)
const [selected, setSelected] = createSignal(0) const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
let content: BoxRenderable | undefined
let logoBox: BoxRenderable | undefined
let headingBox: BoxRenderable | undefined
let descBox: BoxRenderable | undefined
let buttonsBox: BoxRenderable | undefined
const sync = () => {
if (!content || !logoBox) return
setCenter({
x: logoBox.x - content.x + logoBox.width / 2,
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
})
const next: BgPulseMask[] = []
const baseY = PAD_TOP_OUTER
for (const b of [headingBox, descBox, buttonsBox]) {
if (!b) continue
next.push({
x: b.x - content.x,
y: b.y - content.y + baseY,
width: b.width,
height: b.height,
pad: 2,
strength: 0.78,
})
}
setMasks(next)
}
onMount(() => {
sync()
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
})
onCleanup(() => {
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
})
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === 0 ? 1 : 0)) setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
return return
} }
if (evt.name !== "return") return if (evt.name === "return") {
if (selected() === 0) subscribe(props, dialog) if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog) else dismiss(props, dialog)
}
}) })
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box ref={(item: BoxRenderable) => (content = item)}>
<box flexDirection="row" justifyContent="space-between"> <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<text attributes={TextAttributes.BOLD} fg={theme.text}> <BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box> </box>
<box gap={1} paddingBottom={1}> <box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<text fg={theme.textMuted}> <box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at <text attributes={TextAttributes.BOLD} fg={theme.text}>
$5/month. Free limit reached
</text> </text>
<box flexDirection="row" gap={1}> <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
<box flexDirection="row">
<text fg={theme.textMuted}>Subscribe to </text>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
OpenCode Go
</text>
<text fg={theme.textMuted}> for reliable access to the</text>
</box>
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
</box>
<box alignItems="center" gap={1} paddingBottom={1}>
<box ref={(item: BoxRenderable) => (logoBox = item)}>
<GoLogo />
</box>
<Link href={GO_URL} fg={theme.primary} /> <Link href={GO_URL} fg={theme.primary} />
</box> </box>
</box> <box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}> <box
<box paddingLeft={2}
paddingLeft={3} paddingRight={2}
paddingRight={3} backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)} onMouseOver={() => setSelected("dismiss")}
onMouseOver={() => setSelected(0)} onMouseUp={() => dismiss(props, dialog)}
onMouseUp={() => subscribe(props, dialog)}
>
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
subscribe
</text>
</box>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(1)}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === 1 ? fg : theme.textMuted}
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
> >
don't show again <text
</text> fg={selected() === "dismiss" ? fg : theme.textMuted}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("subscribe")}
onMouseUp={() => subscribe(props, dialog)}
>
<text
fg={selected() === "subscribe" ? fg : theme.text}
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
>
subscribe
</text>
</box>
</box> </box>
</box> </box>
</box> </box>

View file

@ -0,0 +1,101 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
void confirm()
}
if (evt.name === "left" || evt.name === "up") {
setStore("active", "delete")
}
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Failed to Delete Session
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.textMuted} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
>
{item.title}
</text>
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}

View file

@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util" import { Keybind } from "@/util"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
@ -30,7 +32,7 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults] = createResource(search, async (query) => { const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 }) const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? [] return result.data ?? []
@ -56,11 +58,66 @@ export function DialogSessionList() {
)) ))
} }
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
return false
}}
/>
))
}
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
return sessions() return sessions()
.filter((x) => x.parentID === undefined) .filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated) .toSorted((a, b) => {
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
if (updatedDay !== 0) return updatedDay
return b.time.created - a.time.created
})
.map((x) => { .map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
@ -82,15 +139,10 @@ export function DialogSessionList() {
{desc}{" "} {desc}{" "}
<span <span
style={{ style={{
fg: fg: workspaceStatus === "connected" ? theme.success : theme.error,
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}} }}
> >
</span> </span>
</> </>
) )
@ -145,9 +197,43 @@ export function DialogSessionList() {
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
void sdk.client.session.delete({ const session = sessions().find((item) => item.id === option.value)
sessionID: option.value, const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
})
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
if (search()) await refetch()
setToDelete(undefined) setToDelete(undefined)
return return
} }

View file

@ -6,6 +6,8 @@ import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js" import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { errorData, errorMessage } from "@/util/error"
import * as Log from "@/util/log"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
@ -15,6 +17,8 @@ type Adaptor = {
description: string description: string
} }
const log = Log.Default.clone().tag("service", "tui-workspace")
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) { function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({ return createOpencodeClient({
baseUrl: sdk.url, baseUrl: sdk.url,
@ -33,8 +37,18 @@ export async function openWorkspaceSession(input: {
workspaceID: string workspaceID: string
}) { }) {
const client = scoped(input.sdk, input.sync, input.workspaceID) const client = scoped(input.sdk, input.sync, input.workspaceID)
log.info("workspace session create requested", {
workspaceID: input.workspaceID,
})
while (true) { while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
log.error("workspace session create request failed", {
workspaceID: input.workspaceID,
error: errorData(err),
})
return undefined
})
if (!result) { if (!result) {
input.toast.show({ input.toast.show({
message: "Failed to create workspace session", message: "Failed to create workspace session",
@ -42,26 +56,119 @@ export async function openWorkspaceSession(input: {
}) })
return return
} }
if (result.response.status >= 500 && result.response.status < 600) { log.info("workspace session create response", {
workspaceID: input.workspaceID,
status: result.response?.status,
sessionID: result.data?.id,
})
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
log.warn("workspace session create retrying after server error", {
workspaceID: input.workspaceID,
status: result.response.status,
})
await sleep(1000) await sleep(1000)
continue continue
} }
if (!result.data) { if (!result.data) {
log.error("workspace session create returned no data", {
workspaceID: input.workspaceID,
status: result.response?.status,
})
input.toast.show({ input.toast.show({
message: "Failed to create workspace session", message: "Failed to create workspace session",
variant: "error", variant: "error",
}) })
return return
} }
input.route.navigate({ input.route.navigate({
type: "session", type: "session",
sessionID: result.data.id, sessionID: result.data.id,
}) })
log.info("workspace session create complete", {
workspaceID: input.workspaceID,
sessionID: result.data.id,
})
input.dialog.clear() input.dialog.clear()
return return
} }
} }
export async function restoreWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string
sessionID: string
done?: () => void
}) {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.catch((err) => {
log.error("session restore request failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
return undefined
})
if (!result?.data) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result?.response?.status,
error: result?.error ? errorData(result.error) : undefined,
})
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
log.info("session restore response", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result.response?.status,
total: result.data.total,
})
input.project.workspace.set(input.workspaceID)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
})
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total: result.data.total,
})
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return
input.dialog.clear()
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) { export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog() const dialog = useDialog()
const sync = useSync() const sync = useSync()
@ -123,18 +230,47 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const create = async (type: string) => { const create = async (type: string) => {
if (creating()) return if (creating()) return
setCreating(type) setCreating(type)
log.info("workspace create requested", {
type,
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
log.error("workspace create request failed", {
type,
error: errorData(err),
})
return undefined
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data const workspace = result?.data
if (!workspace) { if (!workspace) {
setCreating(undefined) setCreating(undefined)
log.error("workspace create failed", {
type,
status: result?.response.status,
error: result?.error ? errorData(result.error) : undefined,
})
toast.show({ toast.show({
message: "Failed to create workspace", message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error", variant: "error",
}) })
return return
} }
log.info("workspace create response", {
type,
workspaceID: workspace.id,
status: result.response?.status,
})
await project.workspace.sync() await project.workspace.sync()
log.info("workspace create synced", {
type,
workspaceID: workspace.id,
})
await props.onSelect(workspace.id) await props.onSelect(workspace.id)
setCreating(undefined) setCreating(undefined)
} }

View file

@ -0,0 +1,81 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "restore" as "cancel" | "restore",
})
const options = ["cancel", "restore"] as const
async function confirm() {
if (store.active === "cancel") {
dialog.clear()
return
}
const result = await props.onRestore?.()
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Workspace Unavailable
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
This session is attached to a workspace that is no longer available.
</text>
<text fg={theme.textMuted} wrapMode="word">
Would you like to restore this session into a new workspace?
</text>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
void confirm()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}

View file

@ -1,8 +1,61 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme" import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound" import * as Sound from "@tui/util/sound"
import { logo } from "@/cli/logo" import { go, logo } from "@/cli/logo"
export type LogoShape = {
left: string[]
right: string[]
}
type ShimmerConfig = {
period: number
rings: number
sweepFraction: number
coreWidth: number
coreAmp: number
softWidth: number
softAmp: number
tail: number
tailAmp: number
haloWidth: number
haloOffset: number
haloAmp: number
breathBase: number
noise: number
ambientAmp: number
ambientCenter: number
ambientWidth: number
shadowMix: number
primaryMix: number
originX: number
originY: number
}
const shimmerConfig: ShimmerConfig = {
period: 4600,
rings: 2,
sweepFraction: 1,
coreWidth: 1.2,
coreAmp: 1.9,
softWidth: 10,
softAmp: 1.6,
tail: 5,
tailAmp: 0.64,
haloWidth: 4.3,
haloOffset: 0.6,
haloAmp: 0.16,
breathBase: 0.04,
noise: 0.1,
ambientAmp: 0.36,
ambientCenter: 0.5,
ambientWidth: 0.34,
shadowMix: 0.1,
primaryMix: 0.3,
originX: 4.5,
originY: 13.5,
}
// Shadow markers (rendered chars in parens): // Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow) // _ = full shadow cell (space with bg=shadow)
@ -74,9 +127,6 @@ type Frame = {
spark: number spark: number
} }
const LEFT = logo.left[0]?.length ?? 0
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
const NEAR = [ const NEAR = [
[1, 0], [1, 0],
[1, 1], [1, 1],
@ -140,7 +190,7 @@ function noise(x: number, y: number, t: number) {
} }
function lit(char: string) { function lit(char: string) {
return char !== " " && char !== "_" && char !== "~" return char !== " " && char !== "_" && char !== "~" && char !== ","
} }
function key(x: number, y: number) { function key(x: number, y: number) {
@ -188,12 +238,12 @@ function route(list: Array<{ x: number; y: number }>) {
return path return path
} }
function mapGlyphs() { function mapGlyphs(full: string[]) {
const cells = [] as Array<{ x: number; y: number }> const cells = [] as Array<{ x: number; y: number }>
for (let y = 0; y < FULL.length; y++) { for (let y = 0; y < full.length; y++) {
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) { for (let x = 0; x < (full[y]?.length ?? 0); x++) {
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y }) if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
} }
} }
@ -237,9 +287,25 @@ function mapGlyphs() {
return { glyph, trace, center } return { glyph, trace, center }
} }
const MAP = mapGlyphs() type LogoContext = {
LEFT: number
FULL: string[]
SPAN: number
MAP: ReturnType<typeof mapGlyphs>
shape: LogoShape
}
function shimmer(x: number, y: number, frame: Frame) { function build(shape: LogoShape): LogoContext {
const LEFT = shape.left[0]?.length ?? 0
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
}
const DEFAULT = build(logo)
const GO = build(go)
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
return frame.list.reduce((best, item) => { return frame.list.reduce((best, item) => {
const age = frame.t - item.at const age = frame.t - item.at
if (age < SHIMMER_IN || age > LIFE) return best if (age < SHIMMER_IN || age > LIFE) return best
@ -247,7 +313,7 @@ function shimmer(x: number, y: number, frame: Frame) {
const dy = y * 2 + 1 - item.y const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
const p = age / LIFE const p = age / LIFE
const r = SPAN * (1 - (1 - p) ** EXPAND) const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const lag = r - dist const lag = r - dist
if (lag < 0.18 || lag > SHIMMER_OUT) return best if (lag < 0.18 || lag > SHIMMER_OUT) return best
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2)) const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
@ -258,19 +324,19 @@ function shimmer(x: number, y: number, frame: Frame) {
}, 0) }, 0)
} }
function remain(x: number, y: number, item: Release, t: number) { function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
const age = t - item.at const age = t - item.at
if (age < 0 || age > LIFE) return 0 if (age < 0 || age > LIFE) return 0
const p = age / LIFE const p = age / LIFE
const dx = x + 0.5 - item.x - 0.5 const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1 const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND) const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
if (dist > r) return 1 if (dist > r) return 1
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0) return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
} }
function wave(x: number, y: number, frame: Frame, live: boolean) { function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
return frame.list.reduce((sum, item) => { return frame.list.reduce((sum, item) => {
const age = frame.t - item.at const age = frame.t - item.at
if (age < 0 || age > LIFE) return sum if (age < 0 || age > LIFE) return sum
@ -278,7 +344,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
const dx = x + 0.5 - item.x const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND) const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const fade = (1 - p) ** 1.32 const fade = (1 - p) ** 1.32
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52 const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
@ -292,7 +358,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
}, 0) }, 0)
} }
function field(x: number, y: number, frame: Frame) { function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold const held = frame.hold
const rest = frame.release const rest = frame.release
const item = held ?? rest const item = held ?? rest
@ -326,11 +392,11 @@ function field(x: number, y: number, frame: Frame) {
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) * Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
Math.exp(-(dist * dist) / 0.15) * Math.exp(-(dist * dist) / 0.15) *
lerp(0.08, 0.42, body) lerp(0.08, 0.42, body)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
} }
function pick(x: number, y: number, frame: Frame) { function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold const held = frame.hold
const rest = frame.release const rest = frame.release
const item = held ?? rest const item = held ?? rest
@ -339,26 +405,26 @@ function pick(x: number, y: number, frame: Frame) {
const dx = x + 0.5 - item.x - 0.5 const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1 const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
} }
function select(x: number, y: number) { function select(x: number, y: number, ctx: LogoContext) {
const direct = MAP.glyph.get(key(x, y)) const direct = ctx.MAP.glyph.get(key(x, y))
if (direct !== undefined) return direct if (direct !== undefined) return direct
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find( const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
(item): item is number => item !== undefined, (item): item is number => item !== undefined,
) )
return near return near
} }
function trace(x: number, y: number, frame: Frame) { function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold const held = frame.hold
const rest = frame.release const rest = frame.release
const item = held ?? rest const item = held ?? rest
if (!item || item.glyph === undefined) return 0 if (!item || item.glyph === undefined) return 0
const step = MAP.trace.get(key(x, y)) const step = ctx.MAP.trace.get(key(x, y))
if (!step || step.glyph !== item.glyph || step.l < 2) return 0 if (!step || step.glyph !== item.glyph || step.l < 2) return 0
const age = frame.t - item.at const age = frame.t - item.at
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
@ -368,29 +434,125 @@ function trace(x: number, y: number, frame: Frame) {
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head)) const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
const tail = (head - TAIL + step.l) % step.l const tail = (head - TAIL + step.l) % step.l
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail)) const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise) const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise) const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise) const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
return (core + glow + trail) * appear * fade return (core + glow + trail) * appear * fade
} }
function bloom(x: number, y: number, frame: Frame) { function idle(
x: number,
pixelY: number,
frame: Frame,
ctx: LogoContext,
state: IdleState,
): { glow: number; peak: number; primary: number } {
const cfg = state.cfg
const dx = x + 0.5 - cfg.originX
const dy = pixelY - cfg.originY
const dist = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
const traveled = dist + jitter
let glow = 0
let peak = 0
let halo = 0
let primary = 0
let ambient = 0
for (const active of state.active) {
const head = active.head
const eased = active.eased
const delta = traveled - head
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
// so adjacent pixels have smaller brightness deltas
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
const tailRange = cfg.tail * 2.6
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
const haloDelta = delta + cfg.haloOffset
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
peak += core * cfg.coreAmp * eased
halo += haloBand * cfg.haloAmp * eased
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
primary += (haloBand + tail * 0.6) * eased
ambient += active.ambient
}
ambient /= state.rings
return {
glow: glow / state.rings,
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
primary: (primary / state.rings) * cfg.primaryMix,
}
}
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
const item = frame.glow const item = frame.glow
if (!item) return 0 if (!item) return 0
const glyph = MAP.glyph.get(key(x, y)) const glyph = ctx.MAP.glyph.get(key(x, y))
if (glyph !== item.glyph) return 0 if (glyph !== item.glyph) return 0
const age = frame.t - item.at const age = frame.t - item.at
if (age < 0 || age > GLOW_OUT) return 0 if (age < 0 || age > GLOW_OUT) return 0
const p = age / GLOW_OUT const p = age / GLOW_OUT
const flash = (1 - p) ** 2 const flash = (1 - p) ** 2
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2)) const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
} }
export function Logo() { type IdleState = {
cfg: ShimmerConfig
reach: number
rings: number
active: Array<{
head: number
eased: number
ambient: number
}>
}
function buildIdleState(t: number, ctx: LogoContext): IdleState {
const cfg = shimmerConfig
const w = ctx.FULL[0]?.length ?? 1
const h = ctx.FULL.length * 2
const corners: [number, number][] = [
[0, 0],
[w, 0],
[0, h],
[w, h],
]
let maxCorner = 0
for (const [cx, cy] of corners) {
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
if (d > maxCorner) maxCorner = d
}
const reach = maxCorner + cfg.tail * 2
const rings = Math.max(1, Math.floor(cfg.rings))
const active = [] as IdleState["active"]
for (let i = 0; i < rings; i++) {
const offset = i / rings
const cyclePhase = (t / cfg.period + offset) % 1
if (cyclePhase >= cfg.sweepFraction) continue
const phase = cyclePhase / cfg.sweepFraction
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
active.push({
head: phase * reach,
eased,
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
})
}
return { cfg, reach, rings, active }
}
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
const ctx = props.shape ? build(props.shape) : DEFAULT
const { theme } = useTheme() const { theme } = useTheme()
const [rings, setRings] = createSignal<Ring[]>([]) const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>() const [hold, setHold] = createSignal<Hold>()
@ -430,6 +592,7 @@ export function Logo() {
} }
if (!live) setRelease(undefined) if (!live) setRelease(undefined)
if (live || hold() || release() || glow()) return if (live || hold() || release() || glow()) return
if (props.idle) return
stop() stop()
} }
@ -438,8 +601,20 @@ export function Logo() {
timer = setInterval(tick, 16) timer = setInterval(tick, 16)
} }
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
onMount(() => {
if (!props.idle) return
setNow(performance.now())
start()
})
const hit = (x: number, y: number) => { const hit = (x: number, y: number) => {
const char = FULL[y]?.[x] const char = ctx.FULL[y]?.[x]
return char !== undefined && char !== " " return char !== undefined && char !== " "
} }
@ -448,7 +623,7 @@ export function Logo() {
if (last) burst(last.x, last.y) if (last) burst(last.x, last.y)
setNow(t) setNow(t)
if (!last) setRelease(undefined) if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y) }) setHold({ x, y, at: t, glyph: select(x, y, ctx) })
hum = false hum = false
start() start()
} }
@ -508,6 +683,8 @@ export function Logo() {
} }
}) })
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
const renderLine = ( const renderLine = (
line: string, line: string,
y: number, y: number,
@ -516,24 +693,64 @@ export function Logo() {
off: number, off: number,
frame: Frame, frame: Frame,
dusk: Frame, dusk: Frame,
state: IdleState | undefined,
): JSX.Element[] => { ): JSX.Element[] => {
const shadow = tint(theme.background, ink, 0.25) const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char, i) => { return Array.from(line).map((char, i) => {
const h = field(off + i, y, frame) if (char === " ") {
const n = wave(off + i, y, frame, lit(char)) + h return (
const s = wave(off + i, y, dusk, false) + h <text fg={ink} attributes={attrs} selectable={false}>
const p = lit(char) ? pick(off + i, y, frame) : 0 {char}
const e = lit(char) ? trace(off + i, y, frame) : 0 </text>
const b = lit(char) ? bloom(off + i, y, frame) : 0 )
const q = shimmer(off + i, y, frame) }
const h = field(off + i, y, frame, ctx)
const charLit = lit(char)
// Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
// Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
// while the bright core stays pure white
const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
// For the non-peak-aware brightness channels, use the average of top/bot
const pulse = {
glow: (pulseTop.glow + pulseBot.glow) / 2,
peak: (pulseTop.peak + pulseBot.peak) / 2,
primary: (pulseTop.primary + pulseBot.primary) / 2,
}
const peakMix = charLit ? Math.min(1, pulse.peak) : 0
const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
const n = wave(off + i, y, frame, charLit, ctx) + h
const s = wave(off + i, y, dusk, false, ctx) + h
const p = charLit ? pick(off + i, y, frame, ctx) : 0
const e = charLit ? trace(off + i, y, frame, ctx) : 0
const b = charLit ? bloom(off + i, y, frame, ctx) : 0
const q = shimmer(off + i, y, frame, ctx)
if (char === "_") { if (char === "_") {
return ( return (
<text <text
fg={shade(ink, theme, s * 0.08)} fg={shade(inkTinted, theme, s * 0.08)}
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))} bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
attributes={attrs} attributes={attrs}
selectable={false} selectable={false}
> >
@ -545,8 +762,8 @@ export function Logo() {
if (char === "^") { if (char === "^") {
return ( return (
<text <text
fg={shade(ink, theme, n + p + e + b)} fg={shade(inkTop, theme, n + p + e + b)}
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))} bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
attributes={attrs} attributes={attrs}
selectable={false} selectable={false}
> >
@ -557,34 +774,60 @@ export function Logo() {
if (char === "~") { if (char === "~") {
return ( return (
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}> <text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text> </text>
) )
} }
if (char === " ") { if (char === ",") {
return ( return (
<text fg={ink} attributes={attrs} selectable={false}> <text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
{char}
</text>
)
}
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
if (char === "█") {
return (
<text
fg={shade(inkTop, theme, n + p + e + b)}
bg={shade(inkBot, theme, n + p + e + b)}
attributes={attrs}
selectable={false}
>
</text>
)
}
// ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
if (char === "▀") {
return (
<text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
</text>
)
}
// ▄ bottom-half-lit: fg uses bottom-pixel sample
if (char === "▄") {
return (
<text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
</text> </text>
) )
} }
return ( return (
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}> <text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
{char} {char}
</text> </text>
) )
}) })
} }
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
const mouse = (evt: MouseEvent) => { const mouse = (evt: MouseEvent) => {
if (!box) return if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) { if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
@ -613,17 +856,28 @@ export function Logo() {
position="absolute" position="absolute"
top={0} top={0}
left={0} left={0}
width={FULL[0]?.length ?? 0} width={ctx.FULL[0]?.length ?? 0}
height={FULL.length} height={ctx.FULL.length}
zIndex={1} zIndex={1}
onMouse={mouse} onMouse={mouse}
/> />
<For each={logo.left}> <For each={ctx.shape.left}>
{(line, index) => ( {(line, index) => (
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
<box flexDirection="row"> <box flexDirection="row">
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())} {renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())}
</box>
<box flexDirection="row">
{renderLine(
ctx.shape.right[index()],
index(),
props.ink ?? theme.text,
true,
ctx.LEFT + GAP,
frame(),
dusk(),
idleState(),
)}
</box> </box>
</box> </box>
)} )}
@ -631,3 +885,9 @@ export function Logo() {
</box> </box>
) )
} }
export function GoLogo() {
const { theme } = useTheme()
const base = tint(theme.background, theme.text, 0.62)
return <Logo shape={go} ink={base} idle />
}

View file

@ -1,18 +1,19 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid" import "opentui-spinner/solid"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme" import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border" import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event" import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history" import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part" import { assign } from "./part"
@ -35,8 +36,11 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert" import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv" import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings" import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill" import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args" import { useArgs } from "@tui/context/args"
export type PromptProps = { export type PromptProps = {
@ -75,6 +79,12 @@ function randomIndex(count: number) {
return Math.floor(Math.random() * count) return Math.floor(Math.random() * count)
} }
function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) { export function Prompt(props: PromptProps) {
let input: TextareaRenderable let input: TextareaRenderable
let anchor: BoxRenderable let anchor: BoxRenderable
@ -85,6 +95,7 @@ export function Prompt(props: PromptProps) {
const args = useArgs() const args = useArgs()
const sdk = useSDK() const sdk = useSDK()
const route = useRoute() const route = useRoute()
const project = useProject()
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
@ -95,6 +106,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer() const renderer = useRenderer()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const kv = useKV() const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? []) const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? []) const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>() const [auto, setAuto] = createSignal<AutocompleteRef>()
@ -233,9 +245,11 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit", keybind: "input_submit",
category: "Prompt", category: "Prompt",
hidden: true, hidden: true,
onSelect: (dialog) => { onSelect: async (dialog) => {
if (!input.focused) return if (!input.focused) return
void submit() const handled = await submit()
if (!handled) return
dialog.clear() dialog.clear()
}, },
}, },
@ -433,26 +447,47 @@ export function Prompt(props: PromptProps) {
}, },
} }
onMount(() => {
const saved = stashed
stashed = undefined
if (store.prompt.input) return
if (saved && saved.prompt.input) {
input.setText(saved.prompt.input)
setStore("prompt", saved.prompt)
restoreExtmarksFromParts(saved.prompt.parts)
input.cursorOffset = saved.cursor
}
})
onCleanup(() => { onCleanup(() => {
if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
props.ref?.(undefined) props.ref?.(undefined)
}) })
createEffect(() => { createEffect(() => {
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) { if (props.visible === false || dialog.stack.length > 0) {
input.blur() if (input.focused) input.blur()
return return
} }
// Slot/plugin updates can remount the background prompt while a dialog is open. // Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes. // Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
input.focus() if (!input.focused) input.focus()
}) })
createEffect(() => { createEffect(() => {
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
const capture =
store.mode === "normal"
? auto()?.visible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
input.traits = { input.traits = {
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined, capture,
suspend: !!props.disabled || store.mode === "shell", suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined, status: store.mode === "shell" ? "SHELL" : undefined,
} }
@ -599,27 +634,53 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText) setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts() syncExtmarksWithPromptParts()
} }
if (props.disabled) return if (props.disabled) return false
if (autocomplete?.visible) return if (autocomplete?.visible) return false
if (!store.prompt.input) return if (!store.prompt.input) return false
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return if (!agent) return false
const trimmed = store.prompt.input.trim() const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() void exit()
return return true
} }
const selectedModel = local.model.current() const selectedModel = local.model.current()
if (!selectedModel) { if (!selectedModel) {
void promptModelWarning() void promptModelWarning()
return return false
}
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
const workspaceID = workspaceSession?.workspaceID
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
}}
/>
))
return false
} }
let sessionID = props.sessionID let sessionID = props.sessionID
if (sessionID == null) { if (sessionID == null) {
const res = await sdk.client.session.create({ const res = await sdk.client.session.create({ workspace: props.workspaceID })
workspaceID: props.workspaceID,
})
if (res.error) { if (res.error) {
console.log("Creating a session failed:", res.error) console.log("Creating a session failed:", res.error)
@ -629,7 +690,7 @@ export function Prompt(props: PromptProps) {
variant: "error", variant: "error",
}) })
return return true
} }
sessionID = res.data.id sessionID = res.data.id
@ -743,6 +804,7 @@ export function Prompt(props: PromptProps) {
}) })
}, 50) }, 50)
input.clear() input.clear()
return true
} }
const exit = useExit() const exit = useExit()
@ -843,6 +905,14 @@ export function Prompt(props: PromptProps) {
return !!current return !!current
}) })
const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
const variantMetaAlpha = createFadeIn(
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => { const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined if (props.showPlaceholder === false) return undefined
if (store.mode === "shell") { if (store.mode === "shell") {
@ -903,7 +973,7 @@ export function Prompt(props: PromptProps) {
<box ref={(r) => (anchor = r)} visible={props.visible !== false}> <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box <box
border={["left"]} border={["left"]}
borderColor={highlight()} borderColor={borderHighlight()}
customBorderChars={{ customBorderChars={{
...SplitBorder.customBorderChars, ...SplitBorder.customBorderChars,
bottomLeft: "╹", bottomLeft: "╹",
@ -1033,6 +1103,10 @@ export function Prompt(props: PromptProps) {
return return
} }
// Once we cross an async boundary below, the terminal may perform its
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
const filepath = iife(() => { const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) { if (raw.startsWith("file://")) {
@ -1050,7 +1124,6 @@ export function Prompt(props: PromptProps) {
const filename = path.basename(filepath) const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image // Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") { if (mime === "image/svg+xml") {
event.preventDefault()
const content = await Filesystem.readText(filepath).catch(() => {}) const content = await Filesystem.readText(filepath).catch(() => {})
if (content) { if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`) pasteText(content, `[SVG: ${filename ?? "image"}]`)
@ -1058,7 +1131,6 @@ export function Prompt(props: PromptProps) {
} }
} }
if (mime.startsWith("image/") || mime === "application/pdf") { if (mime.startsWith("image/") || mime === "application/pdf") {
event.preventDefault()
const content = await Filesystem.readArrayBuffer(filepath) const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64")) .then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {}) .catch(() => {})
@ -1080,11 +1152,12 @@ export function Prompt(props: PromptProps) {
(lineCount >= 3 || pastedContent.length > 150) && (lineCount >= 3 || pastedContent.length > 150) &&
!sync.data.config.experimental?.disable_paste_summary !sync.data.config.experimental?.disable_paste_summary
) { ) {
event.preventDefault()
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return return
} }
input.insertText(normalizedText)
// Force layout update and render for the pasted content // Force layout update and render for the pasted content
setTimeout(() => { setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly // setTimeout is a workaround and needs to be addressed properly
@ -1115,17 +1188,25 @@ export function Prompt(props: PromptProps) {
<Show when={local.agent.current()} fallback={<box height={1} />}> <Show when={local.agent.current()} fallback={<box height={1} />}>
{(agent) => ( {(agent) => (
<> <>
<text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text> <text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
</text>
<Show when={store.mode === "normal"}> <Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}> <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
>
{local.model.parsed().model} {local.model.parsed().model}
</text> </text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text> <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}> <Show when={showVariant()}>
<text fg={theme.textMuted}>·</text> <text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
<text> <text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span> <span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
{local.model.variant.current()}
</span>
</text> </text>
</Show> </Show>
</box> </box>
@ -1145,7 +1226,7 @@ export function Prompt(props: PromptProps) {
<box <box
height={1} height={1}
border={["left"]} border={["left"]}
borderColor={highlight()} borderColor={borderHighlight()}
customBorderChars={{ customBorderChars={{
...EmptyBorder, ...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ", vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",

View file

@ -26,7 +26,6 @@ const TuiLegacy = z
interface MigrateInput { interface MigrateInput {
cwd: string cwd: string
directories: string[] directories: string[]
custom?: string
} }
/** /**
@ -133,8 +132,10 @@ async function backupAndStripLegacy(file: string, source: string) {
} }
async function opencodeFiles(input: { directories: string[]; cwd: string }) { async function opencodeFiles(input: { directories: string[]; cwd: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd) const files = [
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")] ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
]
for (const dir of unique(input.directories)) { for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode")) files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
} }

View file

@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(), $schema: z.string().optional(),
theme: z.string().optional(), theme: z.string().optional(),
keybinds: KeybindOverride.optional(), keybinds: KeybindOverride.optional(),
plugin: ConfigPlugin.Spec.array().optional(), plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(),
}) })
.extend(TuiOptions.shape) .extend(TuiOptions.shape)

View file

@ -1,6 +1,9 @@
export * as TuiConfig from "./tui"
import z from "zod" import z from "zod"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect" import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths" import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate" import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema" import { TuiInfo } from "./tui-schema"
@ -8,201 +11,209 @@ import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@/global" import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd" import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds" import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version" import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime" import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util" import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm"
export namespace TuiConfig { const log = Log.create({ service: "tui.config" })
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo export const Info = TuiInfo
type Acc = { type Acc = {
result: Info result: Info
} }
type State = { type State = {
config: Info config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>> deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
} }
export type Info = z.output<typeof Info> & { export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading. // Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[] plugin_origins?: ConfigPlugin.Origin[]
} }
export interface Interface { export interface Interface {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void> readonly waitForDependencies: () => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {} export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local" if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global" return "global"
} }
function customPath() { function normalize(raw: Record<string, unknown>) {
return Flag.OPENCODE_TUI_CONFIG const data = { ...raw }
} if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
delete data.tui delete data.tui
return {
...tui,
...data,
}
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string }) {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const directories = await ConfigPaths.directories(ctx.directory)
const custom = customPath()
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
}
}
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data return data
} }
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = yield* ConfigPaths.directories(ctx.directory)
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
const acc: Acc = {
result: {},
}
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
if (Flag.OPENCODE_TUI_CONFIG) {
const configFile = Flag.OPENCODE_TUI_CONFIG
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
log.debug("loaded custom tui config", { path: configFile })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
// walking up the tree. Also returned below so callers can install plugin
// dependencies from each location.
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
}
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* loadState({ directory })
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
.then((data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return ConfigParse.schema(Info, normalize(data), configFilepath)
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
} }

View file

@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [store, setStore] = createStore<Record<string, any>>() const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json") const filePath = path.join(Global.Path.state, "kv.json")
Filesystem.readJson(filePath) Filesystem.readJson<Record<string, any>>(filePath)
.then((x) => { .then((x) => {
setStore(x) setStore(x)
}) })

View file

@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}, },
move(direction: 1 | -1) { move(direction: 1 | -1) {
batch(() => { batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction const current = this.current()
if (!current) return
let next = agents().findIndex((x) => x.name === current.name) + direction
if (next < 0) next = agents().length - 1 if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0 if (next >= agents().length) next = 0
const value = agents()[next] const value = agents()[next]

View file

@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
name: "Project", name: "Project",
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const defaultPath = {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path
const [store, setStore] = createStore({ const [store, setStore] = createStore({
project: { project: {
id: undefined as string | undefined, id: undefined as string | undefined,
}, },
instance: { instance: {
path: { path: defaultPath,
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
}, },
workspace: { workspace: {
current: undefined as string | undefined, current: undefined as string | undefined,
@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
]) ])
batch(() => { batch(() => {
setStore("instance", "path", reconcile(path.data!)) setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id) setStore("project", "id", project.data?.id)
}) })
} }

View file

@ -1,16 +1,16 @@
import { createStore } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history" import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = { export type HomeRoute = {
type: "home" type: "home"
initialPrompt?: PromptInfo prompt?: PromptInfo
} }
export type SessionRoute = { export type SessionRoute = {
type: "session" type: "session"
sessionID: string sessionID: string
initialPrompt?: PromptInfo prompt?: PromptInfo
} }
export type PluginRoute = { export type PluginRoute = {
@ -23,13 +23,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route", name: "Route",
init: () => { init: (props: { initialRoute?: Route }) => {
const [store, setStore] = createStore<Route>( const [store, setStore] = createStore<Route>(
process.env["OPENCODE_ROUTE"] props.initialRoute ??
? JSON.parse(process.env["OPENCODE_ROUTE"]) (process.env["OPENCODE_ROUTE"]
: { ? JSON.parse(process.env["OPENCODE_ROUTE"])
type: "home", : {
}, type: "home",
}),
) )
return { return {
@ -37,7 +38,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store return store
}, },
navigate(route: Route) { navigate(route: Route) {
setStore(route) setStore(reconcile(route))
}, },
} }
}, },

View file

@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { Flag } from "@/flag/flag"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = { export type EventSource = {
@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let queue: GlobalEvent[] = [] let queue: GlobalEvent[] = []
let timer: Timer | undefined let timer: Timer | undefined
let last = 0 let last = 0
const retryDelay = 1000
const maxRetryDelay = 30000
const flush = () => { const flush = () => {
if (queue.length === 0) return if (queue.length === 0) return
@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const ctrl = new AbortController() const ctrl = new AbortController()
sse = ctrl sse = ctrl
;(async () => { ;(async () => {
let attempt = 0
while (true) { while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({ signal: ctrl.signal })
const events = await sdk.global.event({
signal: ctrl.signal,
sseMaxRetryAttempts: 0,
})
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
for await (const event of events.stream) { for await (const event of events.stream) {
if (ctrl.signal.aborted) break if (ctrl.signal.aborted) break
@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
if (queue.length > 0) flush() if (queue.length > 0) flush()
attempt += 1
if (abort.signal.aborted || ctrl.signal.aborted) break
// Exponential backoff
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
await new Promise((resolve) => setTimeout(resolve, backoff))
} }
})().catch(() => {}) })().catch(() => {})
} }
@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (props.events) { if (props.events) {
const unsub = await props.events.subscribe(handleEvent) const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub) onCleanup(unsub)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
} else { } else {
startSSE() startSSE()
} }

View file

@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot" import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit" import { useExit } from "./exit"
import { useArgs } from "./args" import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js" import { batch, onMount } from "solid-js"
import { Log } from "@/util" import { Log } from "@/util"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject() const project = useProject()
const sdk = useSDK() const sdk = useSDK()
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
event.subscribe((event) => { event.subscribe((event) => {
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "server.instance.disposed":
@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const exit = useExit() const exit = useExit()
const args = useArgs() const args = useArgs()
async function bootstrap() { async function bootstrap(input: { fatal?: boolean } = {}) {
console.log("bootstrapping") const fatal = input.fatal ?? true
const workspace = project.workspace.current() const workspace = project.workspace.current()
if (workspace !== syncedWorkspace) {
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session const sessionListPromise = sdk.client.session
.list({ start: start }) .list({ start: start })
@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: e instanceof Error ? e.name : undefined, name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined, stack: e instanceof Error ? e.stack : undefined,
}) })
await exit(e) if (fatal) {
await exit(e)
} else {
throw e
}
}) })
} }
const fullSyncedSessions = new Set<string>() onMount(() => {
createEffect( void bootstrap()
on( })
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
const result = { const result = {
data: store, data: store,
@ -463,6 +467,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status return store.status
}, },
get ready() { get ready() {
return true
if (process.env.OPENCODE_FAST_BOOT) return true
return store.status !== "loading" return store.status !== "loading"
}, },
get path() { get path() {
@ -474,6 +480,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index] if (match.found) return store.session[match.index]
return undefined return undefined
}, },
async refresh() {
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const list = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
setStore("session", reconcile(list))
},
status(sessionID: string) { status(sessionID: string) {
const session = result.session.get(sessionID) const session = result.session.get(sessionID)
if (!session) return "idle" if (!session) return "idle"
@ -486,12 +499,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([ const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }), sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100, workspace }), sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID, workspace }), sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID, workspace }), sdk.client.session.diff({ sessionID }),
]) ])
setStore( setStore(
produce((draft) => { produce((draft) => {

View file

@ -397,7 +397,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.lock) return if (store.lock) return
apply(mode) apply(mode)
} }
renderer.on(CliRenderEvents.THEME_MODE, handle) // renderer.on(CliRenderEvents.THEME_MODE, handle)
const refresh = () => { const refresh = () => {
renderer.clearPaletteCache() renderer.clearPaletteCache()

View file

@ -1,6 +1,6 @@
import { Layer } from "effect" import { Layer } from "effect"
import { TuiConfig } from "./config/tui" import { TuiConfig } from "./config/tui"
import { Npm } from "@opencode-ai/shared/npm" import { Npm } from "@/npm"
import { Observability } from "@/effect/observability" import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer)) export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

View file

@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
name: "session", name: "session",
params: { params: {
sessionID: route.data.sessionID, sessionID: route.data.sessionID,
initialPrompt: route.data.initialPrompt, prompt: route.data.prompt,
}, },
} }
} }

View file

@ -1,4 +1,4 @@
// import "@opentui/solid/runtime-plugin-support" import "@opentui/solid/runtime-plugin-support"
import { import {
type TuiDispose, type TuiDispose,
type TuiPlugin, type TuiPlugin,
@ -16,6 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util" import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { import {
readPackageThemes, readPackageThemes,
readPluginId, readPluginId,
@ -789,7 +790,13 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
} }
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()) const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
})
if (!ready.length) { if (!ready.length) {
return false return false
} }
@ -911,108 +918,113 @@ async function installPluginBySpec(
} }
} }
export namespace TuiPluginRuntime { let dir = ""
let dir = "" let loaded: Promise<void> | undefined
let loaded: Promise<void> | undefined let runtime: RuntimeState | undefined
let runtime: RuntimeState | undefined export const Slot = View
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd() const cwd = process.cwd()
if (loaded) { if (loaded) {
if (dir !== cwd) { if (dir !== cwd) {
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`) throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
}
return loaded
} }
dir = cwd
loaded = load(input)
return loaded return loaded
} }
export function list() { dir = cwd
if (!runtime) return [] loaded = load(input)
return listPluginStatus(runtime) return loaded
} }
export async function activatePlugin(id: string) { export function list() {
return activatePluginById(runtime, id, true) if (!runtime) return []
} return listPluginStatus(runtime)
}
export async function deactivatePlugin(id: string) { export async function activatePlugin(id: string) {
return deactivatePluginById(runtime, id, true) return activatePluginById(runtime, id, true)
} }
export async function addPlugin(spec: string) { export async function deactivatePlugin(id: string) {
return addPluginBySpec(runtime, spec) return deactivatePluginById(runtime, id, true)
} }
export async function installPlugin(spec: string, options?: { global?: boolean }) { export async function addPlugin(spec: string) {
return installPluginBySpec(runtime, spec, options?.global) return addPluginBySpec(runtime, spec)
} }
export async function dispose() { export async function installPlugin(spec: string, options?: { global?: boolean }) {
const task = loaded return installPluginBySpec(runtime, spec, options?.global)
loaded = undefined }
dir = ""
if (task) await task
const state = runtime
runtime = undefined
if (!state) return
const queue = [...state.plugins].reverse()
for (const plugin of queue) {
await deactivatePluginEntry(state, plugin, false)
}
}
async function load(input: { api: Api; config: TuiConfig.Info }) { export async function dispose() {
const { api, config } = input const task = loaded
const cwd = process.cwd() loaded = undefined
const slots = setupSlots(api) dir = ""
const next: RuntimeState = { if (task) await task
directory: cwd, const state = runtime
api, runtime = undefined
slots, if (!state) return
plugins: [], const queue = [...state.plugins].reverse()
plugins_by_id: new Map(), for (const plugin of queue) {
pending: new Map(), await deactivatePluginEntry(state, plugin, false)
}
runtime = next
try {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
}
} }
} }
async function load(input: { api: Api; config: TuiConfig.Info }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
directory: cwd,
api,
slots,
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
}
runtime = next
try {
await Instance.provide({
directory: cwd,
fn: async () => {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
},
})
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
}
}
export * as TuiPluginRuntime from "./runtime"

View file

@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin" import { TuiPluginRuntime } from "../plugin"
// TODO: what is the best way to do this?
let once = false let once = false
const placeholder = { const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"], normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
@ -31,8 +30,8 @@ export function Home() {
setRef(r) setRef(r)
promptRef.set(r) promptRef.set(r)
if (once || !r) return if (once || !r) return
if (route.initialPrompt) { if (route.prompt) {
r.set(route.initialPrompt) r.set(route.prompt)
once = true once = true
return return
} }

View file

@ -38,7 +38,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
messageID: message.id, messageID: message.id,
}) })
const parts = sync.data.part[message.id] ?? [] const parts = sync.data.part[message.id] ?? []
const initialPrompt = parts.reduce( const prompt = parts.reduce(
(agg, part) => { (agg, part) => {
if (part.type === "text") { if (part.type === "text") {
if (!part.synthetic) agg.input += part.text if (!part.synthetic) agg.input += part.text
@ -51,7 +51,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
route.navigate({ route.navigate({
sessionID: forked.data!.id, sessionID: forked.data!.id,
type: "session", type: "session",
initialPrompt, prompt,
}) })
dialog.clear() dialog.clear()
}, },

View file

@ -81,25 +81,23 @@ export function DialogMessage(props: {
sessionID: props.sessionID, sessionID: props.sessionID,
messageID: props.messageID, messageID: props.messageID,
}) })
const initialPrompt = (() => { const msg = message()
const msg = message() const prompt = msg
if (!msg) return undefined ? sync.data.part[msg.id].reduce(
const parts = sync.data.part[msg.id] (agg, part) => {
return parts.reduce( if (part.type === "text") {
(agg, part) => { if (!part.synthetic) agg.input += part.text
if (part.type === "text") { }
if (!part.synthetic) agg.input += part.text if (part.type === "file") agg.parts.push(part)
} return agg
if (part.type === "file") agg.parts.push(part) },
return agg { input: "", parts: [] as PromptInfo["parts"] },
}, )
{ input: "", parts: [] as PromptInfo["parts"] }, : undefined
)
})()
route.navigate({ route.navigate({
sessionID: result.data!.id, sessionID: result.data!.id,
type: "session", type: "session",
initialPrompt, prompt,
}) })
dialog.clear() dialog.clear()
}, },

View file

@ -44,6 +44,8 @@ import type { GrepTool } from "@/tool/grep"
import type { EditTool } from "@/tool/edit" import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch" import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch" import type { WebFetchTool } from "@/tool/webfetch"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task" import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question" import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill" import type { SkillTool } from "@/tool/skill"
@ -52,7 +54,6 @@ import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command" import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog" import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
import { parsePatch } from "diff"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item" import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message" import { DialogMessage } from "./dialog-message"
@ -86,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin" import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry" import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@ -179,27 +181,32 @@ export function Session() {
const sdk = useSDK() const sdk = useSDK()
createEffect(async () => { createEffect(async () => {
await sdk.client.session const previousWorkspace = project.workspace.current()
.get({ sessionID: route.sessionID }, { throwOnError: true }) const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => { if (!result.data) {
project.workspace.set(x.data?.workspaceID) toast.show({
}) message: `Session not found: ${route.sessionID}`,
.then(() => sync.session.sync(route.sessionID)) variant: "error",
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
.catch((e) => {
console.error(e)
toast.show({
message: `Session not found: ${route.sessionID}`,
variant: "error",
})
return navigate({ type: "home" })
}) })
navigate({ type: "home" })
return
}
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch (e) {}
}
await sync.session.sync(route.sessionID)
if (scroll) scroll.scrollBy(100_000)
}) })
// Handle initial prompt from fork
let seeded = false
let lastSwitch: string | undefined = undefined let lastSwitch: string | undefined = undefined
event.on("message.part.updated", (evt) => { event.on("message.part.updated", (evt) => {
const part = evt.properties.part const part = evt.properties.part
@ -217,14 +224,15 @@ export function Session() {
} }
}) })
let seeded = false
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
let prompt: PromptRef | undefined let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => { const bind = (r: PromptRef | undefined) => {
prompt = r prompt = r
promptRef.set(r) promptRef.set(r)
if (seeded || !route.initialPrompt || !r) return if (seeded || !route.prompt || !r) return
seeded = true seeded = true
r.set(route.initialPrompt) r.set(route.prompt)
} }
const keybind = useKeybind() const keybind = useKeybind()
const dialog = useDialog() const dialog = useDialog()
@ -597,7 +605,7 @@ export function Session() {
{ {
title: conceal() ? "Disable code concealment" : "Enable code concealment", title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal", value: "session.toggle.conceal",
keybind: "messages_toggle_conceal" as any, keybind: "messages_toggle_conceal",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setConceal((prev) => !prev) setConceal((prev) => !prev)
@ -989,31 +997,7 @@ export function Session() {
const revertInfo = createMemo(() => session()?.revert) const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID) const revertMessageID = createMemo(() => revertInfo()?.messageID)
const revertDiffFiles = createMemo(() => { const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
const diffText = revertInfo()?.diff ?? ""
if (!diffText) return []
try {
const patches = parsePatch(diffText)
return patches.map((patch) => {
const filename = patch.newFileName || patch.oldFileName || "unknown"
const cleanFilename = filename.replace(/^[ab]\//, "")
return {
filename: cleanFilename,
additions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
0,
),
deletions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
0,
),
}
})
} catch {
return []
}
})
const revertRevertedMessages = createMemo(() => { const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID() const messageID = revertMessageID()
@ -1934,28 +1918,26 @@ function Grep(props: ToolProps<typeof GrepTool>) {
function WebFetch(props: ToolProps<typeof WebFetchTool>) { function WebFetch(props: ToolProps<typeof WebFetchTool>) {
return ( return (
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}> <InlineTool icon="%" pending="Fetching from the web..." complete={props.input.url} part={props.part}>
WebFetch {(props.input as any).url} WebFetch {props.input.url}
</InlineTool> </InlineTool>
) )
} }
function CodeSearch(props: ToolProps<any>) { function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
const input = props.input as any const metadata = props.metadata as { results?: number }
const metadata = props.metadata as any
return ( return (
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}> <InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show> Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</InlineTool> </InlineTool>
) )
} }
function WebSearch(props: ToolProps<any>) { function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const input = props.input as any const metadata = props.metadata as { numResults?: number }
const metadata = props.metadata as any
return ( return (
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}> <InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show> Exa Web Search "{props.input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
</InlineTool> </InlineTool>
) )
} }
@ -1979,7 +1961,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
) )
}) })
const current = createMemo(() => tools().findLast((x) => (x.state as any).title)) const current = createMemo(() =>
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
)
const isRunning = createMemo(() => props.part.state.status === "running") const isRunning = createMemo(() => props.part.state.status === "running")
@ -1996,8 +1980,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
if (isRunning() && tools().length > 0) { if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls` // content[0] += ` · ${tools().length} toolcalls`
if (current()) content.push(`${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`) if (current()) {
else content.push(`${tools().length} toolcalls`) const state = current()!.state
const title = state.status === "running" || state.status === "completed" ? state.title : undefined
content.push(`${Locale.titlecase(current()!.tool)} ${title}`)
} else content.push(`${tools().length} toolcalls`)
} }
if (props.part.state.status === "completed") { if (props.part.state.status === "completed") {

View file

@ -1,3 +1,4 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) { export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync() const sync = useSync()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID)) const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return ( return (
@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}> <text fg={theme.text}>
<b>{session()!.title}</b> <b>{session()!.title}</b>
</text> </text>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
</text>
</Show>
<Show when={session()!.share?.url}> <Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text> <text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show> </Show>

View file

@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui" import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
return return
} }
const cwd = Filesystem.resolve(process.cwd()) const cwd = Filesystem.resolve(process.cwd())
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
})
const worker = new Worker(file, { const worker = new Worker(file, {
env: Object.fromEntries( env,
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
}) })
worker.onerror = (e) => { worker.onerror = (e) => {
Log.Default.error("thread error", { Log.Default.error("thread error", {

View file

@ -0,0 +1,18 @@
import { parsePatch } from "diff"
export function getRevertDiffFiles(diffText: string) {
if (!diffText) return []
try {
return parsePatch(diffText).map((patch) => {
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
return {
filename: filename.replace(/^[ab]\//, ""),
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
}
})
} catch {
return []
}
}

View file

@ -1,7 +1,41 @@
import { createSignal, type Accessor } from "solid-js" import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { debounce, type Scheduled } from "@solid-primitives/scheduled" import { debounce, type Scheduled } from "@solid-primitives/scheduled"
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] { export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
const [get, set] = createSignal(value) const [get, set] = createSignal(value)
return [get, debounce((v: T) => set(() => v), ms)] return [get, debounce((v: T) => set(() => v), ms)]
} }
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
let revealed = show()
createEffect(
on([show, enabled], ([visible, animate]) => {
if (!visible) {
setAlpha(0)
return
}
if (!animate || revealed) {
revealed = true
setAlpha(1)
return
}
const start = performance.now()
revealed = true
setAlpha(0)
const timer = setInterval(() => {
const progress = Math.min((performance.now() - start) / 160, 1)
setAlpha(progress * progress * (3 - 2 * progress))
if (progress >= 1) clearInterval(timer)
}, 16)
onCleanup(() => clearInterval(timer))
}),
)
return alpha
}

View file

@ -1,4 +1,5 @@
import { dlopen, ptr } from "bun:ffi" import { dlopen, ptr } from "bun:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10 const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001 const ENABLE_PROCESSED_INPUT = 0x0001
@ -71,7 +72,7 @@ export function win32InstallCtrlCGuard() {
if (!load()) return if (!load()) return
if (unhook) return unhook if (unhook) return unhook
const stdin = process.stdin as any const stdin = process.stdin as ReadStream
const original = stdin.setRawMode const original = stdin.setRawMode
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
@ -93,7 +94,7 @@ export function win32InstallCtrlCGuard() {
setImmediate(enforce) setImmediate(enforce)
} }
let wrapped: ((mode: boolean) => unknown) | undefined let wrapped: ReadStream["setRawMode"] | undefined
if (typeof original === "function") { if (typeof original === "function") {
wrapped = (mode: boolean) => { wrapped = (mode: boolean) => {

View file

@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@/util/opencode-process"
ensureProcessMetadata("worker")
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),

View file

@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) { if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data const data = (input as ErrorLike).data
const suggestions = data?.suggestions as string[] | undefined const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
return [ return [
`Model not found: ${data?.providerID}/${data?.modelID}`, `Model not found: ${data?.providerID}/${data?.modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`, `Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`, `Or check your config (opencode.json) provider/model names`,
].join("\n") ].join("\n")
@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
const data = (input as ErrorLike).data const data = (input as ErrorLike).data
const path = data?.path const path = data?.path
const message = data?.message const message = data?.message
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
return [ return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n") ].join("\n")
} }

View file

@ -8,52 +8,52 @@ const log = Log.create({ service: "heap" })
const MINUTE = 60_000 const MINUTE = 60_000
const LIMIT = 2 * 1024 * 1024 * 1024 const LIMIT = 2 * 1024 * 1024 * 1024
export namespace Heap { let timer: Timer | undefined
let timer: Timer | undefined let lock = false
let lock = false let armed = true
let armed = true
export function start() { export function start() {
if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return
if (timer) return if (timer) return
const run = async () => { const run = async () => {
if (lock) return if (lock) return
const stat = process.memoryUsage() const stat = process.memoryUsage()
if (stat.rss <= LIMIT) { if (stat.rss <= LIMIT) {
armed = true armed = true
return return
} }
if (!armed) return if (!armed) return
lock = true lock = true
armed = false armed = false
const file = path.join( const file = path.join(
Global.Path.log, Global.Path.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
) )
log.warn("heap usage exceeded limit", { log.warn("heap usage exceeded limit", {
rss: stat.rss, rss: stat.rss,
heap: stat.heapUsed, heap: stat.heapUsed,
file, file,
})
await Promise.resolve()
.then(() => writeHeapSnapshot(file))
.catch((err) => {
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
}) })
await Promise.resolve() lock = false
.then(() => writeHeapSnapshot(file))
.catch((err) => {
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
})
lock = false
}
timer = setInterval(() => {
void run()
}, MINUTE)
timer.unref?.()
} }
timer = setInterval(() => {
void run()
}, MINUTE)
timer.unref?.()
} }
export * as Heap from "./heap"

View file

@ -3,4 +3,9 @@ export const logo = {
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"], right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
} }
export const marks = "_^~" export const go = {
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
}
export const marks = "_^~,"

View file

@ -3,131 +3,131 @@ import { EOL } from "os"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import { logo as glyphs } from "./logo" import { logo as glyphs } from "./logo"
export namespace UI { const wordmark = [
const wordmark = [ ``,
``, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, ]
]
export const CancelledError = NamedError.create("UICancelledError", z.void()) export const CancelledError = NamedError.create("UICancelledError", z.void())
export const Style = { export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m", TEXT_HIGHLIGHT: "\x1b[96m",
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m", TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
TEXT_DIM: "\x1b[90m", TEXT_DIM: "\x1b[90m",
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m", TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
TEXT_NORMAL: "\x1b[0m", TEXT_NORMAL: "\x1b[0m",
TEXT_NORMAL_BOLD: "\x1b[1m", TEXT_NORMAL_BOLD: "\x1b[1m",
TEXT_WARNING: "\x1b[93m", TEXT_WARNING: "\x1b[93m",
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
TEXT_DANGER: "\x1b[91m", TEXT_DANGER: "\x1b[91m",
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
TEXT_SUCCESS: "\x1b[92m", TEXT_SUCCESS: "\x1b[92m",
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m", TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
TEXT_INFO: "\x1b[94m", TEXT_INFO: "\x1b[94m",
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m", TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
} }
export function println(...message: string[]) { export function println(...message: string[]) {
print(...message) print(...message)
process.stderr.write(EOL) process.stderr.write(EOL)
} }
export function print(...message: string[]) { export function print(...message: string[]) {
blank = false blank = false
process.stderr.write(message.join(" ")) process.stderr.write(message.join(" "))
} }
let blank = false let blank = false
export function empty() { export function empty() {
if (blank) return if (blank) return
println("" + Style.TEXT_NORMAL) println("" + Style.TEXT_NORMAL)
blank = true blank = true
} }
export function logo(pad?: string) { export function logo(pad?: string) {
if (!process.stdout.isTTY && !process.stderr.isTTY) { if (!process.stdout.isTTY && !process.stderr.isTTY) {
const result = [] const result = []
for (const row of wordmark) { for (const row of wordmark) {
if (pad) result.push(pad)
result.push(row)
result.push(EOL)
}
return result.join("").trimEnd()
}
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
const right = {
fg: reset,
shadow: "\x1b[38;5;238m",
bg: "\x1b[48;5;238m",
}
const gap = " "
const draw = (line: string, fg: string, shadow: string, bg: string) => {
const parts: string[] = []
for (const char of line) {
if (char === "_") {
parts.push(bg, " ", reset)
continue
}
if (char === "^") {
parts.push(fg, bg, "▀", reset)
continue
}
if (char === "~") {
parts.push(shadow, "▀", reset)
continue
}
if (char === " ") {
parts.push(" ")
continue
}
parts.push(fg, char, reset)
}
return parts.join("")
}
glyphs.left.forEach((row, index) => {
if (pad) result.push(pad) if (pad) result.push(pad)
result.push(draw(row, left.fg, left.shadow, left.bg)) result.push(row)
result.push(gap)
const other = glyphs.right[index] ?? ""
result.push(draw(other, right.fg, right.shadow, right.bg))
result.push(EOL) result.push(EOL)
}) }
return result.join("").trimEnd() return result.join("").trimEnd()
} }
export async function input(prompt: string): Promise<string> { const result: string[] = []
const readline = require("readline") const reset = "\x1b[0m"
const rl = readline.createInterface({ const left = {
input: process.stdin, fg: "\x1b[90m",
output: process.stdout, shadow: "\x1b[38;5;235m",
}) bg: "\x1b[48;5;235m",
return new Promise((resolve) => {
rl.question(prompt, (answer: string) => {
rl.close()
resolve(answer.trim())
})
})
} }
const right = {
export function error(message: string) { fg: reset,
if (message.startsWith("Error: ")) { shadow: "\x1b[38;5;238m",
message = message.slice("Error: ".length) bg: "\x1b[48;5;238m",
}
const gap = " "
const draw = (line: string, fg: string, shadow: string, bg: string) => {
const parts: string[] = []
for (const char of line) {
if (char === "_") {
parts.push(bg, " ", reset)
continue
}
if (char === "^") {
parts.push(fg, bg, "▀", reset)
continue
}
if (char === "~") {
parts.push(shadow, "▀", reset)
continue
}
if (char === " ") {
parts.push(" ")
continue
}
parts.push(fg, char, reset)
} }
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) return parts.join("")
}
export function markdown(text: string): string {
return text
} }
glyphs.left.forEach((row, index) => {
if (pad) result.push(pad)
result.push(draw(row, left.fg, left.shadow, left.bg))
result.push(gap)
const other = glyphs.right[index] ?? ""
result.push(draw(other, right.fg, right.shadow, right.bg))
result.push(EOL)
})
return result.join("").trimEnd()
} }
export async function input(prompt: string): Promise<string> {
const readline = require("readline")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(prompt, (answer: string) => {
rl.close()
resolve(answer.trim())
})
})
}
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
export function markdown(text: string): string {
return text
}
export * as UI from "./ui"

View file

@ -1,186 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect"
import { EffectBridge } from "@/effect"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
import { Config } from "../config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
type State = {
commands: Record<string, Info>
}
export const Event = {
Executed: BusEvent.define(
"command.executed",
z.object({
name: z.string(),
sessionID: SessionID.zod,
arguments: z.string(),
messageID: MessageID.zod,
}),
),
}
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
source: z.enum(["command", "mcp", "skill"]).optional(),
// workaround for zod not supporting async functions natively so we use getters
// https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
})
.meta({
ref: "Command",
})
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string) {
const result: string[] = []
const numbered = template.match(/\$\d+/g)
if (numbered) {
for (const match of [...new Set(numbered)].sort()) result.push(match)
}
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
return result
}
export const Default = {
INIT: "init",
REVIEW: "review",
} as const
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
const cfg = yield* config.get()
const bridge = yield* EffectBridge.make()
const commands: Record<string, Info> = {}
commands[Default.INIT] = {
name: Default.INIT,
description: "guided AGENTS.md setup",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
},
hints: hints(PROMPT_INITIALIZE),
}
commands[Default.REVIEW] = {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
}
for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
commands[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
return bridge.promise(
mcp
.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
)
.pipe(
Effect.map(
(template) =>
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
),
),
)
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
for (const item of yield* skill.all()) {
if (commands[item.name]) continue
commands[item.name] = {
name: item.name,
description: item.description,
source: "skill",
get template() {
return item.content
},
hints: [],
}
}
return {
commands,
}
})
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
const get = Effect.fn("Command.get")(function* (name: string) {
const s = yield* InstanceState.get(state)
return s.commands[name]
})
const list = Effect.fn("Command.list")(function* () {
const s = yield* InstanceState.get(state)
return Object.values(s.commands)
})
return Service.of({ get, list })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
)

View file

@ -1 +1,188 @@
export * as Command from "./command" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect"
import { EffectBridge } from "@/effect"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
import { Config } from "../config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
type State = {
commands: Record<string, Info>
}
export const Event = {
Executed: BusEvent.define(
"command.executed",
z.object({
name: z.string(),
sessionID: SessionID.zod,
arguments: z.string(),
messageID: MessageID.zod,
}),
),
}
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
source: z.enum(["command", "mcp", "skill"]).optional(),
// workaround for zod not supporting async functions natively so we use getters
// https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
})
.meta({
ref: "Command",
})
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string) {
const result: string[] = []
const numbered = template.match(/\$\d+/g)
if (numbered) {
for (const match of [...new Set(numbered)].sort()) result.push(match)
}
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
return result
}
export const Default = {
INIT: "init",
REVIEW: "review",
} as const
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
const cfg = yield* config.get()
const bridge = yield* EffectBridge.make()
const commands: Record<string, Info> = {}
commands[Default.INIT] = {
name: Default.INIT,
description: "guided AGENTS.md setup",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
},
hints: hints(PROMPT_INITIALIZE),
}
commands[Default.REVIEW] = {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
}
for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
commands[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
return bridge.promise(
mcp
.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
)
.pipe(
Effect.map(
(template) =>
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
),
),
)
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
for (const item of yield* skill.all()) {
if (commands[item.name]) continue
commands[item.name] = {
name: item.name,
description: item.description,
source: "skill",
get template() {
return item.content
},
hints: [],
}
}
return {
commands,
}
})
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
const get = Effect.fn("Command.get")(function* (name: string) {
const s = yield* InstanceState.get(state)
return s.commands[name]
})
const list = Effect.fn("Command.list")(function* () {
const s = yield* InstanceState.get(state)
return Object.values(s.commands)
})
return Service.of({ get, list })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
export * as Command from "."

View file

@ -0,0 +1,171 @@
export * as ConfigAgent from "./agent"
import { Log } from "../util"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
export const Info = z
.object({
model: ConfigModelID.zod.optional(),
variant: z
.string()
.optional()
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
hidden: z
.boolean()
.optional()
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(),
color: z
.union([
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional()
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: ConfigPermission.Info.optional(),
})
.catchall(z.any())
.transform((agent, _ctx) => {
const knownKeys = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
continue
}
permission[tool] = action
}
Object.assign(permission, agent.permission)
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
})
.meta({
ref: "AgentConfig",
})
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load agent", { agent: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
const name = configEntryNameFromPath(item, patterns)
const config = {
name,
...md.data,
prompt: md.content.trim(),
}
const parsed = Info.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}
export async function loadMode(dir: string) {
const result: Record<string, Info> = {}
for (const item of await Glob.scan("{mode,modes}/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load mode", { mode: item, err })
return undefined
})
if (!md) continue
const config = {
name: configEntryNameFromPath(item, []),
...md.data,
prompt: md.content.trim(),
}
const parsed = Info.safeParse(config)
if (parsed.success) {
result[config.name] = {
...parsed.data,
mode: "primary" as const,
}
}
}
return result
}

View file

@ -0,0 +1,62 @@
export * as ConfigCommand from "./command"
import { Log } from "../util"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
const log = Log.create({ service: "config" })
export const Info = Schema.Struct({
template: Schema.String,
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(ConfigModelID),
subtask: Schema.optional(Schema.Boolean),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const name = configEntryNameFromPath(item, patterns)
const config = {
name,
...md.data,
template: md.content.trim(),
}
const parsed = Info.zod.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}

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