Compare commits

..

2 Commits

Author SHA1 Message Date
Shoubhit Dash
32ba9287b6 tui: warn about attachment limits immediately instead of batch processing 2026-03-23 13:00:49 +05:30
Shoubhit Dash
3c8b069bba fix(app): warn on oversized prompt attachments 2026-03-23 12:52:27 +05:30
125 changed files with 1879 additions and 4150 deletions

View File

@@ -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

View File

@@ -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}"

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:*",
@@ -41,7 +41,6 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
@@ -79,7 +78,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 +112,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 +139,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 +163,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 +187,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 +220,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 +251,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 +280,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 +296,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.2",
"version": "1.3.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -358,7 +357,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 +369,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 +381,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 +408,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 +420,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -448,7 +444,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 +455,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 +490,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 +536,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.2",
"version": "1.3.0",
"dependencies": {
"zod": "catalog:",
},
@@ -551,7 +547,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",
@@ -1893,8 +1889,6 @@
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
@@ -3039,7 +3033,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 +3789,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 +3921,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 +5541,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 +6293,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=="],

6
flake.lock generated
View File

@@ -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": {

View File

@@ -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-CxeVxNDKEvMsdZpvGZOShklSQ+pWAYq4S3cKDoo6cPQ=",
"aarch64-linux": "sha256-qkMacyXRgbFW9ZvAPepDM5O8GROpXtIZhgQsPOHVogg=",
"aarch64-darwin": "sha256-jPGGoMViHvMBYFqe8BdtWVT1tvPUKYiLCrOy34PjOG0=",
"x86_64-darwin": "sha256-Dss5ChrHZV5/J32iNIh4E+ASltlZsp4QKIiKNlDfAWw="
}
}

View File

@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page, key = "K") {
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+${key}`)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { closeDialog, openPalette } from "../actions"
import { openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
@@ -9,12 +9,3 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})

View File

@@ -108,10 +108,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft)
await wait(page, draft)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
@@ -122,7 +119,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await wait(page, draft)
})
})

View File

@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("K")
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.2",
"version": "1.3.0",
"description": "",
"type": "module",
"exports": {
@@ -51,7 +51,6 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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)" }}
/>

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
import { MAX_ATTACHMENT_BYTES, estimateAttachment, totalAttachments, wouldExceedAttachmentLimit } from "./limit"
import { pasteMode } from "./paste"
describe("attachmentMime", () => {
@@ -42,3 +43,19 @@ describe("pasteMode", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})
describe("attachment limit", () => {
test("estimates encoded attachment size", () => {
expect(estimateAttachment({ size: 3 }, "image/png")).toBe("data:image/png;base64,".length + 4)
})
test("totals current attachments", () => {
expect(totalAttachments([{ dataUrl: "abc" }, { dataUrl: "de" }])).toBe(5)
})
test("flags uploads that exceed the total limit", () => {
const list = [{ dataUrl: "a".repeat(MAX_ATTACHMENT_BYTES - 4) }]
expect(wouldExceedAttachmentLimit(list, { size: 3 }, "image/png")).toBe(true)
expect(wouldExceedAttachmentLimit([], { size: 3 }, "image/png")).toBe(false)
})
})

View File

@@ -5,6 +5,7 @@ import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files"
import { wouldExceedAttachmentLimit } from "./limit"
import { normalizePaste, pasteMode } from "./paste"
function dataUrl(file: File, mime: string) {
@@ -33,6 +34,8 @@ type PromptAttachmentsInput = {
readClipboardImage?: () => Promise<File | null>
}
type AddState = "added" | "failed" | "unsupported" | "limit"
export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const language = useLanguage()
@@ -44,18 +47,27 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
})
}
const add = async (file: File, toast = true) => {
const warnLimit = () => {
showToast({
title: language.t("prompt.toast.attachmentLimit.title"),
description: language.t("prompt.toast.attachmentLimit.description"),
})
}
const add = async (file: File): Promise<AddState> => {
const mime = await attachmentMime(file)
if (!mime) {
if (toast) warn()
return false
return "unsupported" as const
}
const editor = input.editor()
if (!editor) return false
if (!editor) return "failed" as const
const images = prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image")
if (wouldExceedAttachmentLimit(images, file, mime)) return "limit" as const
const url = await dataUrl(file, mime)
if (!url) return false
if (!url) return "failed" as const
const attachment: ImageAttachmentPart = {
type: "image",
@@ -66,23 +78,26 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
return true
return "added" as const
}
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
const addAttachments = async (list: File[]) => {
const result = { added: false, unsupported: false }
for (const file of list) {
const state = await add(file)
if (state === "limit") {
warnLimit()
return result.added
}
result.added = result.added || state === "added"
result.unsupported = result.unsupported || state === "unsupported"
}
if (!found && files.length > 0 && toast) warn()
return found
if (!result.added && result.unsupported) warn()
return result.added
}
const addAttachment = (file: File) => addAttachments([file])
const removeAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)

View File

@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,14 +135,11 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)

View File

@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 && text.length === 0
if (direction === "up") return position === 0
return position === text.length
}

View File

@@ -0,0 +1,15 @@
const MB = 1024 * 1024
export const MAX_ATTACHMENT_BYTES = 5 * MB
export function estimateAttachment(file: { size: number }, mime: string) {
return `data:${mime};base64,`.length + Math.ceil(file.size / 3) * 4
}
export function totalAttachments(list: Array<{ dataUrl: string }>) {
return list.reduce((sum, part) => sum + part.dataUrl.length, 0)
}
export function wouldExceedAttachmentLimit(list: Array<{ dataUrl: string }>, file: { size: number }, mime: string) {
return totalAttachments(list) + estimateAttachment(file, mime) > MAX_ATTACHMENT_BYTES
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View File

@@ -40,11 +40,4 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("")
})
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
})

View File

@@ -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() {

View File

@@ -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")
})
}

View File

@@ -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])

View File

@@ -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])

View File

@@ -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

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -281,6 +281,8 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.attachmentLimit.title": "Attachment limit reached",
"prompt.toast.attachmentLimit.description": "Attachments can total up to 5 MB. Try smaller or fewer files.",
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",

View File

@@ -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"

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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)
@@ -210,22 +211,13 @@ export default function Layout(props: ParentProps) {
onMount(() => {
const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
})
@@ -245,12 +237,6 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined
}
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
@@ -319,7 +305,8 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return
reset()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const navigateWithSidebarReset = (href: string) => {
@@ -335,9 +322,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 +480,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 +1138,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 +1192,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 +1427,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 +1448,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 +1784,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(
@@ -2012,10 +1975,6 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject,
openSidebar: () => layout.sidebar.open(),
closeProject,
@@ -2097,7 +2056,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 +2119,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 +2346,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 +2361,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 +2418,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 +2465,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>

View File

@@ -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>
)
@@ -153,49 +157,34 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => {
let ref: HTMLDivElement | undefined
return (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={
<div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
}): JSX.Element => (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={props.trigger}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
}
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()
@@ -309,71 +298,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 +375,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>
}

View File

@@ -23,7 +23,6 @@ export type ProjectSidebarContext = {
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void
openSidebar: () => void
closeProject: (directory: string) => void
@@ -198,6 +197,7 @@ const ProjectPreviewPanel = (props: {
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
setOpen: (value: boolean) => void
ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
@@ -264,7 +264,7 @@ const ProjectPreviewPanel = (props: {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
props.ctx.openSidebar()
props.ctx.onHoverOpenChanged(props.project.worktree, false)
props.setOpen(false)
if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree)
}}
@@ -289,16 +289,28 @@ export const SortableProject = (props: {
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({
open: false,
menu: false,
suppressHover: false,
})
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
const active = createMemo(
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
)
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
createEffect(() => {
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
})
const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
@@ -339,7 +351,7 @@ export const SortableProject = (props: {
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)}
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setOpen={(value) => setState("open", value)}
setSuppressHover={(value) => setState("suppressHover", value)}
language={language}
/>
@@ -350,7 +362,7 @@ export const SortableProject = (props: {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}>
<HoverCard
open={!state.suppressHover && hoverOpen() && !state.menu}
open={!state.suppressHover && state.open && !state.menu}
openDelay={0}
closeDelay={0}
placement="right-start"
@@ -359,7 +371,7 @@ export const SortableProject = (props: {
onOpenChange={(value) => {
if (state.menu) return
if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value)
setState("open", value)
if (value) props.ctx.setHoverSession(undefined)
}}
>
@@ -374,6 +386,7 @@ export const SortableProject = (props: {
projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
ctx={props.ctx}
language={language}
/>

View File

@@ -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({

View File

@@ -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)

View File

@@ -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

View File

@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
@@ -30,7 +30,6 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
type MessageComment = {
path: string
@@ -251,21 +250,38 @@ export function MessageTimeline(props: {
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [timeoutDone, setTimeoutDone] = createSignal(true)
const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
if (working()) return "showing"
if (prev === "showing" || !timeoutDone()) return "hiding"
return "hidden"
const [slot, setSlot] = createStore({
open: false,
show: false,
fade: false,
})
createEffect(() => {
if (workingStatus() !== "hiding") return
setTimeoutDone(false)
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
})
let f: number | undefined
const clear = () => {
if (f !== undefined) window.clearTimeout(f)
f = undefined
}
onCleanup(clear)
createEffect(
on(
working,
(on, prev) => {
clear()
if (on) {
setSlot({ open: true, show: true, fade: false })
return
}
if (prev) {
setSlot({ open: false, show: true, fade: true })
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
return
}
setSlot({ open: false, show: false, fade: false })
},
{ defer: true },
),
)
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
@@ -660,15 +676,17 @@ export function MessageTimeline(props: {
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: working() ? "16px" : "0px",
"margin-right": working() ? "8px" : "0px",
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={workingStatus() !== "hidden"}>
<Show when={slot.show}>
<div
class="transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
classList={{
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div>
@@ -894,6 +912,7 @@ export function MessageTimeline(props: {
</div>
</div>
</Show>
<div
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
@@ -923,15 +942,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 +998,6 @@ export function MessageTimeline(props: {
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={messageID}
messages={sessionMessages()}
actions={props.actions}
active={active()}
status={active() ? sessionStatus() : undefined}

View File

@@ -438,10 +438,12 @@ export function SessionSidePanel(props: {
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>

View File

@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}),

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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(),
]

View File

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

View File

@@ -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",
)
}

View File

@@ -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 })
})(),
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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",

View File

@@ -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],
),

View File

@@ -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({

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -35,7 +35,6 @@ export type CommandEvent =
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
@@ -192,7 +191,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
treeKill(child.pid)
}
return { events, child: { pid: child.pid, kill }, exit }
return { events, child: { kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {

View File

@@ -81,17 +81,6 @@ function setupApp() {
killSidecar()
})
app.on("will-quit", () => {
killSidecar()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
app.exit(0)
})
}
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
@@ -245,15 +234,8 @@ registerIpcHandlers({
function killSidecar() {
if (!sidecar) return
const pid = sidecar.pid
sidecar.kill()
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
}
function ensureLoopbackNoProxy() {

View File

@@ -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>

View File

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

View File

@@ -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

View File

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

View File

@@ -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"]

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -173,6 +173,6 @@ Still open and likely worth migrating:
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
- [x] `Project`
- [ ] `Project`
- [ ] `LSP`
- [ ] `MCP`

View File

@@ -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
}
}

View File

@@ -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() {},
})

View File

@@ -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()

View File

@@ -53,7 +53,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
message: store,
},
)
process.on("SIGHUP", () => exit())
return exit
},
})

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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),
)

View File

@@ -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
},
}
}

View File

@@ -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)
}
})

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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 })
})
},
)

View File

@@ -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,
},

View File

@@ -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())

View File

@@ -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),
)

View File

@@ -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,

View File

@@ -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")
})
})

View File

@@ -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))
}),
)
})
})

View File

@@ -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)
})
})

View File

@@ -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")

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