Merge branch 'dev' into opencode-telegram-bot

pull/13373/head
Denis Jannot 2026-02-13 18:18:46 +01:00 committed by GitHub
commit 16e16be4a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
148 changed files with 2338 additions and 989 deletions

View File

@ -1,7 +0,0 @@
github-policies:
runners:
allowed_groups:
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
build:
disallow_reruns: false
branch_rulesets:

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # اي نظام
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # qualquer sistema
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
```

View File

@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Bilo koji OS
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # jedes Betriebssystem
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # cualquier sistema
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # n'importe quel OS
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Qualsiasi OS
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS と Linux推奨。常に最新
brew install opencode # macOS と Linux公式 brew formula。更新頻度は低め
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # どのOSでも
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 어떤 OS든
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
```

View File

@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # dowolny system
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # любая ОС
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Tüm işletim sistemleri
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux推荐始终保持最新
brew install opencode # macOS 和 Linux官方 brew formula更新频率较低
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
```

View File

@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux推薦始終保持最新
brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
```

View File

@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.63",
"version": "1.1.65",
"bin": {
"opencode": "./bin/opencode",
},
@ -268,12 +268,12 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.74",
"@ai-sdk/anthropic": "2.0.58",
"@ai-sdk/amazon-bedrock": "3.0.79",
"@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.33",
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
@ -366,7 +366,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@ -386,7 +386,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.63",
"version": "1.1.65",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@ -397,7 +397,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@ -410,7 +410,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@ -452,7 +452,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"zod": "catalog:",
},
@ -463,7 +463,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.63",
"version": "1.1.65",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@ -522,7 +522,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.5",
"@types/bun": "1.3.9",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
@ -565,7 +565,7 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.79", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfAQUb1GEmdTjLu5Ud1d5sieNHDpwoQdb4S14KmJlA5RsGREUZ1tfSKngFaiClxFtL0xPSZjePhTMV6Z65A7/g=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
@ -577,7 +577,7 @@
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
@ -1853,7 +1853,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@ -2181,7 +2181,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@ -4151,7 +4151,9 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "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-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
@ -4163,7 +4165,9 @@
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "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-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
@ -4453,6 +4457,8 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
@ -4575,7 +4581,7 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
@ -4995,6 +5001,8 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
@ -5099,6 +5107,8 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "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-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],

16
install
View File

@ -130,7 +130,7 @@ else
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
@ -141,6 +141,20 @@ else
needs_baseline=true
fi
fi
if [ "$os" = "windows" ]; then
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
out=""
if command -v powershell.exe >/dev/null 2>&1; then
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
elif command -v pwsh >/dev/null 2>&1; then
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
fi
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"

View File

@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-saYZlUTkBfg9vp5J1CrJUM1PBXK4xKwyz28RKlT0JWo=",
"aarch64-linux": "sha256-qoiX2CpOD+HSI+eLh3I84TTPdhWdG6MzfkDAXE6ldPo=",
"aarch64-darwin": "sha256-LbAvdaOBuftBoHvQPFwJGr0smg8vH4wNHS6BYdyXdDs=",
"x86_64-darwin": "sha256-bv5qb9Fi8SyrgZFhcdlvYNc4bjyvdyHY3YgUpmkEH2U="
"x86_64-linux": "sha256-FsFTitxnN2brebZDBRGJB0NWTOVYDa/QcNRH0ip/Gk4=",
"aarch64-linux": "sha256-knSEqEPyonBUfmGZKTq5Om4HikItWbfPdfT7p6iljzs=",
"aarch64-darwin": "sha256-uRgWfuOlLECRCOszm8XhySiWxu9IdDhpSbosPZPAZVI=",
"x86_64-darwin": "sha256-gHuA+Ud9L+XLvKm5Vp5jCXfZWOtunnmX/lB8vczHsG0="
}
}

View File

@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"packageManager": "bun@1.3.9",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
@ -23,7 +23,7 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.5",
"@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",

View File

@ -0,0 +1,43 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl } from "../actions"
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})

View File

@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'

View File

@ -9,6 +9,7 @@ import {
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsAgentEnabledSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
@ -335,6 +336,30 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(switchContainer).toBeVisible()
await expect(trigger).toBeEnabled()
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
await expect(trigger).toBeDisabled()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()

View File

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

View File

@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@ -156,8 +156,11 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
if (!server.url) return null
return props.children
return (
<Show when={server.url} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {

View File

@ -103,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
return value.label ?? ""
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
@ -141,7 +159,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
.catch((e) => {
if (!alive.value) return
dispatch({ type: "auth.error", error: String(e) })
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
}
}
@ -328,8 +346,7 @@ export function DialogConnectProvider(props: { provider: string }) {
await complete()
return
}
const message = result.error instanceof Error ? result.error.message : String(result.error)
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
}
return (
@ -385,7 +402,7 @@ export function DialogConnectProvider(props: { provider: string }) {
if (!alive.value) return
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
return
}

View File

@ -1,6 +1,7 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import type { Component } from "solid-js"
import { useLocal } from "@/context/local"
@ -18,6 +19,14 @@ export const DialogManageModels: Component = () => {
dialog.show(() => <DialogSelectProvider />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
const setProviderVisibility = (providerID: string, checked: boolean) => {
providerList(providerID).forEach((x) => {
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
})
}
return (
<Dialog
@ -36,7 +45,28 @@ export const DialogManageModels: Component = () => {
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
groupBy={(x) => x.provider.id}
groupHeader={(group) => {
const provider = group.items[0].provider
return (
<>
<span>{provider.name}</span>
<Tooltip
placement="top"
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
>
<Switch
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)

View File

@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items"
@ -473,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
return !!prevIsBr && !next
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
@ -496,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.appendChild(createPill(part))
}
}
const last = editorRef.lastChild
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
editorRef.appendChild(document.createTextNode("\u200B"))
}
}
createEffect(
@ -729,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
if (last.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(last)
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
const next = last.nextSibling
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
if (isBreak && (!next || emptyText)) {
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
if (!next) last.parentNode?.insertBefore(placeholder, null)
placeholder.textContent = "\u200B"
range.setStart(placeholder, 0)
} else {
range.setStartAfter(last)
}
}
}
range.collapse(true)
@ -899,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
const direction = event.key === "ArrowUp" ? "up" : "down"
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
@ -907,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (event.key === "ArrowUp") {
if (direction === "up") {
if (!allowUp) return
if (navigateHistory("up")) {
event.preventDefault()

View File

@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
describe("prompt-input editor dom", () => {
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
test("createTextFragment preserves newlines with consecutive br nodes", () => {
const fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(5)
expect(container.childNodes.length).toBe(4)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[3]?.textContent).toBe("bar")
})
test("createTextFragment keeps trailing newline as terminal break", () => {
const fragment = createTextFragment("foo\n")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[2]?.textContent).toBe("\u200B")
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[4]?.textContent).toBe("bar")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
container.remove()
})
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("a"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("b"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 3)
expect(getCursorPosition(container)).toBe(3)
container.remove()
})
})

View File

@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
segments.forEach((segment, index) => {
if (segment) {
fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
}
if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br"))

View File

@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@ -66,4 +72,20 @@ describe("prompt-input history", () => {
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
})
})

View File

@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
if (!text.includes("\n")) return true
const position = Math.max(0, Math.min(cursor, text.length))
if (direction === "up") return !text.slice(0, position).includes("\n")
return !text.slice(position).includes("\n")
}
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }

View File

@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
await client.session.promptAsync({
sessionID: session.id,
agent,
model,

View File

@ -15,9 +15,11 @@ interface SessionContextUsageProps {
function openSessionContext(args: {
view: ReturnType<ReturnType<typeof useLayout>["view"]>
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
args.tabs.open("context")
args.tabs.setActive("context")
}
@ -52,6 +54,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
if (!params.id) return
openSessionContext({
view: view(),
layout,
tabs: tabs(),
})
}

View File

@ -552,7 +552,7 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<div class="hidden lg:flex items-center gap-3 ml-2 shrink-0">
<div class="flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}

View File

@ -306,39 +306,66 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-agent-enabled">
<Switch
checked={settings.sounds.agentEnabled()}
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.agentEnabled()}
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-permissions-enabled">
<Switch
checked={settings.sounds.permissionsEnabled()}
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.permissionsEnabled()}
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-errors-enabled">
<Switch
checked={settings.sounds.errorsEnabled()}
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.errorsEnabled()}
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
</div>
</SettingsRow>
</div>
</div>

View File

@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@ -155,11 +156,16 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let fitFrame: number | undefined
let sizeTimer: ReturnType<typeof setTimeout> | undefined
let pendingSize: { cols: number; rows: number } | undefined
let lastSize: { cols: number; rows: number } | undefined
let disposed = false
const cleanups: VoidFunction[] = []
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
let output: ReturnType<typeof terminalWriter> | undefined
const cleanup = () => {
if (!cleanups.length) return
@ -207,6 +213,43 @@ export const Terminal = (props: TerminalProps) => {
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
const scheduleFit = () => {
if (disposed) return
if (!fitAddon) return
if (fitFrame !== undefined) return
fitFrame = requestAnimationFrame(() => {
fitFrame = undefined
if (disposed) return
fitAddon.fit()
})
}
const scheduleSize = (cols: number, rows: number) => {
if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) return
pendingSize = { cols, rows }
if (!lastSize) {
lastSize = pendingSize
void pushSize(cols, rows)
return
}
if (sizeTimer !== undefined) return
sizeTimer = setTimeout(() => {
sizeTimer = undefined
const next = pendingSize
if (!next) return
pendingSize = undefined
if (disposed) return
if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
lastSize = next
void pushSize(next.cols, next.rows)
}, 100)
}
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
@ -218,6 +261,16 @@ export const Terminal = (props: TerminalProps) => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
})
let zoom = platform.webviewZoom?.()
createEffect(() => {
const next = platform.webviewZoom?.()
if (next === undefined) return
if (next === zoom) return
zoom = next
scheduleFit()
})
const focusTerminal = () => {
@ -261,25 +314,6 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
ws = socket
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@ -300,7 +334,7 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false,
convertEol: true,
convertEol: false,
theme: terminalColors(),
scrollback: 10_000,
ghostty: g,
@ -312,6 +346,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
output = terminalWriter((data) => t.write(data))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@ -341,39 +376,16 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
const startResize = () => {
fit.observeResize()
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)
}
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
}
startResize()
}
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await pushSize(size.cols, size.rows)
}
const onResize = t.onResize((size) => {
scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
@ -382,17 +394,64 @@ export const Terminal = (props: TerminalProps) => {
}
})
cleanups.push(() => disposeIfDisposable(onKey))
const startResize = () => {
fit.observeResize()
handleResize = scheduleFit
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
}
startResize()
}
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
const handleOpen = () => {
local.onConnect?.()
void pushSize(t.cols, t.rows)
scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
@ -416,7 +475,7 @@ export const Terminal = (props: TerminalProps) => {
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
t.write(data)
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
@ -459,6 +518,9 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
})
@ -473,7 +535,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{
...(local.classList ?? {}),
"select-text": true,
"size-full px-6 py-3 font-mono": true,
"size-full px-6 py-3 font-mono relative overflow-hidden": true,
[local.class ?? ""]: !!local.class,
}}
{...others}

View File

@ -46,6 +46,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
@ -91,50 +92,58 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
void (async () => {
const events = await eventSdk.global.event({
onSseError: (error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
while (!abort.signal.aborted) {
try {
const events = await eventSdk.global.event({
onSseError: (error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
},
})
let yielded = Date.now()
for await (const event of events.stream) {
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
let yielded = Date.now()
for await (const event of events.stream) {
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})()
.finally(flush)
.catch((error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
})
})().finally(flush)
onCleanup(() => {
abort.abort()

View File

@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { createChildStoreManager } from "./child-store"
const child = () => createStore({} as State)
describe("createChildStoreManager", () => {
test("does not evict the active directory during mark", () => {
const owner = createRoot((dispose) => {
const current = getOwner()
dispose()
return current
})
if (!owner) throw new Error("owner required")
const manager = createChildStoreManager({
owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onDispose() {},
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
manager.children[directory] = child()
manager.pin(directory)
})
const directory = "/active"
manager.children[directory] = child()
manager.mark(directory)
expect(manager.children[directory]).toBeDefined()
})
})

View File

@ -36,7 +36,7 @@ export function createChildStoreManager(input: {
const mark = (directory: string) => {
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction()
runEviction(directory)
}
const pin = (directory: string) => {
@ -106,7 +106,7 @@ export function createChildStoreManager(input: {
return true
}
function runEviction() {
function runEviction(skip?: string) {
const stores = Object.keys(children)
if (stores.length === 0) return
const list = pickDirectoriesToEvict({
@ -116,7 +116,7 @@ export function createChildStoreManager(input: {
max: MAX_DIR_STORES,
ttl: DIR_IDLE_TTL_MS,
now: Date.now(),
})
}).filter((directory) => directory !== skip)
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue

View File

@ -57,6 +57,10 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
}
const LOCALES: readonly Locale[] = [
"en",
"zh",
@ -199,6 +203,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
document.cookie = cookie(locale())
})
return {

View File

@ -233,7 +233,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (!session) return
if (session.parentID) return
playSound(soundSrc(settings.sounds.agent()))
if (settings.sounds.agentEnabled()) {
playSound(soundSrc(settings.sounds.agent()))
}
append({
directory,
@ -260,7 +262,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (meta.disposed) return
if (session?.parentID) return
playSound(soundSrc(settings.sounds.errors()))
if (settings.sounds.errorsEnabled()) {
playSound(soundSrc(settings.sounds.errors()))
}
const error = "error" in event.properties ? event.properties.error : undefined
append({

View File

@ -10,8 +10,11 @@ export interface NotificationSettings {
}
export interface SoundSettings {
agentEnabled: boolean
agent: string
permissionsEnabled: boolean
permissions: string
errorsEnabled: boolean
errors: string
}
@ -57,8 +60,11 @@ const defaultSettings: Settings = {
errors: false,
},
sounds: {
agentEnabled: true,
agent: "staplebops-01",
permissionsEnabled: true,
permissions: "staplebops-02",
errorsEnabled: true,
errors: "nope-03",
},
}
@ -168,14 +174,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
},
sounds: {
agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
setAgentEnabled(value: boolean) {
setStore("sounds", "agentEnabled", value)
},
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
permissionsEnabled: withFallback(
() => store.sounds?.permissionsEnabled,
defaultSettings.sounds.permissionsEnabled,
),
setPermissionsEnabled(value: boolean) {
setStore("sounds", "permissionsEnabled", value)
},
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
setErrorsEnabled(value: boolean) {
setStore("sounds", "errorsEnabled", value)
},
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)

View File

@ -4,6 +4,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@ -68,11 +69,7 @@ const notify: Platform["notify"] = async (title, description, href) => {
})
notification.onclick = () => {
window.focus()
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
handleNotificationClick(href)
notification.close()
}
}

View File

@ -109,6 +109,7 @@ export const dict = {
"dialog.model.empty": "No model results",
"dialog.model.manage": "Manage models",
"dialog.model.manage.description": "Customize which models appear in the model selector.",
"dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",

View File

@ -1,3 +1,4 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command"
export { handleNotificationClick } from "./utils/notification-click"

View File

@ -388,7 +388,9 @@ export default function Layout(props: ParentProps) {
alertedAtBySession.set(sessionKey, now)
if (e.details.type === "permission.asked") {
playSound(soundSrc(settings.sounds.permissions()))
if (settings.sounds.permissionsEnabled()) {
playSound(soundSrc(settings.sounds.permissions()))
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
}

View File

@ -1758,7 +1758,7 @@ export default function Page() {
</div>
<TerminalPanel
open={isDesktop() && view().terminal.opened()}
open={view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}

View File

@ -1,7 +1,7 @@
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { checksum } from "@opencode-ai/util/encode"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
@ -49,7 +49,7 @@ export function FileTabContent(props: {
return props.file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => checksum(contents()))
const cacheKey = createMemo(() => sampledChecksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
@ -163,11 +163,20 @@ export function FileTabContent(props: {
return
}
const estimateTop = (range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const height = 24
const offset = 2
return Math.max(0, (line - 1) * height + offset)
}
const large = contents().length > 500_000
const next: Record<string, number> = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
if (!marker) continue
next[comment.id] = markerTop(el, marker)
if (marker) next[comment.id] = markerTop(el, marker)
else if (large) next[comment.id] = estimateTop(comment.selection)
}
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
@ -194,12 +203,12 @@ export function FileTabContent(props: {
}
const marker = findMarker(root, range)
if (!marker) {
setNote("draftTop", undefined)
if (marker) {
setNote("draftTop", markerTop(el, marker))
return
}
setNote("draftTop", markerTop(el, marker))
setNote("draftTop", large ? estimateTop(range) : undefined)
}
const scheduleComments = () => {

View File

@ -13,11 +13,21 @@ export function SessionMobileTabs(props: {
return (
<Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto">
<Tabs.List class="!grid !grid-cols-2 [&::after]:hidden [&_[data-slot=tabs-trigger-wrapper]]:!max-w-none">
<Tabs.Trigger value="session" class="w-full" classes={{ button: "w-full" }} onClick={props.onSession}>
<Tabs.List>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: "w-full" }}
onClick={props.onSession}
>
{props.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger value="changes" class="w-full" classes={{ button: "w-full" }} onClick={props.onChanges}>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={props.onChanges}
>
{props.hasReview
? props.t("session.review.filesChanged", { count: props.reviewCount })
: props.t("session.review.change.other")}

View File

@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { handleNotificationClick } from "./notification-click"
describe("notification click", () => {
test("focuses and navigates when href exists", () => {
const calls: string[] = []
handleNotificationClick("/abc/session/123", {
focus: () => calls.push("focus"),
location: {
assign: (href) => calls.push(href),
},
})
expect(calls).toEqual(["focus", "/abc/session/123"])
})
test("only focuses when href is missing", () => {
const calls: string[] = []
handleNotificationClick(undefined, {
focus: () => calls.push("focus"),
location: {
assign: (href) => calls.push(href),
},
})
expect(calls).toEqual(["focus"])
})
})

View File

@ -0,0 +1,12 @@
type WindowTarget = {
focus: () => void
location: {
assign: (href: string) => void
}
}
export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
target.focus()
if (!href) return
target.location.assign(href)
}

View File

@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { terminalWriter } from "./terminal-writer"
describe("terminalWriter", () => {
test("buffers and flushes once per schedule", () => {
const calls: string[] = []
const scheduled: VoidFunction[] = []
const writer = terminalWriter(
(data) => calls.push(data),
(flush) => scheduled.push(flush),
)
writer.push("a")
writer.push("b")
writer.push("c")
expect(calls).toEqual([])
expect(scheduled).toHaveLength(1)
scheduled[0]?.()
expect(calls).toEqual(["abc"])
})
test("flush is a no-op when empty", () => {
const calls: string[] = []
const writer = terminalWriter(
(data) => calls.push(data),
(flush) => flush(),
)
writer.flush()
expect(calls).toEqual([])
})
})

View File

@ -0,0 +1,27 @@
export function terminalWriter(
write: (data: string) => void,
schedule: (flush: VoidFunction) => void = queueMicrotask,
) {
let chunks: string[] | undefined
let scheduled = false
const flush = () => {
scheduled = false
const items = chunks
if (!items?.length) return
chunks = undefined
write(items.join(""))
}
const push = (data: string) => {
if (!data) return
if (chunks) chunks.push(data)
else chunks = [data]
if (scheduled) return
scheduled = true
schedule(flush)
}
return { push, flush }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await $`cd ../opencode && bun run build --single`
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)

View File

@ -8,17 +8,17 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "opencode-darwin-x64",
ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "opencode-windows-x64",
ocBinary: "opencode-windows-x64-baseline",
assetExt: "zip",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
ocBinary: "opencode-linux-x64",
ocBinary: "opencode-linux-x64-baseline",
assetExt: "tar.gz",
},
{

View File

@ -566,8 +566,8 @@ async fn initialize(app: AppHandle) {
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
// Then in the loading task, we wait for sqlite migration to complete before
// starting our health check against the server, otherwise long migrations could result in a timeout.
let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
let sqlite_done = (sqlite_enabled && !sqlite_file_exists()).then(|| {
let needs_sqlite_migration = option_env!("OPENCODE_SQLITE").is_some() && !sqlite_file_exists();
let sqlite_done = needs_sqlite_migration.then(|| {
tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(),
"Sqlite file not found, waiting for it to be generated"
@ -665,12 +665,14 @@ async fn initialize(app: AppHandle) {
}
let _ = server_ready_rx.await;
tracing::info!("Loading task finished");
}
})
.map_err(|_| ())
.shared();
let loading_window = if sqlite_enabled
let loading_window = if needs_sqlite_migration
&& timeout(Duration::from_secs(1), loading_task.clone())
.await
.is_err()

View File

@ -1,7 +1,14 @@
// @refresh reload
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app"
import {
AppBaseProviders,
AppInterface,
PlatformProvider,
Platform,
useCommand,
handleNotificationClick,
} from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
@ -329,10 +336,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
void win.show().catch(() => undefined)
void win.unminimize().catch(() => undefined)
void win.setFocus().catch(() => undefined)
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
handleNotificationClick(href)
notification.close()
}
})

View File

@ -5,7 +5,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { Progress } from "@opencode-ai/ui/progress"
import "./styles.css"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { commands, events, InitStep } from "./bindings"
import { Channel } from "@tauri-apps/api/core"
@ -29,36 +29,20 @@ render(() => {
channel.onmessage = (next) => setStep(next)
commands.awaitInitialization(channel as any).catch(() => undefined)
createEffect(() => {
if (phase() !== "sqlite_waiting") return
onMount(() => {
setLine(0)
setPercent(0)
const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms))
let stop: (() => void) | undefined
let active = true
void events.sqliteMigrationProgress
.listen((e) => {
if (e.payload.type === "InProgress") setPercent(Math.max(0, Math.min(100, e.payload.value)))
if (e.payload.type === "Done") setPercent(100)
})
.then((unlisten) => {
if (active) {
stop = unlisten
return
}
unlisten()
})
.catch(() => undefined)
const listener = events.sqliteMigrationProgress.listen((e) => {
if (e.payload.type === "InProgress") setPercent(Math.max(0, Math.min(100, e.payload.value)))
if (e.payload.type === "Done") setPercent(100)
})
onCleanup(() => {
active = false
listener.then((cb) => cb())
timers.forEach(clearTimeout)
stop?.()
})
})

View File

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

View File

@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.63"
version = "1.1.65"
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.1.63/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/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.1.63/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/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.1.63/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@ -47,20 +47,109 @@ if (!arch) {
const base = "opencode-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode.exe" : "opencode"
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
if (result.status !== 0) return false
return (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const cmd =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const out = (result.stdout || "").trim().toLowerCase()
if (out === "true" || out === "1") return true
if (out === "false" || out === "0") return false
} catch {
continue
}
}
return false
}
return false
}
const names = (() => {
const avx2 = supportsAvx2()
const baseline = arch === "x64" && !avx2
if (platform === "linux") {
const musl = (() => {
try {
if (fs.existsSync("/etc/alpine-release")) return true
} catch {
// ignore
}
try {
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase()
if (text.includes("musl")) return true
} catch {
// ignore
}
return false
})()
if (musl) {
if (arch === "x64") {
if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
}
return [`${base}-musl`, base]
}
if (arch === "x64") {
if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
}
return [base, `${base}-musl`]
}
if (arch === "x64") {
if (baseline) return [`${base}-baseline`, base]
return [base, `${base}-baseline`]
}
return [base]
})()
function findBinary(startDir) {
let current = startDir
for (;;) {
const modules = path.join(current, "node_modules")
if (fs.existsSync(modules)) {
const entries = fs.readdirSync(modules)
for (const entry of entries) {
if (!entry.startsWith(base)) {
continue
}
const candidate = path.join(modules, entry, "bin", binary)
if (fs.existsSync(candidate)) {
return candidate
}
for (const name of names) {
const candidate = path.join(modules, name, "bin", binary)
if (fs.existsSync(candidate)) return candidate
}
}
const parent = path.dirname(current)
@ -74,9 +163,9 @@ function findBinary(startDir) {
const resolved = findBinary(scriptDir)
if (!resolved) {
console.error(
'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' +
base +
'" package',
"It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " +
names.map((n) => `\"${n}\"`).join(" or ") +
" package",
)
process.exit(1)
}

View File

@ -2,4 +2,6 @@ preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]
timeout = 30000 # 30 seconds - allow time for package installation
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
# using --timeout in package.json scripts instead
# https://github.com/oven-sh/bun/issues/7789

View File

@ -1,13 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.63",
"version": "1.1.65",
"name": "opencode",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test",
"test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@ -51,12 +51,12 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.74",
"@ai-sdk/anthropic": "2.0.58",
"@ai-sdk/amazon-bedrock": "3.0.79",
"@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.33",
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",

View File

@ -274,6 +274,10 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
})
.option("port", {
type: "number",
describe: "port for the local server (defaults to random port if no value provided)",
@ -293,6 +297,18 @@ export const RunCommand = cmd({
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
const directory = (() => {
if (!args.dir) return undefined
if (args.attach) return args.dir
try {
process.chdir(args.dir)
return process.cwd()
} catch {
UI.error("Failed to change directory to " + args.dir)
process.exit(1)
}
})()
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
@ -582,7 +598,7 @@ export const RunCommand = cmd({
}
if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach })
const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
return await execute(sdk)
}

View File

@ -1,6 +1,7 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@ -180,6 +181,7 @@ export function tui(input: {
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
@ -209,6 +211,35 @@ function App() {
const exit = useExit()
const promptRef = usePromptRef()
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (!renderer.getSelection()) return
// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
// - Esc dismisses selection
// - Most other key input dismisses selection and is passed through
if (evt.ctrl && evt.name === "c") {
if (!Selection.copy(renderer, toast)) {
renderer.clearSelection()
return
}
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "escape") {
renderer.clearSelection()
evt.preventDefault()
evt.stopPropagation()
return
}
renderer.clearSelection()
})
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
@ -216,6 +247,7 @@ function App() {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
@ -702,19 +734,15 @@ function App() {
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseUp={async () => {
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
renderer.clearSelection()
return
}
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
<Switch>
<Match when={route.data.type === "home"}>

View File

@ -1,10 +1,11 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection"
export function Dialog(
props: ParentProps<{
@ -16,10 +17,18 @@ export function Dialog(
const { theme } = useTheme()
const renderer = useRenderer()
let dismiss = false
return (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
onMouseDown={() => {
dismiss = !!renderer.getSelection()
}}
onMouseUp={() => {
if (dismiss) {
dismiss = false
return
}
props.onClose?.()
}}
width={dimensions().width}
@ -32,8 +41,8 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
onMouseUp={(e) => {
dismiss = false
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@ -56,8 +65,13 @@ function init() {
size: "medium" as "medium" | "large",
})
const renderer = useRenderer()
useKeyboard((evt) => {
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@ -67,7 +81,6 @@ function init() {
}
})
const renderer = useRenderer()
let focus: Renderable | null
function refocus() {
setTimeout(() => {
@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>

View File

@ -0,0 +1,25 @@
import { Clipboard } from "./clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
error: (err: unknown) => void
}
type Renderer = {
getSelection: () => { getSelectedText: () => string } | null
clearSelection: () => void
}
export namespace Selection {
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
return true
}
}

View File

@ -37,7 +37,10 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
export const OPENCODE_ENABLE_EXA =
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")

View File

@ -2,7 +2,6 @@ import z from "zod"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { $ } from "bun"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
@ -13,6 +12,7 @@ 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"
export namespace Project {
const log = Log.create({ service: "project" })
@ -55,15 +55,15 @@ export namespace Project {
const { id, sandbox, worktree, vcs } = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value)
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (git) {
let sandbox = path.dirname(git)
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = Bun.which("git")
// cached id calculation
let id = await Bun.file(path.join(git, "opencode"))
let id = await Bun.file(path.join(dotgit, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => undefined)
@ -79,13 +79,11 @@ export namespace Project {
// generate id from root commit
if (!id) {
const roots = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) =>
x
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: sandbox,
})
.then(async (result) =>
(await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
@ -104,7 +102,7 @@ export namespace Project {
id = roots[0]
if (id) {
void Bun.file(path.join(git, "opencode"))
void Bun.file(path.join(dotgit, "opencode"))
.write(id)
.catch(() => undefined)
}
@ -119,12 +117,10 @@ export namespace Project {
}
}
const top = await $`git rev-parse --show-toplevel`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) => path.resolve(sandbox, x.trim()))
const top = await git(["rev-parse", "--show-toplevel"], {
cwd: sandbox,
})
.then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
.catch(() => undefined)
if (!top) {
@ -138,13 +134,11 @@ export namespace Project {
sandbox = top
const worktree = await $`git rev-parse --git-common-dir`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) => {
const dirname = path.dirname(x.trim())
const worktree = await git(["rev-parse", "--git-common-dir"], {
cwd: sandbox,
})
.then(async (result) => {
const dirname = path.dirname((await result.text()).trim())
if (dirname === ".") return sandbox
return dirname
})

View File

@ -14,6 +14,8 @@ import { Env } from "../env"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
// Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@ -1229,9 +1231,19 @@ export namespace Provider {
const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
const providers = await list()
const recent = (await Bun.file(path.join(Global.Path.state, "model.json"))
.json()
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
.catch(() => [])) as { providerID: string; modelID: string }[]
for (const entry of recent) {
const provider = providers[entry.providerID]
if (!provider) continue
if (!provider.models[entry.modelID]) continue
return { providerID: entry.providerID, modelID: entry.modelID }
}
const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")

View File

@ -458,6 +458,22 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
const efforts = ["low", "medium", "high", "max"]
return Object.fromEntries(
efforts.map((effort) => [
effort,
{
thinking: {
type: "adaptive",
},
effort,
},
]),
)
}
return {
high: {
thinking: {
@ -475,6 +491,20 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
const efforts = ["low", "medium", "high", "max"]
return Object.fromEntries(
efforts.map((effort) => [
effort,
{
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
},
},
]),
)
}
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
if (model.api.id.includes("anthropic")) {
return {

View File

@ -4,7 +4,6 @@ import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
@ -17,6 +16,22 @@ export namespace Pty {
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const sockets = new WeakMap<object, number>()
let socketCounter = 0
const tagSocket = (ws: Socket) => {
if (!ws || typeof ws !== "object") return
const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
sockets.set(ws, next)
return next
}
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@ -81,7 +96,7 @@ export namespace Pty {
buffer: string
bufferCursor: number
cursor: number
subscribers: Set<WSContext>
subscribers: Map<Socket, number>
}
const state = Instance.state(
@ -91,8 +106,12 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
}
sessions.clear()
@ -154,18 +173,26 @@ export namespace Pty {
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Set(),
subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData((data) => {
session.cursor += data.length
for (const ws of session.subscribers) {
for (const [ws, id] of session.subscribers) {
if (ws.readyState !== 1) {
session.subscribers.delete(ws)
continue
}
ws.send(data)
if (typeof ws === "object" && sockets.get(ws) !== id) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(data)
} catch {
session.subscribers.delete(ws)
}
}
session.buffer += data
@ -177,14 +204,15 @@ export namespace Pty {
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
for (const ws of session.subscribers) {
ws.close()
}
state().delete(id)
})
Bus.publish(Event.Created, { info })
@ -211,9 +239,14 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
state().delete(id)
Bus.publish(Event.Deleted, { id })
}
@ -232,7 +265,7 @@ export namespace Pty {
}
}
export function connect(id: string, ws: WSContext, cursor?: number) {
export function connect(id: string, ws: Socket, cursor?: number) {
const session = state().get(id)
if (!session) {
ws.close()
@ -272,7 +305,8 @@ export namespace Pty {
return
}
session.subscribers.add(ws)
const socketId = tagSocket(ws)
if (typeof socketId === "number") session.subscribers.set(ws, socketId)
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))

View File

@ -160,9 +160,25 @@ export const PtyRoutes = lazy(() =>
})()
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const isSocket = (value: unknown): value is Socket => {
if (!value || typeof value !== "object") return false
if (!("readyState" in value)) return false
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
return typeof (value as { readyState?: unknown }).readyState === "number"
}
return {
onOpen(_event, ws) {
handler = Pty.connect(id, ws, cursor)
const socket = isSocket(ws.raw) ? ws.raw : ws
handler = Pty.connect(id, socket, cursor)
},
onMessage(event) {
handler?.onMessage(String(event.data))
@ -170,6 +186,9 @@ export const PtyRoutes = lazy(() =>
onClose() {
handler?.onClose()
},
onError() {
handler?.onClose()
},
}
}),
),

View File

@ -26,7 +26,6 @@ import { ToolRegistry } from "../tool/registry"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { ListTool } from "../tool/ls"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@ -1198,7 +1197,7 @@ export namespace SessionPrompt {
}
if (part.mime === "application/x-directory") {
const args = { path: filepath }
const args = { filePath: filepath }
const listCtx: Tool.Context = {
sessionID: input.sessionID,
abort: new AbortController().signal,
@ -1209,7 +1208,7 @@ export namespace SessionPrompt {
metadata: async () => {},
ask: async () => {},
}
const result = await ListTool.init().then((t) => t.execute(args, listCtx))
const result = await ReadTool.init().then((t) => t.execute(args, listCtx))
return [
{
id: Identifier.ascending("part"),
@ -1217,7 +1216,7 @@ export namespace SessionPrompt {
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: `Called the list tool with the following input: ${JSON.stringify(args)}`,
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
{
id: Identifier.ascending("part"),

View File

@ -2,6 +2,7 @@ import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
@ -23,7 +24,7 @@ export namespace Snapshot {
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
@ -48,7 +49,7 @@ export namespace Snapshot {
}
export async function track() {
if (Instance.project.vcs !== "git") return
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()

View File

@ -149,9 +149,17 @@ export namespace ToolRegistry {
})
.map(async (t) => {
using _ = log.time(t.id)
const tool = await t.init({ agent })
const output = {
description: tool.description,
parameters: tool.parameters,
}
await Plugin.trigger("tool.definition", { toolID: t.id }, output)
return {
id: t.id,
...(await t.init({ agent })),
...tool,
description: output.description,
parameters: output.parameters,
}
}),
)

View File

@ -0,0 +1,64 @@
import { $ } from "bun"
import { Flag } from "../flag/flag"
export interface GitResult {
exitCode: number
text(): string | Promise<string>
stdout: Buffer | ReadableStream<Uint8Array>
stderr: Buffer | ReadableStream<Uint8Array>
}
/**
* Run a git command.
*
* Uses Bun's lightweight `$` shell by default. When the process is running
* as an ACP client, child processes inherit the parent's stdin pipe which
* carries protocol data on Windows this causes git to deadlock. In that
* case we fall back to `Bun.spawn` with `stdin: "ignore"`.
*/
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
if (Flag.OPENCODE_CLIENT === "acp") {
try {
const proc = Bun.spawn(["git", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
cwd: opts.cwd,
env: opts.env ? { ...process.env, ...opts.env } : process.env,
})
// Read output concurrently with exit to avoid pipe buffer deadlock
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).arrayBuffer(),
])
const stdoutBuf = Buffer.from(stdout)
const stderrBuf = Buffer.from(stderr)
return {
exitCode,
text: () => stdoutBuf.toString(),
stdout: stdoutBuf,
stderr: stderrBuf,
}
} catch (error) {
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
return {
exitCode: 1,
text: () => "",
stdout: Buffer.alloc(0),
stderr,
}
}
}
const env = opts.env ? { ...process.env, ...opts.env } : undefined
let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
if (env) cmd = cmd.env(env)
const result = await cmd
return {
exitCode: result.exitCode,
text: () => result.text(),
stdout: result.stdout,
stderr: result.stderr,
}
}

View File

@ -420,49 +420,78 @@ export namespace Worktree {
}
const directory = await canonical(input.directory)
const locate = async (stdout: Uint8Array | undefined) => {
const lines = outputText(stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
return (async () => {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
}
const clean = (target: string) =>
fs
.rm(target, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
})
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
const lines = outputText(list.stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
const entry = await (async () => {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
const entry = await locate(list.stdout)
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
await fs.rm(directory, { recursive: true, force: true })
await clean(directory)
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
if (removed.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (next.exitCode !== 0) {
throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
})
}
const stale = await locate(next.stdout)
if (stale?.path) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
}
}
await clean(entry.path)
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)

View File

@ -8,54 +8,45 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const bunModule = await import("bun")
const gitModule = await import("../../src/util/git")
const originalGit = gitModule.git
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
let mode: Mode = "none"
function render(parts: TemplateStringsArray, vals: unknown[]) {
return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
}
function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) {
const result = {
exitCode: output.exitCode,
stdout: Buffer.from(output.stdout),
stderr: Buffer.from(output.stderr),
text: async () => output.stdout,
}
const shell = {
quiet: () => shell,
nothrow: () => shell,
cwd: () => shell,
env: () => shell,
text: async () => output.stdout,
then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) =>
Promise.resolve(result).then(onfulfilled, onrejected),
catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected),
finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally),
}
return shell
}
mock.module("bun", () => ({
...bunModule,
$: (parts: TemplateStringsArray, ...vals: unknown[]) => {
const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim()
mock.module("../../src/util/git", () => ({
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
const cmd = ["git", ...args].join(" ")
if (
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("--all")
) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
return (bunModule.$ as any)(parts, ...vals)
return originalGit(args, opts)
},
}))

View File

@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree"
import { tmpdir } from "../fixture/fixture"
describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-regression-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
const real = (await $`which git`.quiet().text()).trim()
expect(real).toBeTruthy()
const bin = path.join(root, "bin")
const shim = path.join(bin, "git")
await fs.mkdir(bin, { recursive: true })
await Bun.write(
shim,
[
"#!/bin/bash",
`REAL_GIT=${JSON.stringify(real)}`,
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' "$REAL_GIT" "$@" >/dev/null 2>&1',
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
" exit 1",
"fi",
'exec "$REAL_GIT" "$@"',
].join("\n"),
)
await fs.chmod(shim, 0o755)
const prev = process.env.PATH ?? ""
process.env.PATH = `${bin}${path.delimiter}${prev}`
const ok = await (async () => {
try {
return await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
} finally {
process.env.PATH = prev
}
})()
expect(ok).toBe(true)
expect(await Bun.file(dir).exists()).toBe(false)
const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
expect(list).not.toContain(`worktree ${dir}`)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
})

View File

@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
const b = await Pty.create({ command: "cat", title: "b" })
try {
const outA: string[] = []
const outB: string[] = []
const ws = {
readyState: 1,
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
// Connect "a" first with ws.
Pty.connect(a.id, ws as any)
// Now "reuse" the same ws object for another connection.
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.connect(b.id, ws as any)
// Clear connect metadata writes.
outA.length = 0
outB.length = 0
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
await Pty.remove(b.id)
}
},
})
})
})

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.63",
"version": "1.1.65",
"type": "module",
"license": "MIT",
"scripts": {

View File

@ -224,4 +224,8 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
/**
* Modify tool definitions (description and parameters) sent to LLM
*/
"tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise<void>
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.63",
"version": "1.1.65",
"type": "module",
"license": "MIT",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.63",
"version": "1.1.65",
"type": "module",
"license": "MIT",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.63",
"version": "1.1.65",
"type": "module",
"license": "MIT",
"exports": {

View File

@ -1,5 +1,16 @@
import { type ComponentProps, splitProps, Show } from "solid-js"
const segmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: undefined
function first(value: string) {
if (!value) return ""
if (!segmenter) return Array.from(value)[0] ?? ""
return segmenter.segment(value)[Symbol.iterator]().next().value?.segment ?? Array.from(value)[0] ?? ""
}
export interface AvatarProps extends ComponentProps<"div"> {
fallback: string
src?: string
@ -36,7 +47,7 @@ export function Avatar(props: AvatarProps) {
...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}),
}}
>
<Show when={src} fallback={split.fallback?.[0]}>
<Show when={src} fallback={first(split.fallback)}>
{(src) => <img src={src()} draggable={false} data-slot="avatar-image" />}
</Show>
</div>

View File

@ -1,10 +1,27 @@
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import {
DEFAULT_VIRTUAL_FILE_METRICS,
type FileContents,
File,
FileOptions,
LineAnnotation,
type SelectedLineRange,
type VirtualFileMetrics,
VirtualizedFile,
Virtualizer,
} from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
const VIRTUALIZE_BYTES = 500_000
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
lineHeight: 24,
fileGap: 0,
} satisfies Partial<VirtualFileMetrics>
type SelectionSide = "additions" | "deletions"
export type CodeProps<T = {}> = FileOptions<T> & {
@ -160,16 +177,28 @@ export function Code<T>(props: CodeProps<T>) {
const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
const file = createMemo(
() =>
new File<T>(
{
...createDefaultOptions<T>("unified"),
...others,
},
getWorkerPool("unified"),
),
)
let instance: File<T> | VirtualizedFile<T> | undefined
let virtualizer: Virtualizer | undefined
let virtualRoot: Document | HTMLElement | undefined
const bytes = createMemo(() => {
const value = local.file.contents as unknown
if (typeof value === "string") return value.length
if (Array.isArray(value)) {
return value.reduce(
(acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
0,
)
}
if (value == null) return 0
return String(value).length
})
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
const options = createMemo(() => ({
...createDefaultOptions<T>("unified"),
...others,
}))
const getRoot = () => {
const host = container.querySelector("diffs-container")
@ -577,6 +606,14 @@ export function Code<T>(props: CodeProps<T>) {
}
const applySelection = (range: SelectedLineRange | null) => {
const current = instance
if (!current) return false
if (virtual()) {
current.setSelectedLines(range)
return true
}
const root = getRoot()
if (!root) return false
@ -584,7 +621,7 @@ export function Code<T>(props: CodeProps<T>) {
if (root.querySelectorAll("[data-line]").length < lines) return false
if (!range) {
file().setSelectedLines(null)
current.setSelectedLines(null)
return true
}
@ -592,12 +629,12 @@ export function Code<T>(props: CodeProps<T>) {
const end = Math.max(range.start, range.end)
if (start < 1 || end > lines) {
file().setSelectedLines(null)
current.setSelectedLines(null)
return true
}
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
file().setSelectedLines(null)
current.setSelectedLines(null)
return true
}
@ -608,7 +645,7 @@ export function Code<T>(props: CodeProps<T>) {
return { start: range.start, end: range.end }
})()
file().setSelectedLines(normalized)
current.setSelectedLines(normalized)
return true
}
@ -619,9 +656,12 @@ export function Code<T>(props: CodeProps<T>) {
const token = renderToken
const lines = lineCount()
const lines = virtual() ? undefined : lineCount()
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
const isReady = (root: ShadowRoot) =>
virtual()
? root.querySelector("[data-line]") != null
: root.querySelectorAll("[data-line]").length >= (lines ?? 0)
const notify = () => {
if (token !== renderToken) return
@ -844,20 +884,41 @@ export function Code<T>(props: CodeProps<T>) {
}
createEffect(() => {
const current = file()
const opts = options()
const workerPool = getWorkerPool("unified")
const isVirtual = virtual()
onCleanup(() => {
current.cleanUp()
})
})
createEffect(() => {
observer?.disconnect()
observer = undefined
instance?.cleanUp()
instance = undefined
if (!isVirtual && virtualizer) {
virtualizer.cleanUp()
virtualizer = undefined
virtualRoot = undefined
}
const v = (() => {
if (!isVirtual) return
if (typeof document === "undefined") return
const root = getScrollParent(wrapper) ?? document
if (virtualizer && virtualRoot === root) return virtualizer
virtualizer?.cleanUp()
virtualizer = new Virtualizer()
virtualRoot = root
virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
return virtualizer
})()
instance = isVirtual && v ? new VirtualizedFile<T>(opts, v, codeMetrics, workerPool) : new File<T>(opts, workerPool)
container.innerHTML = ""
const value = text()
file().render({
instance.render({
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
lineAnnotations: local.annotations,
containerWrapper: container,
@ -910,6 +971,13 @@ export function Code<T>(props: CodeProps<T>) {
onCleanup(() => {
observer?.disconnect()
instance?.cleanUp()
instance = undefined
virtualizer?.cleanUp()
virtualizer = undefined
virtualRoot = undefined
clearOverlayScroll()
clearOverlay()
if (findCurrent === host) {

View File

@ -1,5 +1,5 @@
import { checksum } from "@opencode-ai/util/encode"
import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
@ -78,14 +78,29 @@ export function Diff<T>(props: DiffProps<T>) {
const mobile = createMediaQuery("(max-width: 640px)")
const options = createMemo(() => {
const opts = {
const large = createMemo(() => {
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
return Math.max(before.length, after.length) > 500_000
})
const largeOptions = {
lineDiffType: "none",
maxLineDiffLength: 0,
tokenizeMaxLineLength: 1,
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
const options = createMemo<FileDiffOptions<T>>(() => {
const base = {
...createDefaultOptions(props.diffStyle),
...others,
}
if (!mobile()) return opts
const perf = large() ? { ...base, ...largeOptions } : base
if (!mobile()) return perf
return {
...opts,
...perf,
disableLineNumbers: true,
}
})
@ -528,12 +543,17 @@ export function Diff<T>(props: DiffProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool(props.diffStyle)
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
const annotations = local.annotations
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
const cacheKey = (contents: string) => {
if (!large()) return sampledChecksum(contents, contents.length)
return sampledChecksum(contents)
}
instance?.cleanUp()
instance = virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
@ -545,12 +565,12 @@ export function Diff<T>(props: DiffProps<T>) {
oldFile: {
...local.before,
contents: beforeContents,
cacheKey: checksum(beforeContents),
cacheKey: cacheKey(beforeContents),
},
newFile: {
...local.after,
contents: afterContents,
cacheKey: checksum(afterContents),
cacheKey: cacheKey(afterContents),
},
lineAnnotations: annotations,
containerWrapper: container,

View File

@ -45,6 +45,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
itemWrapper?: (item: T, node: JSX.Element) => JSX.Element
divider?: boolean
add?: ListAddProps
groupHeader?: (group: { category: string; items: T[] }) => JSX.Element
}
export interface ListRef {
@ -206,7 +207,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
)
}
function GroupHeader(groupProps: { category: string }): JSX.Element {
function GroupHeader(groupProps: { group: { category: string; items: T[] } }): JSX.Element {
const [stuck, setStuck] = createSignal(false)
const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
@ -228,7 +229,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
return (
<div data-slot="list-header" data-stuck={stuck()} ref={setHeader}>
{groupProps.category}
{props.groupHeader?.(groupProps.group) ?? groupProps.group.category}
</div>
)
}
@ -323,7 +324,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
return (
<div data-slot="list-group">
<Show when={group.category}>
<GroupHeader category={group.category} />
<GroupHeader group={group} />
</Show>
<div data-slot="list-items">
<For each={group.items}>

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