Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2beeccb39a |
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
# Extract hash from build log with portability
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
---
|
||||
model: opencode/kimi-k2.5
|
||||
---
|
||||
|
||||
create UPCOMING_CHANGELOG.md
|
||||
|
||||
it should have sections
|
||||
|
||||
```
|
||||
# TUI
|
||||
|
||||
# Desktop
|
||||
|
||||
# Core
|
||||
|
||||
# Misc
|
||||
```
|
||||
|
||||
go through each PR merged since the last tag
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
|
||||
|
||||
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
|
||||
|
||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -164,7 +164,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -358,7 +358,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.2",
|
||||
"gitlab-ai-provider": "5.2.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -370,7 +370,6 @@
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
@@ -383,7 +382,6 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.18.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -411,7 +409,6 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -424,7 +421,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -448,7 +445,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -459,7 +456,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -494,7 +491,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -540,7 +537,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -551,7 +548,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -3039,7 +3036,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -3795,8 +3792,6 @@
|
||||
|
||||
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
|
||||
|
||||
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
|
||||
|
||||
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
|
||||
|
||||
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
|
||||
@@ -3929,8 +3924,6 @@
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"poe-oauth": ["poe-oauth@0.0.3", "", {}, "sha512-KgxDylcuq/mov8URSplrBGjrIjkQwjN/Ml8BhqaGsAvHzYN3yhuROdv1sDRfwqncg7TT8XzJvMeJAWmv/4NDLw=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
@@ -5551,8 +5544,6 @@
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
|
||||
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||
@@ -6305,8 +6296,6 @@
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1773909469,
|
||||
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
|
||||
"lastModified": 1772091128,
|
||||
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
|
||||
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
|
||||
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
|
||||
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
|
||||
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
|
||||
"x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
|
||||
"aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
|
||||
"aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
|
||||
"x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
@@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
@@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
export function AppBaseProviders(props: ParentProps) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider locale={props.locale}>
|
||||
<LanguageProvider>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<QueryProvider>
|
||||
|
||||
@@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
}}
|
||||
>
|
||||
@@ -363,7 +363,11 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
style={{
|
||||
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
@@ -34,25 +34,15 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = globalSync.data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await globalSDK.client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
globalSync.set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
const methods = createMemo(
|
||||
() =>
|
||||
globalSync.data.provider_auth[props.provider] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
],
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
@@ -187,11 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
index: 0,
|
||||
})
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const value = method()
|
||||
if (value?.type !== "oauth") return []
|
||||
return value.prompts ?? []
|
||||
})
|
||||
const prompts = createMemo(() => method()?.prompts ?? [])
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
const actual = value[prompt.when.key]
|
||||
@@ -310,12 +296,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto) return
|
||||
if (loading()) return
|
||||
onMount(() => {
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
selectMethod(0)
|
||||
}
|
||||
})
|
||||
@@ -591,14 +573,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
|
||||
@@ -572,7 +572,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const open = recent()
|
||||
const seen = new Set(open)
|
||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||
if (!query.trim()) return [...agents, ...pinned]
|
||||
const paths = await files.searchFilesAndDirectories(query)
|
||||
const fileOptions: AtOption[] = paths
|
||||
.filter((path) => !seen.has(path))
|
||||
@@ -1498,7 +1497,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
id={local.model.current()!.provider.id}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
@@ -1530,7 +1529,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
id={local.model.current()!.provider.id}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
|
||||
@@ -1,41 +1,27 @@
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
@@ -43,19 +29,12 @@ const stopDemoSound = () => {
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
const playDemoSound = (src: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!id) return
|
||||
if (!src) return
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
demoSoundState.cleanup = playSound(src)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@@ -65,10 +44,6 @@ export const SettingsGeneral: Component = () => {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
@@ -129,7 +104,9 @@ export const SettingsGeneral: Component = () => {
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
const themeOptions = createMemo(() =>
|
||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
||||
)
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
@@ -166,7 +143,7 @@ export const SettingsGeneral: Component = () => {
|
||||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
|
||||
const soundSelectProps = (
|
||||
@@ -181,7 +158,7 @@ export const SettingsGeneral: Component = () => {
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
playDemoSound(option.src)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
@@ -192,7 +169,7 @@ export const SettingsGeneral: Component = () => {
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.id)
|
||||
playDemoSound(option.src)
|
||||
},
|
||||
variant: "secondary" as const,
|
||||
size: "small" as const,
|
||||
@@ -344,9 +321,6 @@ export const SettingsGeneral: Component = () => {
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||
}}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
@@ -53,15 +54,11 @@ const listServersByHealth = (
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
@@ -165,12 +162,6 @@ export function StatusPopover() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [shown, setShown] = createSignal(false)
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
@@ -178,7 +169,7 @@ export function StatusPopover() {
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, shown)
|
||||
const health = useServerHealth(servers)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
@@ -309,13 +300,7 @@ export function StatusPopover() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
@@ -9,7 +9,17 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
import {
|
||||
createContext,
|
||||
getOwner,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
Switch,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -70,8 +80,6 @@ function createGlobalSync() {
|
||||
|
||||
let active = true
|
||||
let projectWritten = false
|
||||
let bootedAt = 0
|
||||
let bootingRoot = false
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
@@ -250,11 +258,6 @@ function createGlobalSync() {
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
@@ -275,20 +278,15 @@ function createGlobalSync() {
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: () => {
|
||||
if (recent) return
|
||||
queue.refresh()
|
||||
},
|
||||
refresh: queue.refresh,
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
queue.push(directory)
|
||||
}
|
||||
@@ -327,19 +325,17 @@ function createGlobalSync() {
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
bootingRoot = true
|
||||
try {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
bootedAt = Date.now()
|
||||
} finally {
|
||||
bootingRoot = false
|
||||
}
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
||||
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
||||
url: globalSDK.url,
|
||||
}),
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -396,7 +392,13 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
||||
|
||||
export function GlobalSyncProvider(props: ParentProps) {
|
||||
const value = createGlobalSync()
|
||||
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={value.ready}>
|
||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function useGlobalSync() {
|
||||
|
||||
@@ -31,102 +31,73 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function waitForPaint() {
|
||||
return new Promise<void>((resolve) => {
|
||||
let done = false
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(finish, 50)
|
||||
if (typeof requestAnimationFrame !== "function") return
|
||||
requestAnimationFrame(() => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
|
||||
function showErrors(input: {
|
||||
errors: unknown[]
|
||||
title: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
}) {
|
||||
if (input.errors.length === 0) return
|
||||
const message = formatServerError(input.errors[0], input.translate)
|
||||
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.title,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const fast = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
const health = await input.globalSDK.global
|
||||
.health()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!health?.healthy) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.connectErrorTitle,
|
||||
description: input.connectErrorDescription,
|
||||
})
|
||||
input.setGlobalStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = [
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.auth().then((x) => {
|
||||
input.setGlobalStore("provider_auth", x.data ?? {})
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
showErrors({
|
||||
errors: errors(await runAll(fast)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
await waitForPaint()
|
||||
showErrors({
|
||||
errors: errors(await runAll(slow)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
const message = formatServerError(errors[0], input.translate)
|
||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
input.setGlobalStore("ready", true)
|
||||
}
|
||||
|
||||
@@ -140,10 +111,6 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||
}, {})
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
@@ -152,130 +119,88 @@ export async function bootstrapDirectory(input: {
|
||||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
global: {
|
||||
config: Config
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||
input.setStore("provider", input.global.provider)
|
||||
}
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", input.global.config)
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||
|
||||
const fast = [
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
const blockingRequests = {
|
||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
||||
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
if (input.store.status !== "complete") input.setStore("status", "partial")
|
||||
|
||||
Promise.all([
|
||||
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
||||
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
||||
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
||||
input.loadSessions(input.directory),
|
||||
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
||||
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||
]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
if (errs.length > 0) {
|
||||
console.error("Failed to bootstrap instance", errs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(errs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
}
|
||||
})
|
||||
}),
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
input.setStore("status", "complete")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
project: Project[]
|
||||
@@ -213,7 +211,6 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
||||
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
|
||||
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
|
||||
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
|
||||
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
|
||||
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
|
||||
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
|
||||
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
|
||||
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
|
||||
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
||||
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
||||
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
||||
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
|
||||
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
@@ -27,7 +59,6 @@ export type Locale =
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
@@ -94,43 +125,24 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
}
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn })
|
||||
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||
|
||||
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
|
||||
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
|
||||
|
||||
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
|
||||
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
|
||||
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
|
||||
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
|
||||
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
|
||||
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
|
||||
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
|
||||
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
|
||||
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
|
||||
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||
}
|
||||
|
||||
function loadDict(locale: Locale) {
|
||||
const hit = dicts.get(locale)
|
||||
if (hit) return Promise.resolve(hit)
|
||||
if (locale === "en") return Promise.resolve(base)
|
||||
const load = loaders[locale]
|
||||
return load().then((next: Dictionary) => {
|
||||
dicts.set(locale, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function loadLocaleDict(locale: Locale) {
|
||||
return loadDict(locale).then(() => undefined)
|
||||
const DICT: Record<Locale, Dictionary> = {
|
||||
en: base,
|
||||
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
|
||||
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
|
||||
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
|
||||
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
|
||||
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
|
||||
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
|
||||
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
|
||||
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
|
||||
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
|
||||
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
|
||||
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
|
||||
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
|
||||
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
|
||||
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
|
||||
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
|
||||
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
@@ -156,6 +168,27 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||
]
|
||||
|
||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
zh,
|
||||
zht,
|
||||
ko,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
da,
|
||||
ja,
|
||||
pl,
|
||||
ru,
|
||||
ar,
|
||||
no,
|
||||
br,
|
||||
th,
|
||||
bs,
|
||||
tr,
|
||||
}
|
||||
void PARITY_CHECK
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator !== "object") return "en"
|
||||
|
||||
@@ -170,48 +203,27 @@ function detectLocale(): Locale {
|
||||
return "en"
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string): Locale {
|
||||
function normalizeLocale(value: string): Locale {
|
||||
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
|
||||
}
|
||||
|
||||
function readStoredLocale() {
|
||||
if (typeof localStorage !== "object") return
|
||||
try {
|
||||
const raw = localStorage.getItem("opencode.global.dat:language")
|
||||
if (!raw) return
|
||||
const next = JSON.parse(raw) as { locale?: string }
|
||||
if (typeof next?.locale !== "string") return
|
||||
return normalizeLocale(next.locale)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const warm = readStoredLocale() ?? detectLocale()
|
||||
if (warm !== "en") void loadDict(warm)
|
||||
|
||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||
name: "Language",
|
||||
init: (props: { locale?: Locale }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
init: () => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
createStore({
|
||||
locale: initial,
|
||||
locale: detectLocale() as Locale,
|
||||
}),
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
console.log("locale", locale())
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const [dict] = createResource(locale, loadDict, {
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
||||
|
||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
void playSoundById(settings.sounds.agent())
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
|
||||
@@ -104,13 +104,6 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
@@ -118,11 +111,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||
if (id !== defaultSettings.appearance.font) {
|
||||
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||
}
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,8 +14,6 @@ import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
@@ -180,8 +178,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const messagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
@@ -339,8 +336,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
|
||||
if (filtered.length) input.setStore("part", p.id, filtered)
|
||||
input.setStore("part", p.id, p.part)
|
||||
}
|
||||
setMeta("limit", key, message.length)
|
||||
setMeta("cursor", key, next.cursor)
|
||||
@@ -464,7 +460,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
@@ -561,7 +557,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? historyMessagePageSize
|
||||
const step = count ?? messagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
const template = "Terminal {{number}}"
|
||||
import { dict as ar } from "@/i18n/ar"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as da } from "@/i18n/da"
|
||||
import { dict as de } from "@/i18n/de"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as es } from "@/i18n/es"
|
||||
import { dict as fr } from "@/i18n/fr"
|
||||
import { dict as ja } from "@/i18n/ja"
|
||||
import { dict as ko } from "@/i18n/ko"
|
||||
import { dict as no } from "@/i18n/no"
|
||||
import { dict as pl } from "@/i18n/pl"
|
||||
import { dict as ru } from "@/i18n/ru"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { dict as zht } from "@/i18n/zht"
|
||||
|
||||
const numbered = [
|
||||
template,
|
||||
"محطة طرفية {{number}}",
|
||||
"Терминал {{number}}",
|
||||
"ターミナル {{number}}",
|
||||
"터미널 {{number}}",
|
||||
"เทอร์มินัล {{number}}",
|
||||
"终端 {{number}}",
|
||||
"終端機 {{number}}",
|
||||
]
|
||||
const numbered = Array.from(
|
||||
new Set([
|
||||
en["terminal.title.numbered"],
|
||||
ar["terminal.title.numbered"],
|
||||
br["terminal.title.numbered"],
|
||||
bs["terminal.title.numbered"],
|
||||
da["terminal.title.numbered"],
|
||||
de["terminal.title.numbered"],
|
||||
es["terminal.title.numbered"],
|
||||
fr["terminal.title.numbered"],
|
||||
ja["terminal.title.numbered"],
|
||||
ko["terminal.title.numbered"],
|
||||
no["terminal.title.numbered"],
|
||||
pl["terminal.title.numbered"],
|
||||
ru["terminal.title.numbered"],
|
||||
th["terminal.title.numbered"],
|
||||
tr["terminal.title.numbered"],
|
||||
zh["terminal.title.numbered"],
|
||||
zht["terminal.title.numbered"],
|
||||
]),
|
||||
)
|
||||
|
||||
export function defaultTitle(number: number) {
|
||||
return template.replace("{{number}}", String(number))
|
||||
return en["terminal.title.numbered"].replace("{{number}}", String(number))
|
||||
}
|
||||
|
||||
export function isDefaultTitle(title: string, number: number) {
|
||||
|
||||
@@ -97,15 +97,10 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const localUrl = () =>
|
||||
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return localUrl()
|
||||
if (import.meta.env.DEV) return localUrl()
|
||||
if (isLocalHost()) return localUrl()
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
if (projectStore.provider.all.length > 0) return projectStore.provider
|
||||
return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { handleNotificationClick } from "./utils/notification-click"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
|
||||
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
@@ -10,18 +11,10 @@ import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
createEffect(() => {
|
||||
const next = sync.data.path.directory
|
||||
if (!next || next === props.directory) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
@@ -36,31 +29,50 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const location = useLocation()
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const navigate = useNavigate()
|
||||
let invalid = ""
|
||||
|
||||
const resolved = createMemo(() => {
|
||||
if (!params.dir) return ""
|
||||
return decode64(params.dir) ?? ""
|
||||
})
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (params.dir) return [location.pathname, params.dir] as const
|
||||
},
|
||||
async ([pathname, b64Dir]) => {
|
||||
const directory = decode64(b64Dir)
|
||||
|
||||
createEffect(() => {
|
||||
const dir = params.dir
|
||||
if (!dir) return
|
||||
if (resolved()) {
|
||||
invalid = ""
|
||||
return
|
||||
}
|
||||
if (invalid === dir) return
|
||||
invalid = dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
if (!directory) {
|
||||
if (invalid === params.dir) return
|
||||
invalid = b64Dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
return await globalSDK
|
||||
.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
.path.get()
|
||||
.then((x) => {
|
||||
const next = x.data?.directory ?? directory
|
||||
invalid = ""
|
||||
if (next === directory) return next
|
||||
const path = pathname.slice(b64Dir.length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
.catch(() => {
|
||||
invalid = ""
|
||||
return directory
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={resolved()} keyed>
|
||||
|
||||
@@ -113,14 +113,6 @@ export default function Home() {
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync.ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
|
||||
@@ -49,16 +49,21 @@ import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { createAim } from "@/utils/aim"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
@@ -105,8 +110,6 @@ export default function Layout(props: ParentProps) {
|
||||
const pageReady = createMemo(() => ready())
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
@@ -136,7 +139,7 @@ export default function Layout(props: ParentProps) {
|
||||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
||||
}
|
||||
})
|
||||
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
|
||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||
system: "theme.scheme.system",
|
||||
@@ -198,8 +201,6 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
clearTimeout(sortNowTimeout)
|
||||
if (sortNowInterval) clearInterval(sortNowInterval)
|
||||
@@ -335,9 +336,10 @@ export default function Layout(props: ParentProps) {
|
||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
||||
const nextThemeId = ids[nextIndex]
|
||||
theme.setTheme(nextThemeId)
|
||||
const nextTheme = theme.themes()[nextThemeId]
|
||||
showToast({
|
||||
title: language.t("toast.theme.title"),
|
||||
description: theme.name(nextThemeId),
|
||||
description: nextTheme?.name ?? nextThemeId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -492,7 +494,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
if (e.details.type === "permission.asked") {
|
||||
if (settings.sounds.permissionsEnabled()) {
|
||||
void playSoundById(settings.sounds.permissions())
|
||||
playSound(soundSrc(settings.sounds.permissions()))
|
||||
}
|
||||
if (settings.notifications.permissions()) {
|
||||
void platform.notify(title, description, href)
|
||||
@@ -1150,10 +1152,10 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
for (const [id] of availableThemeEntries()) {
|
||||
for (const [id, definition] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
id: `theme.set.${id}`,
|
||||
title: language.t("command.theme.set", { theme: theme.name(id) }),
|
||||
title: language.t("command.theme.set", { theme: definition.name ?? id }),
|
||||
category: language.t("command.category.theme"),
|
||||
onSelect: () => theme.commitPreview(),
|
||||
onHighlight: () => {
|
||||
@@ -1204,27 +1206,15 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
function connectProvider() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-provider").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
function openServer() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />)
|
||||
})
|
||||
dialog.show(() => <DialogSelectServer />)
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
dialog.show(() => <DialogSettings />)
|
||||
}
|
||||
|
||||
function projectRoot(directory: string) {
|
||||
@@ -1451,13 +1441,7 @@ export default function Layout(props: ParentProps) {
|
||||
layout.sidebar.toggleWorkspaces(project.worktree)
|
||||
}
|
||||
|
||||
const showEditProjectDialog = (project: LocalProject) => {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-edit-project").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogEditProject project={project} />)
|
||||
})
|
||||
}
|
||||
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
|
||||
|
||||
async function chooseProject() {
|
||||
function resolve(result: string | string[] | null) {
|
||||
@@ -1478,14 +1462,10 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1818,9 +1798,6 @@ export default function Layout(props: ParentProps) {
|
||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||
})
|
||||
|
||||
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
|
||||
const panel = createMemo(() => Math.max(side() - 64, 0))
|
||||
|
||||
const loadedSessionDirs = new Set<string>()
|
||||
|
||||
createEffect(
|
||||
@@ -2097,7 +2074,7 @@ export default function Layout(props: ParentProps) {
|
||||
"max-w-full overflow-hidden": panelProps.mobile,
|
||||
}}
|
||||
style={{
|
||||
width: panelProps.mobile ? undefined : `${panel()}px`,
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
@@ -2160,11 +2137,9 @@ export default function Layout(props: ParentProps) {
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={slug()}
|
||||
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
|
||||
class="shrink-0 size-6 rounded-md 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(),
|
||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
@@ -2389,7 +2364,7 @@ export default function Layout(props: ParentProps) {
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
@@ -2404,29 +2379,24 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setState("sizing", true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
@@ -2466,7 +2436,7 @@ export default function Layout(props: ParentProps) {
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
@@ -2513,7 +2483,7 @@ export default function Layout(props: ParentProps) {
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
}): JSX.Element => (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
@@ -115,26 +115,30 @@ const SessionRow = (props: {
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
@@ -163,11 +167,7 @@ const SessionHoverPreview = (props: {
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={
|
||||
<div ref={ref} class="min-w-0 w-full">
|
||||
{props.trigger}
|
||||
</div>
|
||||
}
|
||||
trigger={<div ref={ref}>{props.trigger}</div>}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@@ -309,71 +309,62 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
|
||||
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
|
||||
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={props.session.title}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
language={language}
|
||||
isActive={isActive}
|
||||
slug={props.slug}
|
||||
setHoverSession={props.setHoverSession}
|
||||
messageLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive())
|
||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||
classList={{
|
||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
{item}
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
language={language}
|
||||
isActive={isActive}
|
||||
slug={props.slug}
|
||||
setHoverSession={props.setHoverSession}
|
||||
messageLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive())
|
||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||
classList={{
|
||||
"opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -395,26 +386,30 @@ export const NewSessionItem = (props: {
|
||||
<A
|
||||
href={`/${props.slug}/session`}
|
||||
end
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (layout.sidebar.opened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||
<Show
|
||||
when={!tooltip()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
@@ -41,13 +41,7 @@ import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import {
|
||||
createOpenReviewFile,
|
||||
createSessionTabs,
|
||||
createSizing,
|
||||
focusTerminalById,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
} from "@/pages/session/helpers"
|
||||
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
@@ -246,19 +240,14 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
|
||||
if (added <= 0) return
|
||||
if (growth <= 0) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const current = turnStart()
|
||||
preserveScroll(() => setTurnStart(current + growth))
|
||||
return
|
||||
}
|
||||
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const reveal = !opts?.prefetch
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = Math.min(afterVisible, base + turnBatch)
|
||||
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
|
||||
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
@@ -861,7 +850,7 @@ export default function Page() {
|
||||
// Prefer the open terminal over the composer when it can take focus
|
||||
if (view().terminal.opened()) {
|
||||
const id = terminal.active()
|
||||
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
|
||||
if (id && focusTerminalById(id)) return
|
||||
}
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
@@ -1184,6 +1173,8 @@ export default function Page() {
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
void file.tree.list("")
|
||||
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
@@ -1638,9 +1629,6 @@ export default function Page() {
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
@@ -1712,7 +1700,7 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<Show when={lastUserMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createSessionTabs,
|
||||
focusTerminalById,
|
||||
getTabReorderIndex,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
} from "./helpers"
|
||||
|
||||
describe("createOpenReviewFile", () => {
|
||||
@@ -87,26 +86,6 @@ describe("focusTerminalById", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldFocusTerminalOnKeyDown", () => {
|
||||
test("skips pure modifier keys", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("skips shortcut key combos", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps plain typing focused on terminal", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTabReorderIndex", () => {
|
||||
test("returns target index for valid drag reorder", () => {
|
||||
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
|
||||
|
||||
@@ -93,13 +93,6 @@ export const focusTerminalById = (id: string) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
|
||||
|
||||
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
|
||||
if (skip.has(event.key)) return false
|
||||
return !(event.ctrlKey || event.metaKey || event.altKey)
|
||||
}
|
||||
|
||||
export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
|
||||
@@ -923,15 +923,7 @@ export function MessageTimeline(props: {
|
||||
{(messageID) => {
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(c, i) =>
|
||||
c.path === b[i].path &&
|
||||
c.comment === b[i].comment &&
|
||||
c.selection?.startLine === b[i].selection?.startLine &&
|
||||
c.selection?.endLine === b[i].selection?.endLine,
|
||||
),
|
||||
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
@@ -987,7 +979,6 @@ export function MessageTimeline(props: {
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={messageID}
|
||||
messages={sessionMessages()}
|
||||
actions={props.actions}
|
||||
active={active()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
|
||||
@@ -8,9 +8,6 @@ export const useSessionHashScroll = (input: {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
turnStart: () => number
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
@@ -184,21 +181,6 @@ export const useSessionHashScroll = (input: {
|
||||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID || !input.messagesReady()) return
|
||||
|
||||
visibleUserMessages()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (messageById().has(targetId)) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
void input.loadMore(sessionID)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
|
||||
@@ -14,15 +14,6 @@ interface CheckServerHealthOptions {
|
||||
const defaultTimeoutMs = 3000
|
||||
const defaultRetryCount = 2
|
||||
const defaultRetryDelayMs = 100
|
||||
const cacheMs = 750
|
||||
const healthCache = new Map<
|
||||
string,
|
||||
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
|
||||
>()
|
||||
|
||||
function cacheKey(server: ServerConnection.HttpBase) {
|
||||
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
|
||||
}
|
||||
|
||||
function timeoutSignal(timeoutMs: number) {
|
||||
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
||||
@@ -96,18 +87,5 @@ export function useCheckServerHealth() {
|
||||
const platform = usePlatform()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
|
||||
return (http: ServerConnection.HttpBase) => {
|
||||
const key = cacheKey(http)
|
||||
const hit = healthCache.get(key)
|
||||
const now = Date.now()
|
||||
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
|
||||
const promise = checkServerHealth(http, fetcher).finally(() => {
|
||||
const next = healthCache.get(key)
|
||||
if (!next || next.promise !== promise) return
|
||||
next.done = true
|
||||
next.at = Date.now()
|
||||
})
|
||||
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
|
||||
return promise
|
||||
}
|
||||
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
|
||||
}
|
||||
|
||||
@@ -1,89 +1,106 @@
|
||||
let files: Record<string, () => Promise<string>> | undefined
|
||||
let loads: Record<SoundID, () => Promise<string>> | undefined
|
||||
|
||||
function getFiles() {
|
||||
if (files) return files
|
||||
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
|
||||
string,
|
||||
() => Promise<string>
|
||||
>
|
||||
return files
|
||||
}
|
||||
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
|
||||
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
|
||||
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
|
||||
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
|
||||
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
|
||||
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
|
||||
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
|
||||
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
|
||||
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
|
||||
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
|
||||
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
|
||||
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
|
||||
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
|
||||
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
|
||||
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
|
||||
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
|
||||
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
|
||||
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
|
||||
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
|
||||
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
|
||||
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
|
||||
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
|
||||
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
|
||||
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
|
||||
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
|
||||
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
|
||||
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
|
||||
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
|
||||
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
|
||||
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
|
||||
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
|
||||
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
|
||||
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
|
||||
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
|
||||
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
|
||||
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
|
||||
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
|
||||
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
|
||||
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
|
||||
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
|
||||
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
|
||||
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
|
||||
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
|
||||
|
||||
export const SOUND_OPTIONS = [
|
||||
{ id: "alert-01", label: "sound.option.alert01" },
|
||||
{ id: "alert-02", label: "sound.option.alert02" },
|
||||
{ id: "alert-03", label: "sound.option.alert03" },
|
||||
{ id: "alert-04", label: "sound.option.alert04" },
|
||||
{ id: "alert-05", label: "sound.option.alert05" },
|
||||
{ id: "alert-06", label: "sound.option.alert06" },
|
||||
{ id: "alert-07", label: "sound.option.alert07" },
|
||||
{ id: "alert-08", label: "sound.option.alert08" },
|
||||
{ id: "alert-09", label: "sound.option.alert09" },
|
||||
{ id: "alert-10", label: "sound.option.alert10" },
|
||||
{ id: "bip-bop-01", label: "sound.option.bipbop01" },
|
||||
{ id: "bip-bop-02", label: "sound.option.bipbop02" },
|
||||
{ id: "bip-bop-03", label: "sound.option.bipbop03" },
|
||||
{ id: "bip-bop-04", label: "sound.option.bipbop04" },
|
||||
{ id: "bip-bop-05", label: "sound.option.bipbop05" },
|
||||
{ id: "bip-bop-06", label: "sound.option.bipbop06" },
|
||||
{ id: "bip-bop-07", label: "sound.option.bipbop07" },
|
||||
{ id: "bip-bop-08", label: "sound.option.bipbop08" },
|
||||
{ id: "bip-bop-09", label: "sound.option.bipbop09" },
|
||||
{ id: "bip-bop-10", label: "sound.option.bipbop10" },
|
||||
{ id: "staplebops-01", label: "sound.option.staplebops01" },
|
||||
{ id: "staplebops-02", label: "sound.option.staplebops02" },
|
||||
{ id: "staplebops-03", label: "sound.option.staplebops03" },
|
||||
{ id: "staplebops-04", label: "sound.option.staplebops04" },
|
||||
{ id: "staplebops-05", label: "sound.option.staplebops05" },
|
||||
{ id: "staplebops-06", label: "sound.option.staplebops06" },
|
||||
{ id: "staplebops-07", label: "sound.option.staplebops07" },
|
||||
{ id: "nope-01", label: "sound.option.nope01" },
|
||||
{ id: "nope-02", label: "sound.option.nope02" },
|
||||
{ id: "nope-03", label: "sound.option.nope03" },
|
||||
{ id: "nope-04", label: "sound.option.nope04" },
|
||||
{ id: "nope-05", label: "sound.option.nope05" },
|
||||
{ id: "nope-06", label: "sound.option.nope06" },
|
||||
{ id: "nope-07", label: "sound.option.nope07" },
|
||||
{ id: "nope-08", label: "sound.option.nope08" },
|
||||
{ id: "nope-09", label: "sound.option.nope09" },
|
||||
{ id: "nope-10", label: "sound.option.nope10" },
|
||||
{ id: "nope-11", label: "sound.option.nope11" },
|
||||
{ id: "nope-12", label: "sound.option.nope12" },
|
||||
{ id: "yup-01", label: "sound.option.yup01" },
|
||||
{ id: "yup-02", label: "sound.option.yup02" },
|
||||
{ id: "yup-03", label: "sound.option.yup03" },
|
||||
{ id: "yup-04", label: "sound.option.yup04" },
|
||||
{ id: "yup-05", label: "sound.option.yup05" },
|
||||
{ id: "yup-06", label: "sound.option.yup06" },
|
||||
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
|
||||
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
|
||||
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
|
||||
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
|
||||
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
|
||||
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
|
||||
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
|
||||
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
|
||||
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
|
||||
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
|
||||
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
|
||||
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
|
||||
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
|
||||
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
|
||||
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
|
||||
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
|
||||
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
|
||||
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
|
||||
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
|
||||
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
|
||||
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
|
||||
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
|
||||
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
|
||||
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
|
||||
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
|
||||
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
|
||||
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
|
||||
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
|
||||
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
|
||||
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
|
||||
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
|
||||
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
|
||||
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
|
||||
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
|
||||
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
|
||||
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
|
||||
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
|
||||
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
|
||||
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
|
||||
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
|
||||
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
|
||||
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
|
||||
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
|
||||
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
|
||||
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
|
||||
] as const
|
||||
|
||||
export type SoundOption = (typeof SOUND_OPTIONS)[number]
|
||||
export type SoundID = SoundOption["id"]
|
||||
|
||||
function getLoads() {
|
||||
if (loads) return loads
|
||||
loads = Object.fromEntries(
|
||||
Object.entries(getFiles()).flatMap(([path, load]) => {
|
||||
const file = path.split("/").at(-1)
|
||||
if (!file) return []
|
||||
return [[file.replace(/\.aac$/, ""), load] as const]
|
||||
}),
|
||||
) as Record<SoundID, () => Promise<string>>
|
||||
return loads
|
||||
}
|
||||
|
||||
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
const loads = getLoads()
|
||||
if (!id || !(id in loads)) return Promise.resolve(undefined)
|
||||
const key = id as SoundID
|
||||
const hit = cache.get(key)
|
||||
if (hit) return hit
|
||||
const next = loads[key]().catch(() => undefined)
|
||||
cache.set(key, next)
|
||||
return next
|
||||
if (!id) return
|
||||
if (!(id in soundById)) return
|
||||
return soundById[id as SoundID]
|
||||
}
|
||||
|
||||
export function playSound(src: string | undefined) {
|
||||
@@ -91,12 +108,10 @@ export function playSound(src: string | undefined) {
|
||||
if (!src) return
|
||||
const audio = new Audio(src)
|
||||
audio.play().catch(() => undefined)
|
||||
|
||||
// Return a cleanup function to pause the sound.
|
||||
return () => {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
export function playSoundById(id: string | undefined) {
|
||||
return soundSrc(id).then((src) => playSound(src))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
|
||||
|
||||
/**
|
||||
* @type {import("vite").PluginOption}
|
||||
*/
|
||||
@@ -24,15 +21,6 @@ export default [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opencode-desktop:theme-preload",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
|
||||
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
|
||||
)
|
||||
},
|
||||
},
|
||||
tailwindcss(),
|
||||
solidPlugin(),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
|
||||
|
||||
export async function GET() {
|
||||
return redirect(
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -340,13 +340,6 @@ export async function handler(
|
||||
"error.message": error.message,
|
||||
"error.cause": error.cause?.toString(),
|
||||
})
|
||||
if (error.message.startsWith("Failed query")) {
|
||||
try {
|
||||
logger.metric({
|
||||
"error.cause2": JSON.stringify(error.cause),
|
||||
})
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
if (
|
||||
@@ -468,17 +461,12 @@ export async function handler(
|
||||
...modelProvider,
|
||||
...zenData.providers[modelProvider.id],
|
||||
...(() => {
|
||||
const providerProps = zenData.providers[modelProvider.id]
|
||||
const format = providerProps.format
|
||||
const format = zenData.providers[modelProvider.id].format
|
||||
const providerModel = modelProvider.model
|
||||
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
|
||||
if (format === "google") return googleHelper({ reqModel, providerModel })
|
||||
if (format === "openai") return openaiHelper({ reqModel, providerModel })
|
||||
return oaCompatHelper({
|
||||
reqModel,
|
||||
providerModel,
|
||||
adjustCacheUsage: providerProps.adjustCacheUsage,
|
||||
})
|
||||
return oaCompatHelper({ reqModel, providerModel })
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type Usage = {
|
||||
}
|
||||
}
|
||||
|
||||
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
|
||||
export const oaCompatHelper: ProviderHelper = () => ({
|
||||
format: "oa-compat",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
@@ -57,15 +57,10 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
let inputTokens = usage.prompt_tokens ?? 0
|
||||
const inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
|
||||
if (adjustCacheUsage && !cacheReadTokens) {
|
||||
cacheReadTokens = Math.floor(inputTokens * 0.9)
|
||||
}
|
||||
|
||||
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens,
|
||||
|
||||
@@ -33,7 +33,7 @@ export type UsageInfo = {
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
|
||||
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
|
||||
export type ProviderHelper = (input: { reqModel: string; providerModel: string }) => {
|
||||
format: ZenData.Format
|
||||
modifyUrl: (providerApi: string, isStream?: boolean) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import {
|
||||
BillingTable,
|
||||
PaymentTable,
|
||||
SubscriptionTable,
|
||||
BlackPlans,
|
||||
UsageTable,
|
||||
LiteTable,
|
||||
} from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
@@ -79,13 +72,11 @@ else {
|
||||
workspaceID: UserTable.workspaceID,
|
||||
workspaceName: WorkspaceTable.name,
|
||||
role: UserTable.role,
|
||||
black: SubscriptionTable.timeCreated,
|
||||
lite: LiteTable.timeCreated,
|
||||
subscribed: SubscriptionTable.timeCreated,
|
||||
})
|
||||
.from(UserTable)
|
||||
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||
.leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
|
||||
.where(eq(UserTable.accountID, accountID))
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
@@ -93,8 +84,7 @@ else {
|
||||
workspaceID: row.workspaceID,
|
||||
workspaceName: row.workspaceName,
|
||||
role: row.role,
|
||||
black: formatDate(row.black),
|
||||
lite: formatDate(row.lite),
|
||||
subscribed: formatDate(row.subscribed),
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -161,14 +151,13 @@ async function printWorkspace(workspaceID: string) {
|
||||
balance: BillingTable.balance,
|
||||
customerID: BillingTable.customerID,
|
||||
reload: BillingTable.reload,
|
||||
blackSubscriptionID: BillingTable.subscriptionID,
|
||||
blackSubscription: {
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
subscription: {
|
||||
plan: BillingTable.subscriptionPlan,
|
||||
booked: BillingTable.timeSubscriptionBooked,
|
||||
enrichment: BillingTable.subscription,
|
||||
},
|
||||
timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||
liteSubscriptionID: BillingTable.liteSubscriptionID,
|
||||
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspace.id))
|
||||
@@ -178,21 +167,16 @@ async function printWorkspace(workspaceID: string) {
|
||||
balance: `$${(row.balance / 100000000).toFixed(2)}`,
|
||||
reload: row.reload ? "yes" : "no",
|
||||
customerID: row.customerID,
|
||||
liteSubscriptionID: row.liteSubscriptionID,
|
||||
blackSubscriptionID: row.blackSubscriptionID,
|
||||
blackSubscription: row.blackSubscriptionID
|
||||
subscriptionID: row.subscriptionID,
|
||||
subscription: row.subscriptionID
|
||||
? [
|
||||
`Black ${row.blackSubscription.enrichment!.plan}`,
|
||||
row.blackSubscription.enrichment!.seats > 1
|
||||
? `X ${row.blackSubscription.enrichment!.seats} seats`
|
||||
: "",
|
||||
row.blackSubscription.enrichment!.coupon
|
||||
? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
|
||||
: "",
|
||||
`(ref: ${row.blackSubscriptionID})`,
|
||||
`Black ${row.subscription.enrichment!.plan}`,
|
||||
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
|
||||
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
|
||||
`(ref: ${row.subscriptionID})`,
|
||||
].join(" ")
|
||||
: row.blackSubscription.booked
|
||||
? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
|
||||
: row.subscription.booked
|
||||
? `Waitlist ${row.subscription.plan} plan${row.timeSubscriptionSelected ? " (selected)" : ""}`
|
||||
: undefined,
|
||||
}))[0],
|
||||
),
|
||||
|
||||
@@ -48,7 +48,6 @@ export namespace ZenData {
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const ModelsSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -6,9 +6,6 @@ import {
|
||||
AppBaseProviders,
|
||||
AppInterface,
|
||||
handleNotificationClick,
|
||||
loadLocaleDict,
|
||||
normalizeLocale,
|
||||
type Locale,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
ServerConnection,
|
||||
@@ -249,17 +246,6 @@ listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
const raw = current ?? legacy
|
||||
if (!raw) return
|
||||
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||
if (!locale) return
|
||||
const next = normalizeLocale(locale)
|
||||
if (next !== "en") await loadLocaleDict(next)
|
||||
return next satisfies Locale
|
||||
}
|
||||
|
||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||
|
||||
@@ -271,7 +257,6 @@ render(() => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
const [locale] = createResource(loadLocale)
|
||||
|
||||
const servers = () => {
|
||||
const data = sidecar()
|
||||
@@ -324,14 +309,15 @@ render(() => {
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
|
||||
<AppBaseProviders>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
disableHealthCheck={(windowCount() ?? 0) > 1}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,9 +6,6 @@ import {
|
||||
AppBaseProviders,
|
||||
AppInterface,
|
||||
handleNotificationClick,
|
||||
loadLocaleDict,
|
||||
normalizeLocale,
|
||||
type Locale,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
ServerConnection,
|
||||
@@ -417,17 +414,6 @@ void listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
const raw = current ?? legacy
|
||||
if (!raw) return
|
||||
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||
if (!locale) return
|
||||
const next = normalizeLocale(locale)
|
||||
if (next !== "en") await loadLocaleDict(next)
|
||||
return next satisfies Locale
|
||||
}
|
||||
|
||||
// Fetch sidecar credentials from Rust (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
||||
@@ -437,7 +423,6 @@ render(() => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
const [locale] = createResource(loadLocale)
|
||||
|
||||
// Build the sidecar server connection once credentials arrive
|
||||
const servers = () => {
|
||||
@@ -480,8 +465,8 @@ render(() => {
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
|
||||
<AppBaseProviders>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.2"
|
||||
version = "1.3.0"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -55,7 +55,6 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -122,7 +121,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.2",
|
||||
"gitlab-ai-provider": "5.2.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -134,7 +133,6 @@
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
@@ -146,7 +144,6 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.18.0",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
|
||||
@@ -101,14 +101,6 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "kotlin",
|
||||
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
|
||||
queries: {
|
||||
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
|
||||
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ruby",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
|
||||
@@ -166,15 +158,6 @@ export default {
|
||||
// },
|
||||
// },
|
||||
},
|
||||
{
|
||||
filetype: "hcl",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "json",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
|
||||
@@ -220,16 +203,6 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "lua",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
|
||||
],
|
||||
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ocaml",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
|
||||
@@ -263,15 +236,6 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "toml",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "nix",
|
||||
// TODO: Replace with official tree-sitter-nix WASM when published
|
||||
|
||||
@@ -173,6 +173,6 @@ Still open and likely worth migrating:
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [x] `Project`
|
||||
- [ ] `Project`
|
||||
- [ ] `LSP`
|
||||
- [ ] `MCP`
|
||||
|
||||
@@ -3,6 +3,7 @@ import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool/truncate"
|
||||
import { Auth } from "../auth"
|
||||
@@ -19,9 +20,6 @@ import { Global } from "@/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, ServiceMap, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -51,364 +49,295 @@ export namespace 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
|
||||
}>
|
||||
}
|
||||
const state = Instance.state(async () => {
|
||||
const cfg = await Config.get()
|
||||
|
||||
type State = Omit<Interface, "generate">
|
||||
const skillDirs = await 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 ?? {})
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = () => Effect.promise(() => Config.get())
|
||||
const auth = yield* Auth.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config()
|
||||
const skillDirs = yield* Effect.promise(() => Skill.dirs())
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
const result: 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({
|
||||
todoread: "deny",
|
||||
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"])),
|
||||
},
|
||||
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",
|
||||
},
|
||||
})
|
||||
}),
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
if (value.disable) {
|
||||
delete result[key]
|
||||
continue
|
||||
}
|
||||
let item = result[key]
|
||||
if (!item)
|
||||
item = result[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 ?? {}))
|
||||
}
|
||||
|
||||
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({
|
||||
todoread: "deny",
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
yield* Effect.promise(() =>
|
||||
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
|
||||
)
|
||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||
|
||||
const params = {
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
},
|
||||
},
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
...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]
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
||||
if (model.providerID === "openai" && authInfo?.type === "oauth") {
|
||||
return yield* Effect.promise(async () => {
|
||||
const result = streamObject({
|
||||
...params,
|
||||
providerOptions: ProviderTransform.providerOptions(resolved, {
|
||||
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))
|
||||
}),
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
for (const name in result) {
|
||||
const agent = result[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
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
result[name].permission = Permission.merge(
|
||||
result[name].permission,
|
||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
return result
|
||||
})
|
||||
|
||||
export async function get(agent: string) {
|
||||
return runPromise((svc) => svc.get(agent))
|
||||
return state().then((x) => x[agent])
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
const cfg = await Config.get()
|
||||
return pipe(
|
||||
await state(),
|
||||
values(),
|
||||
sortBy(
|
||||
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
|
||||
[(x) => x.name, "asc"],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function defaultAgent() {
|
||||
return runPromise((svc) => svc.defaultAgent())
|
||||
const cfg = await Config.get()
|
||||
const agents = await state()
|
||||
|
||||
if (cfg.default_agent) {
|
||||
const agent = agents[cfg.default_agent]
|
||||
if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`)
|
||||
if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`)
|
||||
if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`)
|
||||
return agent.name
|
||||
}
|
||||
|
||||
const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
|
||||
if (!primaryVisible) throw new Error("no primary visible agent found")
|
||||
return primaryVisible.name
|
||||
}
|
||||
|
||||
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
|
||||
return runPromise((svc) => svc.generate(input))
|
||||
const cfg = await Config.get()
|
||||
const defaultModel = input.model ?? (await Provider.defaultModel())
|
||||
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
|
||||
const language = await Provider.getLanguage(model)
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
|
||||
const existing = await list()
|
||||
|
||||
const params = {
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
},
|
||||
},
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
...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]
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
|
||||
const result = streamObject({
|
||||
...params,
|
||||
providerOptions: ProviderTransform.providerOptions(model, {
|
||||
store: false,
|
||||
}),
|
||||
onError: () => {},
|
||||
})
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
}
|
||||
return result.object
|
||||
}
|
||||
|
||||
const result = await generateObject(params)
|
||||
return result.object
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,26 +10,6 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
|
||||
|
||||
const println = (msg: string) => Effect.sync(() => UI.println(msg))
|
||||
|
||||
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
|
||||
|
||||
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
|
||||
|
||||
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
|
||||
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
|
||||
|
||||
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
|
||||
`${org.name} (${account.email})${activeSuffix(isActive)}`
|
||||
|
||||
export const formatOrgLine = (
|
||||
account: { email: string; url: string },
|
||||
org: { id: string; name: string },
|
||||
isActive: boolean,
|
||||
) => {
|
||||
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
|
||||
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
|
||||
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
|
||||
}
|
||||
|
||||
const isActiveOrgChoice = (
|
||||
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
|
||||
choice: { accountID: AccountID; orgID: OrgID },
|
||||
@@ -96,9 +76,10 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
|
||||
const opts = accounts.map((a) => {
|
||||
const isActive = Option.isSome(activeID) && activeID.value === a.id
|
||||
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
|
||||
return {
|
||||
value: a,
|
||||
label: formatAccountLabel(a, isActive),
|
||||
label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -128,7 +109,9 @@ const switchEffect = Effect.fn("switch")(function* () {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
return {
|
||||
value: { orgID: org.id, accountID: group.account.id, label: org.name },
|
||||
label: formatOrgChoiceLabel(group.account, org, isActive),
|
||||
label: isActive
|
||||
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
|
||||
: `${org.name} (${group.account.email})`,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -156,21 +139,15 @@ const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
for (const group of groups) {
|
||||
for (const org of group.orgs) {
|
||||
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
|
||||
yield* println(formatOrgLine(group.account, org, isActive))
|
||||
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
|
||||
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
|
||||
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
|
||||
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
|
||||
yield* println(` ${dot} ${name} ${email} ${id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const openEffect = Effect.fn("open")(function* () {
|
||||
const service = yield* Account.Service
|
||||
const active = yield* service.active()
|
||||
if (Option.isNone(active)) return yield* println("No active account")
|
||||
|
||||
const url = active.value.url
|
||||
yield* openBrowser(url)
|
||||
yield* Prompt.outro("Opened " + url)
|
||||
})
|
||||
|
||||
export const LoginCommand = cmd({
|
||||
command: "login <url>",
|
||||
describe: false,
|
||||
@@ -218,15 +195,6 @@ export const OrgsCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
export const OpenCommand = cmd({
|
||||
command: "open",
|
||||
describe: false,
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await Account.runPromise((_svc) => openEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
command: "console",
|
||||
describe: false,
|
||||
@@ -248,10 +216,6 @@ export const ConsoleCommand = cmd({
|
||||
...OrgsCommand,
|
||||
describe: "list orgs",
|
||||
})
|
||||
.command({
|
||||
...OpenCommand,
|
||||
describe: "open active console account",
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -110,7 +110,6 @@ export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Info
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
@@ -161,7 +160,7 @@ export function tui(input: {
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
@@ -202,7 +201,7 @@ export function tui(input: {
|
||||
})
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
@@ -213,7 +212,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const command = useCommandDialog()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = useTheme()
|
||||
const { theme, mode, setMode } = useTheme()
|
||||
const sync = useSync()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
@@ -558,7 +557,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle Theme Mode",
|
||||
title: "Toggle appearance",
|
||||
value: "theme.switch_mode",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
@@ -566,16 +565,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
|
||||
value: "theme.mode.lock",
|
||||
onSelect: (dialog) => {
|
||||
if (locked()) unlock()
|
||||
else lock()
|
||||
dialog.clear()
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: "help.show",
|
||||
@@ -628,11 +617,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
title: "Write heap snapshot",
|
||||
category: "System",
|
||||
value: "app.heap_snapshot",
|
||||
onSelect: async (dialog) => {
|
||||
const files = await props.onSnapshot?.()
|
||||
onSelect: (dialog) => {
|
||||
const path = writeHeapSnapshot()
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Heap snapshot written to ${files?.join(", ")}`,
|
||||
message: `Heap snapshot written to ${path}`,
|
||||
duration: 5000,
|
||||
})
|
||||
dialog.clear()
|
||||
|
||||
@@ -53,7 +53,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
message: store,
|
||||
},
|
||||
)
|
||||
process.on("SIGHUP", () => exit())
|
||||
return exit
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
||||
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Glob } from "../../../../util/glob"
|
||||
import aura from "./theme/aura.json" with { type: "json" }
|
||||
@@ -280,18 +280,11 @@ function ansiToRgba(code: number): RGBA {
|
||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light" }) => {
|
||||
const renderer = useRenderer()
|
||||
const config = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
}
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
|
||||
lock,
|
||||
mode: kv.get("theme_mode", props.mode),
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
@@ -302,7 +295,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
function init() {
|
||||
resolveSystemTheme(store.mode)
|
||||
resolveSystemTheme()
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
@@ -323,12 +316,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolveSystemTheme")
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
console.log(colors.palette)
|
||||
if (!colors.palette[0]) {
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
@@ -342,7 +337,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, mode)
|
||||
draft.themes.system = generateSystem(colors, store.mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
@@ -351,44 +346,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
function apply(mode: "dark" | "light") {
|
||||
kv.set("theme_mode", mode)
|
||||
if (store.mode === mode) return
|
||||
setStore("mode", mode)
|
||||
const renderer = useRenderer()
|
||||
process.on("SIGUSR2", async () => {
|
||||
renderer.clearPaletteCache()
|
||||
resolveSystemTheme(mode)
|
||||
}
|
||||
|
||||
function pin(mode: "dark" | "light" = store.mode) {
|
||||
setStore("lock", mode)
|
||||
kv.set("theme_mode_lock", mode)
|
||||
apply(mode)
|
||||
}
|
||||
|
||||
function free() {
|
||||
setStore("lock", undefined)
|
||||
kv.set("theme_mode_lock", undefined)
|
||||
const mode = renderer.themeMode
|
||||
if (mode) apply(mode)
|
||||
}
|
||||
|
||||
const handle = (mode: "dark" | "light") => {
|
||||
if (store.lock) return
|
||||
apply(mode)
|
||||
}
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
init()
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.setBackgroundColor(values().background)
|
||||
})
|
||||
|
||||
const syntax = createMemo(() => generateSyntax(values()))
|
||||
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
|
||||
|
||||
@@ -410,17 +377,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
mode() {
|
||||
return store.mode
|
||||
},
|
||||
locked() {
|
||||
return store.lock !== undefined
|
||||
},
|
||||
lock() {
|
||||
pin(store.mode)
|
||||
},
|
||||
unlock() {
|
||||
free()
|
||||
},
|
||||
setMode(mode: "dark" | "light") {
|
||||
pin(mode)
|
||||
setStore("mode", mode)
|
||||
kv.set("theme_mode", mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
setStore("active", theme)
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -202,11 +201,6 @@ export const TuiThreadCommand = cmd({
|
||||
try {
|
||||
await tui({
|
||||
url: transport.url,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -118,10 +117,6 @@ export const rpc = {
|
||||
body,
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
const result = writeHeapSnapshot("server.heapsnapshot")
|
||||
return result
|
||||
},
|
||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||
if (server) await server.stop(true)
|
||||
server = await Server.listen(input)
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
import type * as Arr from "effect/Array"
|
||||
import { NodeSink, NodeStream } from "@effect/platform-node"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as FileSystem from "effect/FileSystem"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Path from "effect/Path"
|
||||
import * as PlatformError from "effect/PlatformError"
|
||||
import * as Predicate from "effect/Predicate"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as Sink from "effect/Sink"
|
||||
import * as Stream from "effect/Stream"
|
||||
import * as ChildProcess from "effect/unstable/process/ChildProcess"
|
||||
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import {
|
||||
ChildProcessSpawner,
|
||||
ExitCode,
|
||||
make as makeSpawner,
|
||||
makeHandle,
|
||||
ProcessId,
|
||||
} from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as NodeChildProcess from "node:child_process"
|
||||
import { PassThrough } from "node:stream"
|
||||
import launch from "cross-spawn"
|
||||
|
||||
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
||||
|
||||
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
|
||||
switch (err.code) {
|
||||
case "ENOENT":
|
||||
return "NotFound"
|
||||
case "EACCES":
|
||||
return "PermissionDenied"
|
||||
case "EEXIST":
|
||||
return "AlreadyExists"
|
||||
case "EISDIR":
|
||||
return "BadResource"
|
||||
case "ENOTDIR":
|
||||
return "BadResource"
|
||||
case "EBUSY":
|
||||
return "Busy"
|
||||
case "ELOOP":
|
||||
return "BadResource"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const flatten = (command: ChildProcess.Command) => {
|
||||
const commands: Array<ChildProcess.StandardCommand> = []
|
||||
const opts: Array<ChildProcess.PipeOptions> = []
|
||||
|
||||
const walk = (cmd: ChildProcess.Command): void => {
|
||||
switch (cmd._tag) {
|
||||
case "StandardCommand":
|
||||
commands.push(cmd)
|
||||
return
|
||||
case "PipedCommand":
|
||||
walk(cmd.left)
|
||||
opts.push(cmd.options)
|
||||
walk(cmd.right)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
walk(command)
|
||||
if (commands.length === 0) throw new Error("flatten produced empty commands array")
|
||||
const [head, ...tail] = commands
|
||||
return {
|
||||
commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
|
||||
opts,
|
||||
}
|
||||
}
|
||||
|
||||
const toPlatformError = (
|
||||
method: string,
|
||||
err: NodeJS.ErrnoException,
|
||||
command: ChildProcess.Command,
|
||||
): PlatformError.PlatformError => {
|
||||
const cmd = flatten(command)
|
||||
.commands.map((x) => `${x.command} ${x.args.join(" ")}`)
|
||||
.join(" | ")
|
||||
return PlatformError.systemError({
|
||||
_tag: toTag(err),
|
||||
module: "ChildProcess",
|
||||
method,
|
||||
pathOrDescriptor: cmd,
|
||||
syscall: err.syscall,
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
|
||||
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
|
||||
const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) {
|
||||
if (Predicate.isUndefined(opts.cwd)) return undefined
|
||||
yield* fs.access(opts.cwd)
|
||||
return path.resolve(opts.cwd)
|
||||
})
|
||||
|
||||
const env = (opts: ChildProcess.CommandOptions) =>
|
||||
opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env
|
||||
|
||||
const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
|
||||
Stream.isStream(x) ? "pipe" : x
|
||||
|
||||
const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
|
||||
Sink.isSink(x) ? "pipe" : x
|
||||
|
||||
const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
|
||||
const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
|
||||
if (Predicate.isUndefined(opts.stdin)) return cfg
|
||||
if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin }
|
||||
if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin }
|
||||
return {
|
||||
stream: opts.stdin.stream,
|
||||
encoding: opts.stdin.encoding ?? cfg.encoding,
|
||||
endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone,
|
||||
}
|
||||
}
|
||||
|
||||
const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => {
|
||||
const cfg = opts[key]
|
||||
if (Predicate.isUndefined(cfg)) return { stream: "pipe" }
|
||||
if (typeof cfg === "string") return { stream: cfg }
|
||||
if (Sink.isSink(cfg)) return { stream: cfg }
|
||||
return { stream: cfg.stream }
|
||||
}
|
||||
|
||||
const fds = (opts: ChildProcess.CommandOptions) => {
|
||||
if (Predicate.isUndefined(opts.additionalFds)) return []
|
||||
return Object.entries(opts.additionalFds)
|
||||
.flatMap(([name, config]) => {
|
||||
const fd = ChildProcess.parseFdName(name)
|
||||
return Predicate.isUndefined(fd) ? [] : [{ fd, config }]
|
||||
})
|
||||
.toSorted((a, b) => a.fd - b.fd)
|
||||
}
|
||||
|
||||
const stdios = (
|
||||
sin: ChildProcess.StdinConfig,
|
||||
sout: ChildProcess.StdoutConfig,
|
||||
serr: ChildProcess.StderrConfig,
|
||||
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
|
||||
): NodeChildProcess.StdioOptions => {
|
||||
const pipe = (x: NodeChildProcess.IOType | undefined) =>
|
||||
process.platform === "win32" && x === "pipe" ? "overlapped" : x
|
||||
const arr: Array<NodeChildProcess.IOType | undefined> = [
|
||||
pipe(input(sin.stream)),
|
||||
pipe(output(sout.stream)),
|
||||
pipe(output(serr.stream)),
|
||||
]
|
||||
if (extra.length === 0) return arr as NodeChildProcess.StdioOptions
|
||||
const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2)
|
||||
for (let i = 3; i <= max; i++) arr[i] = "ignore"
|
||||
for (const x of extra) arr[x.fd] = pipe("pipe")
|
||||
return arr as NodeChildProcess.StdioOptions
|
||||
}
|
||||
|
||||
const setupFds = Effect.fnUntraced(function* (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
|
||||
) {
|
||||
if (extra.length === 0) {
|
||||
return {
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
}
|
||||
}
|
||||
|
||||
const ins = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
|
||||
const outs = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
|
||||
|
||||
for (const x of extra) {
|
||||
const node = proc.stdio[x.fd]
|
||||
switch (x.config.type) {
|
||||
case "input": {
|
||||
let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
|
||||
if (node && "write" in node) {
|
||||
sink = NodeSink.fromWritable({
|
||||
evaluate: () => node,
|
||||
onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command),
|
||||
endOnDone: true,
|
||||
})
|
||||
}
|
||||
if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink))
|
||||
ins.set(x.fd, sink)
|
||||
break
|
||||
}
|
||||
case "output": {
|
||||
let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
|
||||
if (node && "read" in node) {
|
||||
const tap = new PassThrough()
|
||||
node.on("error", (err) => tap.destroy(toError(err)))
|
||||
node.pipe(tap)
|
||||
stream = NodeStream.fromReadable({
|
||||
evaluate: () => tap,
|
||||
onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command),
|
||||
})
|
||||
}
|
||||
if (x.config.sink) stream = Stream.transduce(stream, x.config.sink)
|
||||
outs.set(x.fd, stream)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain,
|
||||
getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty,
|
||||
}
|
||||
})
|
||||
|
||||
const setupStdin = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
cfg: ChildProcess.StdinConfig,
|
||||
) =>
|
||||
Effect.suspend(() => {
|
||||
let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
|
||||
if (Predicate.isNotNull(proc.stdin)) {
|
||||
sink = NodeSink.fromWritable({
|
||||
evaluate: () => proc.stdin!,
|
||||
onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command),
|
||||
endOnDone: cfg.endOnDone,
|
||||
encoding: cfg.encoding,
|
||||
})
|
||||
}
|
||||
if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink)
|
||||
return Effect.succeed(sink)
|
||||
})
|
||||
|
||||
const setupOutput = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
out: ChildProcess.StdoutConfig,
|
||||
err: ChildProcess.StderrConfig,
|
||||
) => {
|
||||
let stdout = proc.stdout
|
||||
? NodeStream.fromReadable({
|
||||
evaluate: () => proc.stdout!,
|
||||
onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
|
||||
})
|
||||
: Stream.empty
|
||||
let stderr = proc.stderr
|
||||
? NodeStream.fromReadable({
|
||||
evaluate: () => proc.stderr!,
|
||||
onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
|
||||
})
|
||||
: Stream.empty
|
||||
|
||||
if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream)
|
||||
if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream)
|
||||
|
||||
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
|
||||
}
|
||||
|
||||
const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
|
||||
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
|
||||
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
|
||||
const proc = launch(command.command, command.args, opts)
|
||||
let end = false
|
||||
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
|
||||
proc.on("error", (err) => {
|
||||
resume(Effect.fail(toPlatformError("spawn", err, command)))
|
||||
})
|
||||
proc.on("exit", (...args) => {
|
||||
exit = args
|
||||
})
|
||||
proc.on("close", (...args) => {
|
||||
if (end) return
|
||||
end = true
|
||||
Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
|
||||
})
|
||||
proc.on("spawn", () => {
|
||||
resume(Effect.succeed([proc, signal]))
|
||||
})
|
||||
return Effect.sync(() => {
|
||||
proc.kill("SIGTERM")
|
||||
})
|
||||
})
|
||||
|
||||
const killGroup = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals,
|
||||
) => {
|
||||
if (globalThis.process.platform === "win32") {
|
||||
return Effect.callback<void, PlatformError.PlatformError>((resume) => {
|
||||
NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => {
|
||||
if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command)))
|
||||
resume(Effect.void)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
globalThis.process.kill(-proc.pid!, signal)
|
||||
},
|
||||
catch: (err) => toPlatformError("kill", toError(err), command),
|
||||
})
|
||||
}
|
||||
|
||||
const killOne = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals,
|
||||
) =>
|
||||
Effect.suspend(() => {
|
||||
if (proc.kill(signal)) return Effect.void
|
||||
return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command))
|
||||
})
|
||||
|
||||
const timeout =
|
||||
(
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
command: ChildProcess.StandardCommand,
|
||||
opts: ChildProcess.KillOptions | undefined,
|
||||
) =>
|
||||
<A, E, R>(
|
||||
f: (
|
||||
command: ChildProcess.StandardCommand,
|
||||
proc: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals,
|
||||
) => Effect.Effect<A, E, R>,
|
||||
) => {
|
||||
const signal = opts?.killSignal ?? "SIGTERM"
|
||||
if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal)
|
||||
return Effect.timeoutOrElse(f(command, proc, signal), {
|
||||
duration: opts.forceKillAfter,
|
||||
onTimeout: () => f(command, proc, "SIGKILL"),
|
||||
})
|
||||
}
|
||||
|
||||
const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => {
|
||||
const opt = from ?? "stdout"
|
||||
switch (opt) {
|
||||
case "stdout":
|
||||
return handle.stdout
|
||||
case "stderr":
|
||||
return handle.stderr
|
||||
case "all":
|
||||
return handle.all
|
||||
default: {
|
||||
const fd = ChildProcess.parseFdName(opt)
|
||||
return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCommand: (
|
||||
command: ChildProcess.Command,
|
||||
) => Effect.Effect<ChildProcessHandle, PlatformError.PlatformError, Scope.Scope> = Effect.fnUntraced(
|
||||
function* (command) {
|
||||
switch (command._tag) {
|
||||
case "StandardCommand": {
|
||||
const sin = stdin(command.options)
|
||||
const sout = stdio(command.options, "stdout")
|
||||
const serr = stdio(command.options, "stderr")
|
||||
const extra = fds(command.options)
|
||||
const dir = yield* cwd(command.options)
|
||||
|
||||
const [proc, signal] = yield* Effect.acquireRelease(
|
||||
spawn(command, {
|
||||
cwd: dir,
|
||||
env: env(command.options),
|
||||
stdio: stdios(sin, sout, serr, extra),
|
||||
detached: command.options.detached ?? process.platform !== "win32",
|
||||
shell: command.options.shell,
|
||||
windowsHide: process.platform === "win32",
|
||||
}),
|
||||
Effect.fnUntraced(function* ([proc, signal]) {
|
||||
const done = yield* Deferred.isDone(signal)
|
||||
const kill = timeout(proc, command, command.options)
|
||||
if (done) {
|
||||
const [code] = yield* Deferred.await(signal)
|
||||
if (process.platform === "win32") return yield* Effect.void
|
||||
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
||||
return yield* Effect.void
|
||||
}
|
||||
return yield* kill((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
|
||||
}),
|
||||
)
|
||||
|
||||
const fd = yield* setupFds(command, proc, extra)
|
||||
const out = setupOutput(command, proc, sout, serr)
|
||||
return makeHandle({
|
||||
pid: ProcessId(proc.pid!),
|
||||
stdin: yield* setupStdin(command, proc, sin),
|
||||
stdout: out.stdout,
|
||||
stderr: out.stderr,
|
||||
all: out.all,
|
||||
getInputFd: fd.getInputFd,
|
||||
getOutputFd: fd.getOutputFd,
|
||||
isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
|
||||
exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
|
||||
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
|
||||
return Effect.fail(
|
||||
toPlatformError(
|
||||
"exitCode",
|
||||
new Error(`Process interrupted due to receipt of signal: '${signal}'`),
|
||||
command,
|
||||
),
|
||||
)
|
||||
}),
|
||||
kill: (opts?: ChildProcess.KillOptions) =>
|
||||
timeout(
|
||||
proc,
|
||||
command,
|
||||
opts,
|
||||
)((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
}
|
||||
case "PipedCommand": {
|
||||
const flat = flatten(command)
|
||||
const [head, ...tail] = flat.commands
|
||||
let handle = spawnCommand(head)
|
||||
for (let i = 0; i < tail.length; i++) {
|
||||
const next = tail[i]
|
||||
const opts = flat.opts[i] ?? {}
|
||||
const sin = stdin(next.options)
|
||||
const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from)))
|
||||
const to = opts.to ?? "stdin"
|
||||
if (to === "stdin") {
|
||||
handle = spawnCommand(
|
||||
ChildProcess.make(next.command, next.args, {
|
||||
...next.options,
|
||||
stdin: { ...sin, stream },
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const fd = ChildProcess.parseFdName(to)
|
||||
if (Predicate.isUndefined(fd)) {
|
||||
handle = spawnCommand(
|
||||
ChildProcess.make(next.command, next.args, {
|
||||
...next.options,
|
||||
stdin: { ...sin, stream },
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
handle = spawnCommand(
|
||||
ChildProcess.make(next.command, next.args, {
|
||||
...next.options,
|
||||
additionalFds: {
|
||||
...next.options.additionalFds,
|
||||
[ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream },
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
return yield* handle
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return makeSpawner(spawnCommand)
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
|
||||
ChildProcessSpawner,
|
||||
make,
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
@@ -341,7 +340,7 @@ export namespace Installation {
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
+6
-20
@@ -1,12 +1,11 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Log } from "../../util/log"
|
||||
import { Installation } from "../../installation"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import { Log } from "../util/log"
|
||||
import { Installation } from "../installation"
|
||||
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createWebSocketFetch } from "./websocket"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -352,18 +351,12 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
||||
}
|
||||
|
||||
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const ws = createWebSocketFetch()
|
||||
return {
|
||||
auth: {
|
||||
provider: "openai",
|
||||
async loader(getAuth, provider) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth")
|
||||
return {
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
return ws(requestInput, init)
|
||||
},
|
||||
}
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
// Filter models to only allowed Codex models for OAuth
|
||||
const allowedModels = new Set([
|
||||
@@ -498,7 +491,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
? new URL(CODEX_API_ENDPOINT)
|
||||
: parsed
|
||||
|
||||
return ws(url, {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
@@ -629,14 +622,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
if (input.model.providerID !== "openai") return
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
|
||||
// TODO: this is kinda hacky, we need to move the transport creation to a later point so it can accept more arguments rather than just relying on whatever is passed to fetch...
|
||||
// distinguish between title gen and actual chat session
|
||||
if (!input.small) {
|
||||
output.headers.session_id = input.sessionID
|
||||
} else {
|
||||
output.headers.session_id = input.sessionID + "_title"
|
||||
}
|
||||
output.headers.session_id = input.sessionID
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
@@ -45,7 +44,7 @@ export namespace Plugin {
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
||||
|
||||
// Old npm package names for plugins that are now built-in — skip if users still have them in config
|
||||
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||
@@ -137,11 +136,7 @@ export namespace Plugin {
|
||||
|
||||
// Notify plugins of current config
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
await (hook as any).config?.(cfg)
|
||||
} catch (err) {
|
||||
log.error("plugin config hook failed", { error: err })
|
||||
}
|
||||
await (hook as any).config?.(cfg)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import WebSocket from "ws"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
const log = Log.create({ service: "plugin.openai.websocket" })
|
||||
|
||||
export interface CreateWebSocketFetchOptions {
|
||||
/**
|
||||
* WebSocket endpoint URL.
|
||||
* @default 'wss://api.openai.com/v1/responses'
|
||||
*/
|
||||
url?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `fetch` function that routes OpenAI Responses API streaming
|
||||
* requests through a persistent WebSocket connection instead of HTTP.
|
||||
*
|
||||
* Non-streaming requests and requests to other endpoints are passed
|
||||
* through to the standard `fetch`.
|
||||
*
|
||||
* The connection is created lazily on the first streaming request and
|
||||
* reused for subsequent ones, which is the main source of latency
|
||||
* savings in multi-step tool-calling workflows.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createOpenAI } from '@ai-sdk/openai';
|
||||
* import { createWebSocketFetch } from 'ai-sdk-openai-websocket-fetch';
|
||||
*
|
||||
* const wsFetch = createWebSocketFetch();
|
||||
* const openai = createOpenAI({ fetch: wsFetch });
|
||||
*
|
||||
* const result = streamText({
|
||||
* model: openai('gpt-4.1-mini'),
|
||||
* prompt: 'Hello!',
|
||||
* onFinish: () => wsFetch.close(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
let ws: WebSocket | null = null
|
||||
let connecting: Promise<WebSocket> | null = null
|
||||
let busy = false
|
||||
|
||||
function getConnection(url: string, headers: Record<string, string>): Promise<WebSocket> {
|
||||
if (ws?.readyState === WebSocket.OPEN && !busy) {
|
||||
log.debug("reusing websocket", { url })
|
||||
return Promise.resolve(ws)
|
||||
}
|
||||
if (connecting && !busy) return connecting
|
||||
|
||||
connecting = new Promise<WebSocket>((resolve, reject) => {
|
||||
log.debug("connecting websocket", {
|
||||
url,
|
||||
headers: Object.keys(headers).sort().join(","),
|
||||
})
|
||||
const socket = new WebSocket(url, { headers })
|
||||
|
||||
socket.on("open", () => {
|
||||
ws = socket
|
||||
connecting = null
|
||||
log.debug("websocket connected", { url })
|
||||
resolve(socket)
|
||||
})
|
||||
|
||||
socket.on("error", (err) => {
|
||||
log.debug("websocket connect error", {
|
||||
url,
|
||||
error: err.message,
|
||||
})
|
||||
if (connecting) {
|
||||
connecting = null
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("close", () => {
|
||||
log.debug("websocket closed", { url })
|
||||
if (ws === socket) ws = null
|
||||
})
|
||||
})
|
||||
|
||||
return connecting
|
||||
}
|
||||
|
||||
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
|
||||
|
||||
if (init?.method !== "POST" || !url.endsWith("/responses")) {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = JSON.parse(typeof init.body === "string" ? init.body : "")
|
||||
} catch {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
if (!body.stream) {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
const wsUrl = getWebSocketURL(url, options?.url)
|
||||
const headers = getWebSocketHeaders(init.headers)
|
||||
|
||||
log.debug("intercepting responses request", {
|
||||
url,
|
||||
wsUrl,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
const connection = await getConnection(wsUrl, headers)
|
||||
busy = true
|
||||
|
||||
const { stream: _, ...requestBody } = body
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const responseStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
function cleanup() {
|
||||
connection.off("message", onMessage)
|
||||
connection.off("error", onError)
|
||||
connection.off("close", onClose)
|
||||
busy = false
|
||||
}
|
||||
|
||||
function onMessage(data: WebSocket.RawData) {
|
||||
const text = data.toString()
|
||||
log.debug("websocket event", { event: pretty(text), url: wsUrl })
|
||||
controller.enqueue(encoder.encode(`data: ${text}\n\n`))
|
||||
|
||||
try {
|
||||
const event = JSON.parse(text)
|
||||
if (event.type === "response.completed" || event.type === "error") {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
cleanup()
|
||||
controller.close()
|
||||
}
|
||||
} catch {
|
||||
// non-JSON frame, continue
|
||||
}
|
||||
}
|
||||
|
||||
function onError(err: Error) {
|
||||
log.debug("websocket stream error", { url: wsUrl, error: err.message })
|
||||
cleanup()
|
||||
controller.error(err)
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
log.debug("websocket stream close", { url: wsUrl })
|
||||
cleanup()
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
|
||||
connection.on("message", onMessage)
|
||||
connection.on("error", onError)
|
||||
connection.on("close", onClose)
|
||||
|
||||
if (init?.signal) {
|
||||
if (init.signal.aborted) {
|
||||
cleanup()
|
||||
controller.error(init.signal.reason ?? new DOMException("Aborted", "AbortError"))
|
||||
return
|
||||
}
|
||||
init.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
cleanup()
|
||||
try {
|
||||
controller.error(init!.signal!.reason ?? new DOMException("Aborted", "AbortError"))
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
|
||||
connection.send(JSON.stringify({ type: "response.create", ...requestBody }))
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(responseStream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
return Object.assign(websocketFetch, {
|
||||
/** Close the underlying WebSocket connection. */
|
||||
close() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!headers) return result
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((v, k) => {
|
||||
result[k.toLowerCase()] = v
|
||||
})
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [k, v] of headers) {
|
||||
result[k.toLowerCase()] = v
|
||||
}
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (v != null) result[k.toLowerCase()] = v
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getWebSocketHeaders(headers: HeadersInit | undefined) {
|
||||
const result = normalizeHeaders(headers)
|
||||
delete result["content-length"]
|
||||
result["openai-beta"] ??= "responses_websockets=2026-02-06"
|
||||
return result
|
||||
}
|
||||
|
||||
function getWebSocketURL(url: string, fallback?: string) {
|
||||
if (fallback) return fallback
|
||||
|
||||
const parsed = new URL(url)
|
||||
parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"
|
||||
return parsed.toString()
|
||||
}
|
||||
|
||||
function pretty(text: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,36 @@
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import path from "path"
|
||||
import { and, Database, eq } from "../storage/db"
|
||||
import { ProjectTable } from "./project.sql"
|
||||
import { SessionTable } from "../session/session.sql"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { fn } from "@opencode-ai/util/fn"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { existsSync } from "fs"
|
||||
import { git } from "../util/git"
|
||||
import { Glob } from "../util/glob"
|
||||
import { which } from "../util/which"
|
||||
import { ProjectID } from "./schema"
|
||||
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
function gitpath(cwd: string, name: string) {
|
||||
if (!name) return cwd
|
||||
// git output includes trailing newlines; keep path whitespace intact.
|
||||
name = name.replace(/[\r\n]+$/, "")
|
||||
if (!name) return cwd
|
||||
|
||||
name = Filesystem.windowsPath(name)
|
||||
|
||||
if (path.isAbsolute(name)) return path.normalize(name)
|
||||
return path.resolve(cwd, name)
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: ProjectID.zod,
|
||||
@@ -60,7 +73,7 @@ export namespace Project {
|
||||
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
|
||||
: undefined
|
||||
return {
|
||||
id: row.id,
|
||||
id: ProjectID.make(row.id),
|
||||
worktree: row.worktree,
|
||||
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
|
||||
name: row.name ?? undefined,
|
||||
@@ -75,405 +88,245 @@ export namespace Project {
|
||||
}
|
||||
}
|
||||
|
||||
export const UpdateInput = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
name: z.string().optional(),
|
||||
icon: Info.shape.icon.optional(),
|
||||
commands: Info.shape.commands.optional(),
|
||||
})
|
||||
export type UpdateInput = z.infer<typeof UpdateInput>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
||||
readonly discover: (input: Info) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
|
||||
readonly update: (input: UpdateInput) => Effect.Effect<Info>
|
||||
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
|
||||
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
|
||||
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
|
||||
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
||||
function readCachedId(dir: string) {
|
||||
return Filesystem.readText(path.join(dir, "opencode"))
|
||||
.then((x) => x.trim())
|
||||
.then(ProjectID.make)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
|
||||
export async function fromDirectory(directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
const data = await iife(async () => {
|
||||
const matches = Filesystem.up({ targets: [".git"], start: directory })
|
||||
const dotgit = await matches.next().then((x) => x.value)
|
||||
await matches.return()
|
||||
if (dotgit) {
|
||||
let sandbox = path.dirname(dotgit)
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const gitBinary = which("git")
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
||||
)
|
||||
// cached id calculation
|
||||
let id = await readCachedId(dotgit)
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
const emitUpdated = (data: Info) =>
|
||||
Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
payload: { type: Event.Updated.type, properties: data },
|
||||
}),
|
||||
)
|
||||
const worktree = await git(["rev-parse", "--git-common-dir"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) => {
|
||||
const common = gitpath(sandbox, await result.text())
|
||||
// Avoid going to parent of sandbox when git-common-dir is empty.
|
||||
return common === sandbox ? sandbox : path.dirname(common)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
|
||||
if (!worktree) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
const resolveGitPath = (cwd: string, name: string) => {
|
||||
if (!name) return cwd
|
||||
name = name.replace(/[\r\n]+$/, "")
|
||||
if (!name) return cwd
|
||||
name = AppFileSystem.windowsPath(name)
|
||||
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
||||
return pathSvc.resolve(cwd, name)
|
||||
}
|
||||
// In the case of a git worktree, it can't cache the id
|
||||
// because `.git` is not a folder, but it always needs the
|
||||
// same project id as the common dir, so we resolve it now
|
||||
if (id == null) {
|
||||
id = await readCachedId(path.join(worktree, ".git"))
|
||||
}
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
// generate id from root commit
|
||||
if (!id) {
|
||||
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) =>
|
||||
(await result.text())
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted(),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map(ProjectID.make),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
})
|
||||
|
||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
// Phase 1: discover git info
|
||||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
|
||||
if (!dotgit) {
|
||||
if (!roots) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
if (commonDir.code !== 0) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
// Write to common dir so the cache is shared across worktrees.
|
||||
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
|
||||
}
|
||||
const worktree = (() => {
|
||||
const common = resolveGitPath(sandbox, commonDir.text.trim())
|
||||
return common === sandbox ? sandbox : pathSvc.dirname(common)
|
||||
})()
|
||||
}
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
|
||||
if (!id) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: "git",
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted()
|
||||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
if (topLevel.code !== 0) {
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
const top = await git(["rev-parse", "--show-toplevel"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) => gitpath(sandbox, await result.text()))
|
||||
.catch(() => undefined)
|
||||
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
id: data.id,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
sandboxes: [] as string[],
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
if (!top) {
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
|
||||
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
sandbox = top
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
return {
|
||||
id,
|
||||
sandbox,
|
||||
worktree,
|
||||
vcs: "git",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
})
|
||||
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
id: data.id,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
result.sandboxes,
|
||||
(s) =>
|
||||
fsys.exists(s).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((exists) => (exists ? s : undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
|
||||
yield* db((d) =>
|
||||
d
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: result.id,
|
||||
worktree: result.worktree,
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
icon_color: result.icon?.color,
|
||||
time_created: result.time.created,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
commands: result.commands,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: {
|
||||
worktree: result.worktree,
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
icon_color: result.icon?.color,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
commands: result.commands,
|
||||
},
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
|
||||
if (data.id !== ProjectID.global) {
|
||||
yield* db((d) =>
|
||||
d
|
||||
.update(SessionTable)
|
||||
.set({ project_id: data.id })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||
.run(),
|
||||
)
|
||||
vcs: data.vcs as Info["vcs"],
|
||||
sandboxes: [] as string[],
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
yield* emitUpdated(result)
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
|
||||
|
||||
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
|
||||
const matches = yield* fsys
|
||||
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
|
||||
const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = AppFileSystem.mimeType(shortest)
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
yield* update({ projectID: input.id, icon: { url } })
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
||||
})
|
||||
|
||||
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
name: input.name,
|
||||
icon_url: input.icon?.url,
|
||||
icon_color: input.icon?.color,
|
||||
commands: input.commands,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${input.projectID}`)
|
||||
const data = fromRow(result)
|
||||
yield* emitUpdated(data)
|
||||
return data
|
||||
})
|
||||
|
||||
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
|
||||
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
|
||||
}
|
||||
const { project } = yield* fromDirectory(input.directory)
|
||||
return project
|
||||
})
|
||||
|
||||
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
|
||||
yield* db((d) =>
|
||||
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
})
|
||||
|
||||
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return []
|
||||
const data = fromRow(row)
|
||||
return yield* Effect.forEach(
|
||||
data.sandboxes,
|
||||
(dir) =>
|
||||
fsys.isDir(dir).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((ok) => (ok ? dir : undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
})
|
||||
|
||||
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = [...row.sandboxes]
|
||||
if (!sboxes.includes(directory)) sboxes.push(directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
|
||||
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
fromDirectory,
|
||||
discover,
|
||||
list,
|
||||
get,
|
||||
update,
|
||||
initGit,
|
||||
setInitialized,
|
||||
sandboxes,
|
||||
addSandbox,
|
||||
removeSandbox,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Promise-based API (delegates to Effect service via runPromise)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fromDirectory(directory: string) {
|
||||
return runPromise((svc) => svc.fromDirectory(directory))
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs as Info["vcs"],
|
||||
time: {
|
||||
...existing.time,
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
|
||||
const insert = {
|
||||
id: result.id,
|
||||
worktree: result.worktree,
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
icon_color: result.icon?.color,
|
||||
time_created: result.time.created,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
commands: result.commands,
|
||||
}
|
||||
const updateSet = {
|
||||
worktree: result.worktree,
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
icon_color: result.icon?.color,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
commands: result.commands,
|
||||
}
|
||||
Database.use((db) =>
|
||||
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
|
||||
)
|
||||
// Runs after upsert so the target project row exists (FK constraint).
|
||||
// Runs on every startup because sessions created before git init
|
||||
// accumulate under "global" and need migrating whenever they appear.
|
||||
if (data.id !== ProjectID.global) {
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ project_id: data.id })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: Event.Updated.type,
|
||||
properties: result,
|
||||
},
|
||||
})
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
}
|
||||
|
||||
export function discover(input: Info) {
|
||||
return runPromise((svc) => svc.discover(input))
|
||||
export async function discover(input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
const buffer = await Filesystem.readBytes(shortest)
|
||||
const base64 = buffer.toString("base64")
|
||||
const mime = Filesystem.mimeType(shortest) || "image/png"
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
await update({
|
||||
projectID: input.id,
|
||||
icon: {
|
||||
url,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
export function setInitialized(id: ProjectID) {
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
time_initialized: Date.now(),
|
||||
})
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
|
||||
export function list() {
|
||||
@@ -492,29 +345,112 @@ export namespace Project {
|
||||
return fromRow(row)
|
||||
}
|
||||
|
||||
export function setInitialized(id: ProjectID) {
|
||||
Database.use((db) =>
|
||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
export async function initGit(input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
if (!which("git")) throw new Error("Git is not installed")
|
||||
|
||||
const result = await git(["init", "--quiet"], {
|
||||
cwd: input.directory,
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
const text = result.stderr.toString().trim() || result.text().trim()
|
||||
throw new Error(text || "Failed to initialize git repository")
|
||||
}
|
||||
|
||||
return (await fromDirectory(input.directory)).project
|
||||
}
|
||||
|
||||
export const update = fn(
|
||||
z.object({
|
||||
projectID: ProjectID.zod,
|
||||
name: z.string().optional(),
|
||||
icon: Info.shape.icon.optional(),
|
||||
commands: Info.shape.commands.optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const id = ProjectID.make(input.projectID)
|
||||
const result = Database.use((db) =>
|
||||
db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
name: input.name,
|
||||
icon_url: input.icon?.url,
|
||||
icon_color: input.icon?.color,
|
||||
commands: input.commands,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${input.projectID}`)
|
||||
const data = fromRow(result)
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: Event.Updated.type,
|
||||
properties: data,
|
||||
},
|
||||
})
|
||||
return data
|
||||
},
|
||||
)
|
||||
|
||||
export async function sandboxes(id: ProjectID) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return []
|
||||
const data = fromRow(row)
|
||||
const valid: string[] = []
|
||||
for (const dir of data.sandboxes) {
|
||||
const s = Filesystem.stat(dir)
|
||||
if (s?.isDirectory()) valid.push(dir)
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function addSandbox(id: ProjectID, directory: string) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sandboxes = [...row.sandboxes]
|
||||
if (!sandboxes.includes(directory)) sandboxes.push(directory)
|
||||
const result = Database.use((db) =>
|
||||
db
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
const data = fromRow(result)
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: Event.Updated.type,
|
||||
properties: data,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export function initGit(input: { directory: string; project: Info }) {
|
||||
return runPromise((svc) => svc.initGit(input))
|
||||
}
|
||||
|
||||
export function update(input: UpdateInput) {
|
||||
return runPromise((svc) => svc.update(input))
|
||||
}
|
||||
|
||||
export function sandboxes(id: ProjectID) {
|
||||
return runPromise((svc) => svc.sandboxes(id))
|
||||
}
|
||||
|
||||
export function addSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.addSandbox(id, directory))
|
||||
}
|
||||
|
||||
export function removeSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.removeSandbox(id, directory))
|
||||
export async function removeSandbox(id: ProjectID, directory: string) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sandboxes = row.sandboxes.filter((s) => s !== directory)
|
||||
const result = Database.use((db) =>
|
||||
db
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
const data = fromRow(result)
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: Event.Updated.type,
|
||||
properties: data,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +194,7 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
const useMessageLevelOptions =
|
||||
model.providerID === "anthropic" ||
|
||||
model.providerID.includes("bedrock") ||
|
||||
model.api.npm === "@ai-sdk/amazon-bedrock"
|
||||
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
|
||||
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
|
||||
|
||||
if (shouldUseContentOptions) {
|
||||
|
||||
@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ projectID: ProjectID.zod })),
|
||||
validator("json", Project.UpdateInput.omit({ projectID: true })),
|
||||
validator("json", Project.update.schema.omit({ projectID: true })),
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
|
||||
@@ -19,8 +19,6 @@ import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Bus } from "../../bus"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -848,13 +846,7 @@ export const SessionRoutes = lazy(() =>
|
||||
return stream(c, async () => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
})
|
||||
SessionPrompt.prompt({ ...body, sessionID })
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -113,20 +113,17 @@ export namespace LLM {
|
||||
options.instructions = system.join("\n")
|
||||
}
|
||||
|
||||
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
||||
const messages = isOpenaiOauth
|
||||
? input.messages
|
||||
: isWorkflow
|
||||
? input.messages
|
||||
: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...input.messages,
|
||||
]
|
||||
: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...input.messages,
|
||||
]
|
||||
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
@@ -155,7 +152,6 @@ export namespace LLM {
|
||||
model: input.model,
|
||||
provider,
|
||||
message: input.user,
|
||||
small: input.small === true,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
@@ -194,7 +190,6 @@ export namespace LLM {
|
||||
// and results sent back over the WebSocket.
|
||||
if (language instanceof GitLabWorkflowLanguageModel) {
|
||||
const workflowModel = language
|
||||
workflowModel.systemPrompt = system.join("\n")
|
||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||
const t = tools[toolName]
|
||||
if (!t || !t.execute) {
|
||||
@@ -255,16 +250,12 @@ export namespace LLM {
|
||||
maxOutputTokens,
|
||||
abortSignal: input.abort,
|
||||
headers: {
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
"x-opencode-project": Instance.project.id,
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||
}
|
||||
: {
|
||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||
}),
|
||||
...(input.model.providerID.startsWith("opencode") && {
|
||||
"x-opencode-project": Instance.project.id,
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||
}),
|
||||
...input.model.headers,
|
||||
...headers,
|
||||
},
|
||||
|
||||
@@ -418,16 +418,6 @@ export namespace SessionPrompt {
|
||||
)
|
||||
let executionError: Error | undefined
|
||||
const taskAgent = await Agent.get(task.agent)
|
||||
if (!taskAgent) {
|
||||
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: error.toObject(),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
const taskCtx: Tool.Context = {
|
||||
agent: task.agent,
|
||||
messageID: assistantMessage.id,
|
||||
@@ -570,16 +560,6 @@ export namespace SessionPrompt {
|
||||
|
||||
// normal processing
|
||||
const agent = await Agent.get(lastUser.agent)
|
||||
if (!agent) {
|
||||
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: error.toObject(),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
const maxSteps = agent.steps ?? Infinity
|
||||
const isLastStep = step >= maxSteps
|
||||
msgs = await insertReminders({
|
||||
@@ -984,18 +964,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
async function createUserMessage(input: PromptInput) {
|
||||
const agentName = input.agent || (await Agent.defaultAgent())
|
||||
const agent = await Agent.get(agentName)
|
||||
if (!agent) {
|
||||
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error: error.toObject(),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
|
||||
|
||||
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
||||
const full =
|
||||
@@ -1562,16 +1531,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
await SessionRevert.cleanup(session)
|
||||
}
|
||||
const agent = await Agent.get(input.agent)
|
||||
if (!agent) {
|
||||
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error: error.toObject(),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
||||
const userMsg: MessageV2.User = {
|
||||
id: MessageID.ascending(),
|
||||
@@ -1824,14 +1783,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
log.info("command", input)
|
||||
const command = await Command.get(input.command)
|
||||
if (!command) {
|
||||
const available = await Command.list().then((cmds) => cmds.map((c) => c.name))
|
||||
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error: error.toObject(),
|
||||
})
|
||||
throw error
|
||||
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
|
||||
}
|
||||
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
@@ -355,9 +354,9 @@ export namespace Snapshot {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
|
||||
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
|
||||
+109
-101
@@ -33,6 +33,16 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
|
||||
return text.replaceAll("\n", "\r\n")
|
||||
}
|
||||
|
||||
function stats(before: string, after: string) {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(before, after)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
@@ -115,23 +125,16 @@ export const EditTool = Tool.define("edit", {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
const count = stats(contentOld, contentNew)
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
before: contentOld,
|
||||
after: contentNew,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(contentOld, contentNew)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
additions: count.additions,
|
||||
deletions: count.deletions,
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
@@ -167,7 +170,18 @@ export const EditTool = Tool.define("edit", {
|
||||
},
|
||||
})
|
||||
|
||||
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
||||
type Prep = {
|
||||
content: string
|
||||
find: string
|
||||
lines: string[]
|
||||
finds: string[]
|
||||
}
|
||||
|
||||
export type Replacer = (prep: Prep) => Generator<string, void, unknown>
|
||||
|
||||
function trimLastEmpty(lines: string[]) {
|
||||
return lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines
|
||||
}
|
||||
|
||||
// Similarity thresholds for block anchor fallback matching
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||
@@ -181,37 +195,36 @@ function levenshtein(a: string, b: string): number {
|
||||
if (a === "" || b === "") {
|
||||
return Math.max(a.length, b.length)
|
||||
}
|
||||
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
)
|
||||
const [left, right] = a.length < b.length ? [a, b] : [b, a]
|
||||
let prev = Array.from({ length: left.length + 1 }, (_, i) => i)
|
||||
let next = Array.from({ length: left.length + 1 }, () => 0)
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||
for (let i = 1; i <= right.length; i++) {
|
||||
next[0] = i
|
||||
for (let j = 1; j <= left.length; j++) {
|
||||
const cost = right[i - 1] === left[j - 1] ? 0 : 1
|
||||
next[j] = Math.min(next[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
|
||||
}
|
||||
}
|
||||
return matrix[a.length][b.length]
|
||||
}
|
||||
|
||||
export const SimpleReplacer: Replacer = function* (_content, find) {
|
||||
yield find
|
||||
}
|
||||
|
||||
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
;[prev, next] = [next, prev]
|
||||
}
|
||||
|
||||
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||
return prev[left.length]
|
||||
}
|
||||
|
||||
export const SimpleReplacer: Replacer = function* (prep) {
|
||||
yield prep.find
|
||||
}
|
||||
|
||||
export const LineTrimmedReplacer: Replacer = function* (prep) {
|
||||
const original = prep.lines
|
||||
const search = trimLastEmpty(prep.finds)
|
||||
|
||||
for (let i = 0; i <= original.length - search.length; i++) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
for (let j = 0; j < search.length; j++) {
|
||||
const originalTrimmed = original[i + j].trim()
|
||||
const searchTrimmed = search[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
@@ -222,48 +235,42 @@ export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||
if (matches) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
matchStartIndex += original[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length
|
||||
if (k < searchLines.length - 1) {
|
||||
for (let k = 0; k < search.length; k++) {
|
||||
matchEndIndex += original[i + k].length
|
||||
if (k < search.length - 1) {
|
||||
matchEndIndex += 1 // Add newline character except for the last line
|
||||
}
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
export const BlockAnchorReplacer: Replacer = function* (prep) {
|
||||
const original = prep.lines
|
||||
const search = trimLastEmpty(prep.finds)
|
||||
|
||||
if (searchLines.length < 3) {
|
||||
return
|
||||
}
|
||||
if (search.length < 3) return
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
const firstLineSearch = search[0].trim()
|
||||
const lastLineSearch = search[search.length - 1].trim()
|
||||
const searchBlockSize = search.length
|
||||
|
||||
// Collect all candidate positions where both anchors match
|
||||
const candidates: Array<{ startLine: number; endLine: number }> = []
|
||||
for (let i = 0; i < originalLines.length; i++) {
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
if (original[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look for the matching last line after this first line
|
||||
for (let j = i + 2; j < originalLines.length; j++) {
|
||||
if (originalLines[j].trim() === lastLineSearch) {
|
||||
for (let j = i + 2; j < original.length; j++) {
|
||||
if (original[j].trim() === lastLineSearch) {
|
||||
candidates.push({ startLine: i, endLine: j })
|
||||
break // Only match the first occurrence of the last line
|
||||
}
|
||||
@@ -285,8 +292,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[startLine + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const originalLine = original[startLine + j].trim()
|
||||
const searchLine = search[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
@@ -307,16 +314,16 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < startLine; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
matchStartIndex += original[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = startLine; k <= endLine; k++) {
|
||||
matchEndIndex += originalLines[k].length
|
||||
matchEndIndex += original[k].length
|
||||
if (k < endLine) {
|
||||
matchEndIndex += 1 // Add newline character except for the last line
|
||||
}
|
||||
}
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -334,8 +341,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[startLine + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const originalLine = original[startLine + j].trim()
|
||||
const searchLine = search[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
@@ -360,25 +367,25 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const { startLine, endLine } = bestMatch
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < startLine; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
matchStartIndex += original[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = startLine; k <= endLine; k++) {
|
||||
matchEndIndex += originalLines[k].length
|
||||
matchEndIndex += original[k].length
|
||||
if (k < endLine) {
|
||||
matchEndIndex += 1
|
||||
}
|
||||
}
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
yield prep.content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (prep) {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||
const normalizedFind = normalizeWhitespace(find)
|
||||
const normalizedFind = normalizeWhitespace(prep.find)
|
||||
|
||||
// Handle single line matches
|
||||
const lines = content.split("\n")
|
||||
const lines = prep.lines
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (normalizeWhitespace(line) === normalizedFind) {
|
||||
@@ -388,7 +395,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
|
||||
const normalizedLine = normalizeWhitespace(line)
|
||||
if (normalizedLine.includes(normalizedFind)) {
|
||||
// Find the actual substring in the original line that matches
|
||||
const words = find.trim().split(/\s+/)
|
||||
const words = prep.find.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
||||
try {
|
||||
@@ -406,7 +413,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
|
||||
}
|
||||
|
||||
// Handle multi-line matches
|
||||
const findLines = find.split("\n")
|
||||
const findLines = prep.finds
|
||||
if (findLines.length > 1) {
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length)
|
||||
@@ -417,7 +424,7 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
|
||||
}
|
||||
}
|
||||
|
||||
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
export const IndentationFlexibleReplacer: Replacer = function* (prep) {
|
||||
const removeIndentation = (text: string) => {
|
||||
const lines = text.split("\n")
|
||||
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
||||
@@ -433,9 +440,9 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
||||
}
|
||||
|
||||
const normalizedFind = removeIndentation(find)
|
||||
const contentLines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
const normalizedFind = removeIndentation(prep.find)
|
||||
const contentLines = prep.lines
|
||||
const findLines = prep.finds
|
||||
|
||||
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
||||
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
||||
@@ -445,7 +452,7 @@ export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||
export const EscapeNormalizedReplacer: Replacer = function* (prep) {
|
||||
const unescapeString = (str: string): string => {
|
||||
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
||||
switch (capturedChar) {
|
||||
@@ -473,15 +480,15 @@ export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||
})
|
||||
}
|
||||
|
||||
const unescapedFind = unescapeString(find)
|
||||
const unescapedFind = unescapeString(prep.find)
|
||||
|
||||
// Try direct match with unescaped find string
|
||||
if (content.includes(unescapedFind)) {
|
||||
if (prep.content.includes(unescapedFind)) {
|
||||
yield unescapedFind
|
||||
}
|
||||
|
||||
// Also try finding escaped versions in content that match unescaped find
|
||||
const lines = content.split("\n")
|
||||
const lines = prep.lines
|
||||
const findLines = unescapedFind.split("\n")
|
||||
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
@@ -494,36 +501,36 @@ export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
||||
export const MultiOccurrenceReplacer: Replacer = function* (prep) {
|
||||
// This replacer yields all exact matches, allowing the replace function
|
||||
// to handle multiple occurrences based on replaceAll parameter
|
||||
let startIndex = 0
|
||||
|
||||
while (true) {
|
||||
const index = content.indexOf(find, startIndex)
|
||||
const index = prep.content.indexOf(prep.find, startIndex)
|
||||
if (index === -1) break
|
||||
|
||||
yield find
|
||||
startIndex = index + find.length
|
||||
yield prep.find
|
||||
startIndex = index + prep.find.length
|
||||
}
|
||||
}
|
||||
|
||||
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
||||
const trimmedFind = find.trim()
|
||||
export const TrimmedBoundaryReplacer: Replacer = function* (prep) {
|
||||
const trimmedFind = prep.find.trim()
|
||||
|
||||
if (trimmedFind === find) {
|
||||
if (trimmedFind === prep.find) {
|
||||
// Already trimmed, no point in trying
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find the trimmed version
|
||||
if (content.includes(trimmedFind)) {
|
||||
if (prep.content.includes(trimmedFind)) {
|
||||
yield trimmedFind
|
||||
}
|
||||
|
||||
// Also try finding blocks where trimmed content matches
|
||||
const lines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
const lines = prep.lines
|
||||
const findLines = prep.finds
|
||||
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length).join("\n")
|
||||
@@ -534,19 +541,13 @@ export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
||||
}
|
||||
}
|
||||
|
||||
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
||||
const findLines = find.split("\n")
|
||||
export const ContextAwareReplacer: Replacer = function* (prep) {
|
||||
const findLines = trimLastEmpty(prep.finds)
|
||||
if (findLines.length < 3) {
|
||||
// Need at least 3 lines to have meaningful context
|
||||
return
|
||||
}
|
||||
|
||||
// Remove trailing empty line if present
|
||||
if (findLines[findLines.length - 1] === "") {
|
||||
findLines.pop()
|
||||
}
|
||||
|
||||
const contentLines = content.split("\n")
|
||||
const contentLines = prep.lines
|
||||
|
||||
// Extract first and last lines as context anchors
|
||||
const firstLine = findLines[0].trim()
|
||||
@@ -635,6 +636,13 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
|
||||
let notFound = true
|
||||
|
||||
const prep = {
|
||||
content,
|
||||
find: oldString,
|
||||
lines: content.split("\n"),
|
||||
finds: oldString.split("\n"),
|
||||
} satisfies Prep
|
||||
|
||||
for (const replacer of [
|
||||
SimpleReplacer,
|
||||
LineTrimmedReplacer,
|
||||
@@ -646,7 +654,7 @@ export function replace(content: string, oldString: string, newString: string, r
|
||||
ContextAwareReplacer,
|
||||
MultiOccurrenceReplacer,
|
||||
]) {
|
||||
for (const search of replacer(content, oldString)) {
|
||||
for (const search of replacer(prep)) {
|
||||
const index = content.indexOf(search)
|
||||
if (index === -1) continue
|
||||
notFound = false
|
||||
|
||||
@@ -17,6 +17,8 @@ const MAX_LINE_LENGTH = 2000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MAX_BYTES = 50 * 1024
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
|
||||
const MAX_ATTACHMENT_LABEL = `${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`
|
||||
|
||||
export const ReadTool = Tool.define("read", {
|
||||
description: DESCRIPTION,
|
||||
@@ -122,6 +124,9 @@ export const ReadTool = Tool.define("read", {
|
||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||
const isPdf = mime === "application/pdf"
|
||||
if (isImage || isPdf) {
|
||||
if (Number(stat.size) > MAX_ATTACHMENT_BYTES) {
|
||||
throw new Error(`Cannot attach file larger than ${MAX_ATTACHMENT_LABEL}: ${filepath}`)
|
||||
}
|
||||
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
||||
return {
|
||||
title,
|
||||
@@ -167,7 +172,7 @@ export const ReadTool = Tool.define("read", {
|
||||
|
||||
if (raw.length >= limit) {
|
||||
hasMoreLines = true
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||
@@ -198,7 +203,6 @@ export const ReadTool = Tool.define("read", {
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||
output += content.join("\n")
|
||||
|
||||
const totalLines = lines
|
||||
const lastReadLine = offset + raw.length - 1
|
||||
const nextOffset = lastReadLine + 1
|
||||
const truncated = hasMoreLines || truncatedByBytes
|
||||
@@ -206,9 +210,9 @@ export const ReadTool = Tool.define("read", {
|
||||
if (truncatedByBytes) {
|
||||
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
||||
} else if (hasMoreLines) {
|
||||
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
|
||||
output += `\n\n(Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
||||
} else {
|
||||
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||
output += `\n\n(End of file - total ${lines} lines)`
|
||||
}
|
||||
output += "\n</content>"
|
||||
|
||||
|
||||
@@ -22,10 +22,6 @@ export namespace Log {
|
||||
return levelPriority[input] >= levelPriority[level]
|
||||
}
|
||||
|
||||
function shouldServiceLog(service: unknown) {
|
||||
return service === "plugin.openai.websocket"
|
||||
}
|
||||
|
||||
export type Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>): void
|
||||
info(message?: any, extra?: Record<string, any>): void
|
||||
@@ -66,10 +62,8 @@ export namespace Log {
|
||||
cleanup(Global.Path.log)
|
||||
if (options.print) return
|
||||
logpath = path.join(
|
||||
// TODO: STOP DOING THIS!!!!!
|
||||
"dev.log",
|
||||
// Global.Path.log,
|
||||
// options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
Global.Path.log,
|
||||
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
)
|
||||
await fs.truncate(logpath).catch(() => {})
|
||||
const stream = createWriteStream(logpath, { flags: "a" })
|
||||
@@ -115,7 +109,6 @@ export namespace Log {
|
||||
}
|
||||
|
||||
function build(message: any, extra?: Record<string, any>) {
|
||||
if (!shouldServiceLog(tags?.["service"])) return
|
||||
const prefix = Object.entries({
|
||||
...tags,
|
||||
...extra,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import stripAnsi from "strip-ansi"
|
||||
|
||||
import { formatAccountLabel, formatOrgLine } from "../../src/cli/cmd/account"
|
||||
|
||||
describe("console account display", () => {
|
||||
test("includes the account url in account labels", () => {
|
||||
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, false))).toBe(
|
||||
"one@example.com https://one.example.com",
|
||||
)
|
||||
})
|
||||
|
||||
test("includes the active marker in account labels", () => {
|
||||
expect(stripAnsi(formatAccountLabel({ email: "one@example.com", url: "https://one.example.com" }, true))).toBe(
|
||||
"one@example.com https://one.example.com (active)",
|
||||
)
|
||||
})
|
||||
|
||||
test("includes the account url in org rows", () => {
|
||||
expect(
|
||||
stripAnsi(
|
||||
formatOrgLine({ email: "one@example.com", url: "https://one.example.com" }, { id: "org-1", name: "One" }, true),
|
||||
),
|
||||
).toBe(" ● One one@example.com https://one.example.com org-1")
|
||||
})
|
||||
})
|
||||
@@ -1,402 +0,0 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
const fx = testEffect(live)
|
||||
|
||||
function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
return ChildProcess.make("node", ["-e", code], opts)
|
||||
}
|
||||
|
||||
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
||||
return Stream.runCollect(stream).pipe(
|
||||
Effect.map((chunks) => {
|
||||
const total = chunks.reduce((acc, x) => acc + x.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, off)
|
||||
off += chunk.length
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(out).trim()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function alive(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function gone(pid: number, timeout = 5_000) {
|
||||
const end = Date.now() + timeout
|
||||
while (Date.now() < end) {
|
||||
if (!alive(pid)) return true
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
return !alive(pid)
|
||||
}
|
||||
|
||||
describe("cross-spawn spawner", () => {
|
||||
describe("basic spawning", () => {
|
||||
fx.effect(
|
||||
"captures stdout",
|
||||
Effect.gen(function* () {
|
||||
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
|
||||
)
|
||||
expect(out).toBe("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures multiple lines",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")')
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
expect(out).toBe("line1\nline2\nline3")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"returns exit code",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("process.exit(0)")
|
||||
const code = yield* handle.exitCode
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"returns non-zero exit code",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("process.exit(42)")
|
||||
const code = yield* handle.exitCode
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("cwd option", () => {
|
||||
fx.effect(
|
||||
"uses cwd when spawning commands",
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(
|
||||
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
|
||||
),
|
||||
)
|
||||
expect(out).toBe(tmp.path)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"fails for invalid cwd",
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("env option", () => {
|
||||
fx.effect(
|
||||
"passes environment variables with extendEnv",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', {
|
||||
env: { TEST_VAR: "test_value" },
|
||||
extendEnv: true,
|
||||
})
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
expect(out).toBe("test_value")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"passes multiple environment variables",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js(
|
||||
"process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)",
|
||||
{
|
||||
env: { VAR1: "one", VAR2: "two", VAR3: "three" },
|
||||
extendEnv: true,
|
||||
},
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
expect(out).toBe("one-two-three")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("stderr", () => {
|
||||
fx.effect(
|
||||
"captures stderr output",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stderr.write("error message")')
|
||||
const err = yield* decodeByteStream(handle.stderr)
|
||||
expect(err).toBe("error message")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures both stdout and stderr",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
|
||||
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
|
||||
expect(stdout).toBe("stdout")
|
||||
expect(stderr).toBe("stderr")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("combined output (all)", () => {
|
||||
fx.effect(
|
||||
"captures stdout via .all when no stderr",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
|
||||
const all = yield* decodeByteStream(handle.all)
|
||||
expect(all).toBe("hello from stdout")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures stderr via .all when no stdout",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stderr.write("hello from stderr")')
|
||||
const all = yield* decodeByteStream(handle.all)
|
||||
expect(all).toBe("hello from stderr")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("stdin", () => {
|
||||
fx.effect(
|
||||
"allows providing standard input to a command",
|
||||
Effect.gen(function* () {
|
||||
const input = "a b c"
|
||||
const stdin = Stream.make(Buffer.from(input, "utf-8"))
|
||||
const handle = yield* js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
||||
{ stdin },
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("a b c")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("process control", () => {
|
||||
fx.effect(
|
||||
"kills a running process",
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("setTimeout(() => {}, 10_000)")
|
||||
yield* handle.kill()
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"kills a child when scope exits",
|
||||
Effect.gen(function* () {
|
||||
const pid = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("setInterval(() => {}, 10_000)")
|
||||
return Number(handle.pid)
|
||||
}),
|
||||
)
|
||||
const done = yield* Effect.promise(() => gone(pid))
|
||||
expect(done).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"forceKillAfter escalates for stubborn processes",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
const started = Date.now()
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
|
||||
yield* handle.kill({ forceKillAfter: 100 })
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"isRunning reflects process state",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("done")')
|
||||
yield* handle.exitCode
|
||||
const running = yield* handle.isRunning
|
||||
expect(running).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
fx.effect(
|
||||
"fails for invalid command",
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("nonexistent-command-12345")
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("pipeline", () => {
|
||||
fx.effect(
|
||||
"pipes stdout of one command to stdin of another",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("hello world")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
|
||||
),
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("HELLO WORLD")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"three-stage pipeline",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("hello world")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
|
||||
),
|
||||
),
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))',
|
||||
),
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("HELLO-WORLD")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"pipes stderr with { from: 'stderr' }",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stderr.write("error")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
||||
),
|
||||
{ from: "stderr" },
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("error")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"pipes combined output with { from: 'all' }",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
||||
),
|
||||
{ from: "all" },
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toContain("stdout")
|
||||
expect(out).toContain("stderr")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Windows-specific", () => {
|
||||
fx.effect(
|
||||
"uses shell routing on Windows",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(
|
||||
ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], {
|
||||
shell: true,
|
||||
extendEnv: true,
|
||||
env: { OPENCODE_TEST_SHELL: "ok" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(out).toContain("OPENCODE_TEST_SHELL=ok")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"runs cmd scripts with spaces on Windows without shell",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const dir = path.join(tmp.path, "with space")
|
||||
const file = path.join(dir, "echo cmd.cmd")
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
|
||||
|
||||
const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.exitCode(
|
||||
ChildProcess.make(file, ["--stdio"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -54,19 +54,3 @@ describe("plugin.auth-override", () => {
|
||||
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
|
||||
}, 30000) // Increased timeout for plugin installation
|
||||
})
|
||||
|
||||
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
|
||||
|
||||
describe("plugin.config-hook-error-isolation", () => {
|
||||
test("config hooks are individually error-isolated in the layer factory", async () => {
|
||||
const src = await Bun.file(file).text()
|
||||
|
||||
// The config hook try/catch lives in the InstanceState factory (layer definition),
|
||||
// not in init() which now just delegates to the Effect service.
|
||||
expect(src).toContain("plugin config hook failed")
|
||||
|
||||
const pattern =
|
||||
/for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/
|
||||
expect(pattern.test(src)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
extractAccountIdFromClaims,
|
||||
extractAccountId,
|
||||
type IdTokenClaims,
|
||||
} from "../../src/plugin/openai/codex"
|
||||
} from "../../src/plugin/codex"
|
||||
|
||||
function createTestJwt(payload: object): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
|
||||
|
||||
@@ -1,69 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const gitModule = await import("../../src/util/git")
|
||||
const originalGit = gitModule.git
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
|
||||
* matching `failArg` and returns exit code 128, while delegating everything
|
||||
* else to the real CrossSpawnSpawner.
|
||||
*/
|
||||
function mockGitFailure(failArg: string) {
|
||||
return Layer.effect(
|
||||
ChildProcessSpawner.ChildProcessSpawner,
|
||||
Effect.gen(function* () {
|
||||
const real = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
return ChildProcessSpawner.make(
|
||||
Effect.fnUntraced(function* (command) {
|
||||
const std = ChildProcess.isStandardCommand(command) ? command : undefined
|
||||
if (std?.command === "git" && std.args.some((a) => a === failArg)) {
|
||||
return ChildProcessSpawner.makeHandle({
|
||||
pid: ChildProcessSpawner.ProcessId(0),
|
||||
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
|
||||
isRunning: Effect.succeed(false),
|
||||
kill: () => Effect.void,
|
||||
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
||||
stdout: Stream.empty,
|
||||
stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
|
||||
all: Stream.empty,
|
||||
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
||||
getOutputFd: () => Stream.empty,
|
||||
})
|
||||
}
|
||||
return yield* real.spawn(command)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
|
||||
let mode: Mode = "none"
|
||||
|
||||
mock.module("../../src/util/git", () => ({
|
||||
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
|
||||
const cmd = ["git", ...args].join(" ")
|
||||
if (
|
||||
mode === "rev-list-fail" &&
|
||||
cmd.includes("git rev-list") &&
|
||||
cmd.includes("--max-parents=0") &&
|
||||
cmd.includes("HEAD")
|
||||
) {
|
||||
return Promise.resolve({
|
||||
exitCode: 128,
|
||||
text: () => Promise.resolve(""),
|
||||
stdout: Buffer.from(""),
|
||||
stderr: Buffer.from("fatal"),
|
||||
})
|
||||
}
|
||||
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
|
||||
return Promise.resolve({
|
||||
exitCode: 128,
|
||||
text: () => Promise.resolve(""),
|
||||
stdout: Buffer.from(""),
|
||||
stderr: Buffer.from("fatal"),
|
||||
})
|
||||
}
|
||||
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
|
||||
return Promise.resolve({
|
||||
exitCode: 128,
|
||||
text: () => Promise.resolve(""),
|
||||
stdout: Buffer.from(""),
|
||||
stderr: Buffer.from("fatal"),
|
||||
})
|
||||
}
|
||||
return originalGit(args, opts)
|
||||
},
|
||||
}))
|
||||
|
||||
async function withMode(next: Mode, run: () => Promise<void>) {
|
||||
const prev = mode
|
||||
mode = next
|
||||
try {
|
||||
await run()
|
||||
} finally {
|
||||
mode = prev
|
||||
}
|
||||
}
|
||||
|
||||
function projectLayerWithFailure(failArg: string) {
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
async function loadProject() {
|
||||
return (await import("../../src/project/project")).Project
|
||||
}
|
||||
|
||||
describe("Project.fromDirectory", () => {
|
||||
test("should handle git repository with no commits", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir()
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
@@ -71,13 +80,15 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
|
||||
const opencodeFile = path.join(tmp.path, ".git", "opencode")
|
||||
expect(await Bun.file(opencodeFile).exists()).toBe(false)
|
||||
const fileExists = await Filesystem.exists(opencodeFile)
|
||||
expect(fileExists).toBe(false)
|
||||
})
|
||||
|
||||
test("should handle git repository with commits", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
@@ -85,63 +96,54 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
|
||||
const opencodeFile = path.join(tmp.path, ".git", "opencode")
|
||||
expect(await Bun.file(opencodeFile).exists()).toBe(true)
|
||||
const fileExists = await Filesystem.exists(opencodeFile)
|
||||
expect(fileExists).toBe(true)
|
||||
})
|
||||
|
||||
test("returns global for non-git directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
})
|
||||
|
||||
test("derives stable project ID from root commit", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project: a } = await Project.fromDirectory(tmp.path)
|
||||
const { project: b } = await Project.fromDirectory(tmp.path)
|
||||
expect(b.id).toBe(a.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Project.fromDirectory git failure paths", () => {
|
||||
test("keeps vcs when rev-list exits non-zero (no commits)", async () => {
|
||||
test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir()
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
|
||||
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
await withMode("rev-list-fail", async () => {
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
|
||||
test("handles show-toplevel failure gracefully", async () => {
|
||||
test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const layer = projectLayerWithFailure("--show-toplevel")
|
||||
|
||||
const { project, sandbox } = await Effect.runPromise(
|
||||
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
await withMode("top-fail", async () => {
|
||||
const { project, sandbox } = await p.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
|
||||
test("handles git-common-dir failure gracefully", async () => {
|
||||
test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const layer = projectLayerWithFailure("--git-common-dir")
|
||||
|
||||
const { project, sandbox } = await Effect.runPromise(
|
||||
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
await withMode("common-dir-fail", async () => {
|
||||
const { project, sandbox } = await p.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Project.fromDirectory with worktrees", () => {
|
||||
test("should set worktree to root when called from root", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project, sandbox } = await Project.fromDirectory(tmp.path)
|
||||
const { project, sandbox } = await p.fromDirectory(tmp.path)
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
@@ -149,13 +151,14 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("should set worktree to root when called from a worktree", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
|
||||
try {
|
||||
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project, sandbox } = await Project.fromDirectory(worktreePath)
|
||||
const { project, sandbox } = await p.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(worktreePath)
|
||||
@@ -170,21 +173,22 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("worktree should share project ID with main repo", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project: main } = await Project.fromDirectory(tmp.path)
|
||||
const { project: main } = await p.fromDirectory(tmp.path)
|
||||
|
||||
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
|
||||
try {
|
||||
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project: wt } = await Project.fromDirectory(worktreePath)
|
||||
const { project: wt } = await p.fromDirectory(worktreePath)
|
||||
|
||||
expect(wt.id).toBe(main.id)
|
||||
|
||||
// Cache should live in the common .git dir, not the worktree's .git file
|
||||
const cache = path.join(tmp.path, ".git", "opencode")
|
||||
const exists = await Bun.file(cache).exists()
|
||||
const exists = await Filesystem.exists(cache)
|
||||
expect(exists).toBe(true)
|
||||
} finally {
|
||||
await $`git worktree remove ${worktreePath}`
|
||||
@@ -195,6 +199,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("separate clones of the same repo should share project ID", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
// Create a bare remote, push, then clone into a second directory
|
||||
@@ -204,8 +209,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
|
||||
await $`git clone ${bare} ${clone}`.quiet()
|
||||
|
||||
const { project: a } = await Project.fromDirectory(tmp.path)
|
||||
const { project: b } = await Project.fromDirectory(clone)
|
||||
const { project: a } = await p.fromDirectory(tmp.path)
|
||||
const { project: b } = await p.fromDirectory(clone)
|
||||
|
||||
expect(b.id).toBe(a.id)
|
||||
} finally {
|
||||
@@ -214,6 +219,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("should accumulate multiple worktrees in sandboxes", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
|
||||
@@ -222,8 +228,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
|
||||
|
||||
await Project.fromDirectory(worktree1)
|
||||
const { project } = await Project.fromDirectory(worktree2)
|
||||
await p.fromDirectory(worktree1)
|
||||
const { project } = await p.fromDirectory(worktree2)
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(project.sandboxes).toContain(worktree1)
|
||||
@@ -244,13 +250,14 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
|
||||
describe("Project.discover", () => {
|
||||
test("should discover favicon.png in root", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
|
||||
|
||||
await Project.discover(project)
|
||||
await p.discover(project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -261,12 +268,13 @@ describe("Project.discover", () => {
|
||||
})
|
||||
|
||||
test("should not discover non-image files", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
|
||||
|
||||
await Project.discover(project)
|
||||
await p.discover(project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -336,6 +344,8 @@ describe("Project.update", () => {
|
||||
})
|
||||
|
||||
test("should throw error when project not found", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await expect(
|
||||
Project.update({
|
||||
projectID: ProjectID.make("nonexistent-project-id"),
|
||||
@@ -348,24 +358,22 @@ describe("Project.update", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
let eventFired = false
|
||||
let eventPayload: any = null
|
||||
const on = (data: any) => {
|
||||
|
||||
GlobalBus.on("event", (data) => {
|
||||
eventFired = true
|
||||
eventPayload = data
|
||||
}
|
||||
GlobalBus.on("event", on)
|
||||
})
|
||||
|
||||
try {
|
||||
await Project.update({
|
||||
projectID: project.id,
|
||||
name: "Updated Name",
|
||||
})
|
||||
await Project.update({
|
||||
projectID: project.id,
|
||||
name: "Updated Name",
|
||||
})
|
||||
|
||||
expect(eventPayload).not.toBeNull()
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
expect(eventPayload.payload.properties.name).toBe("Updated Name")
|
||||
} finally {
|
||||
GlobalBus.off("event", on)
|
||||
}
|
||||
expect(eventFired).toBe(true)
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
expect(eventPayload.payload.properties.name).toBe("Updated Name")
|
||||
})
|
||||
|
||||
test("should update multiple fields at once", async () => {
|
||||
@@ -385,75 +393,3 @@ describe("Project.update", () => {
|
||||
expect(updated.commands?.start).toBe("make start")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Project.list and Project.get", () => {
|
||||
test("list returns all projects", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
const all = Project.list()
|
||||
expect(all.length).toBeGreaterThan(0)
|
||||
expect(all.find((p) => p.id === project.id)).toBeDefined()
|
||||
})
|
||||
|
||||
test("get returns project by id", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
const found = Project.get(project.id)
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.id).toBe(project.id)
|
||||
})
|
||||
|
||||
test("get returns undefined for unknown id", () => {
|
||||
const found = Project.get(ProjectID.make("nonexistent"))
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Project.setInitialized", () => {
|
||||
test("sets time_initialized on project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
expect(project.time.initialized).toBeUndefined()
|
||||
|
||||
Project.setInitialized(project.id)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated?.time.initialized).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
test("addSandbox adds directory and removeSandbox removes it", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const sandboxDir = path.join(tmp.path, "sandbox-test")
|
||||
|
||||
await Project.addSandbox(project.id, sandboxDir)
|
||||
|
||||
let found = Project.get(project.id)
|
||||
expect(found?.sandboxes).toContain(sandboxDir)
|
||||
|
||||
await Project.removeSandbox(project.id, sandboxDir)
|
||||
|
||||
found = Project.get(project.id)
|
||||
expect(found?.sandboxes).not.toContain(sandboxDir)
|
||||
})
|
||||
|
||||
test("addSandbox emits GlobalBus event", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const sandboxDir = path.join(tmp.path, "sandbox-event")
|
||||
|
||||
const events: any[] = []
|
||||
const on = (evt: any) => events.push(evt)
|
||||
GlobalBus.on("event", on)
|
||||
|
||||
await Project.addSandbox(project.id, sandboxDir)
|
||||
|
||||
GlobalBus.off("event", on)
|
||||
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1629,43 +1629,6 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.message - bedrock caching with non-bedrock providerID", () => {
|
||||
test("applies cache options at message level when npm package is amazon-bedrock", () => {
|
||||
const model = {
|
||||
id: "aws/us.anthropic.claude-opus-4-6-v1",
|
||||
providerID: "aws",
|
||||
api: {
|
||||
id: "us.anthropic.claude-opus-4-6-v1",
|
||||
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
},
|
||||
name: "Claude Opus 4.6",
|
||||
capabilities: {},
|
||||
options: {},
|
||||
headers: {},
|
||||
} as any
|
||||
|
||||
const msgs = [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "You are a helpful assistant" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, model, {}) as any[]
|
||||
|
||||
// Cache should be at the message level and not the content-part level
|
||||
expect(result[0].providerOptions?.bedrock).toEqual({
|
||||
cachePoint: { type: "default" },
|
||||
})
|
||||
expect(result[0].content[0].providerOptions?.bedrock).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.message - cache control on gateway", () => {
|
||||
const createModel = (overrides: Partial<any> = {}) =>
|
||||
({
|
||||
|
||||
@@ -117,16 +117,3 @@ describe("session messages endpoint", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.prompt_async error handling", () => {
|
||||
test("prompt_async route has error handler for detached prompt call", async () => {
|
||||
const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text()
|
||||
const start = src.indexOf('"/:sessionID/prompt_async"')
|
||||
const end = src.indexOf('"/:sessionID/command"', start)
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const route = src.slice(start, end)
|
||||
expect(route).toContain(".catch(")
|
||||
expect(route).toContain("Bus.publish(Session.Event.Error")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
@@ -211,78 +210,3 @@ describe("session.prompt agent variant", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.agent-resolution", () => {
|
||||
test("unknown agent throws typed error", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const err = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "nonexistent-agent-xyz",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
)
|
||||
expect(err).toBeDefined()
|
||||
expect(err).not.toBeInstanceOf(TypeError)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
|
||||
}
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("unknown agent error includes available agent names", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const err = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "nonexistent-agent-xyz",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain("build")
|
||||
}
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("unknown command throws typed error with available names", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const err = await SessionPrompt.command({
|
||||
sessionID: session.id,
|
||||
command: "nonexistent-command-xyz",
|
||||
arguments: "",
|
||||
}).then(
|
||||
() => undefined,
|
||||
(e) => e,
|
||||
)
|
||||
expect(err).toBeDefined()
|
||||
expect(err).not.toBeInstanceOf(TypeError)
|
||||
expect(NamedError.Unknown.isInstance(err)).toBe(true)
|
||||
if (NamedError.Unknown.isInstance(err)) {
|
||||
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
|
||||
expect(err.data.message).toContain("init")
|
||||
}
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("tool.read truncation", () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("Showing lines 1-10 of 100")
|
||||
expect(result.output).toContain("Showing lines 1-10. Use offset=11")
|
||||
expect(result.output).toContain("Use offset=11")
|
||||
expect(result.output).toContain("line0")
|
||||
expect(result.output).toContain("line9")
|
||||
@@ -418,6 +418,23 @@ describe("tool.read truncation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects oversized image attachments", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "huge.png"), Buffer.alloc(6 * 1024 * 1024, 0))
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
await expect(read.execute({ filePath: path.join(tmp.path, "huge.png") }, ctx)).rejects.toThrow(
|
||||
"Cannot attach file larger than 5 MB",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -187,14 +187,7 @@ export interface Hooks {
|
||||
output: { temperature: number; topP: number; topK: number; options: Record<string, any> },
|
||||
) => Promise<void>
|
||||
"chat.headers"?: (
|
||||
input: {
|
||||
sessionID: string
|
||||
agent: string
|
||||
model: Model
|
||||
provider: ProviderContext
|
||||
message: UserMessage
|
||||
small: boolean
|
||||
},
|
||||
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
|
||||
output: { headers: Record<string, string> },
|
||||
) => Promise<void>
|
||||
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
@@ -12,7 +12,6 @@
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./context": "./src/context/index.ts",
|
||||
"./context/*": "./src/context/*.tsx",
|
||||
"./font-loader": "./src/font-loader.ts",
|
||||
"./styles": "./src/styles/index.css",
|
||||
"./styles/tailwind": "./src/styles/tailwind/index.css",
|
||||
"./theme": "./src/theme/index.ts",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
shape-rendering="geometricPrecision"
|
||||
d="M9.8132 15.9038L9 18.75L8.1868 15.9038C7.75968 14.4089 6.59112 13.2403 5.09619 12.8132L2.25 12L5.09619 11.1868C6.59113 10.7597 7.75968 9.59112 8.1868 8.09619L9 5.25L9.8132 8.09619C10.2403 9.59113 11.4089 10.7597 12.9038 11.1868L15.75 12L12.9038 12.8132C11.4089 13.2403 10.2403 14.4089 9.8132 15.9038Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.2589 8.71454L18 9.75L17.7411 8.71454C17.4388 7.50533 16.4947 6.56117 15.2855 6.25887L14.25 6L15.2855 5.74113C16.4947 5.43883 17.4388 4.49467 17.7411 3.28546L18 2.25L18.2589 3.28546C18.5612 4.49467 19.5053 5.43883 20.7145 5.74113L21.75 6L20.7145 6.25887C19.5053 6.56117 18.5612 7.50533 18.2589 8.71454Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.8942 20.5673L16.5 21.75L16.1058 20.5673C15.8818 19.8954 15.3546 19.3682 14.6827 19.1442L13.5 18.75L14.6827 18.3558C15.3546 18.1318 15.8818 17.6046 16.1058 16.9327L16.5 15.75L16.8942 16.9327C17.1182 17.6046 17.6454 18.1318 18.3173 18.3558L19.5 18.75L18.3173 19.1442C17.6454 19.3682 17.1182 19.8954 16.8942 20.5673Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-terminal h-5 w-5 text-primary"><path d="m4 17 6-6-6-6M12 19h8"/></svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user