diff --git a/.signpath/policies/opencode/test-signing.yml b/.signpath/policies/opencode/test-signing.yml deleted file mode 100644 index 4c9f654cd3..0000000000 --- a/.signpath/policies/opencode/test-signing.yml +++ /dev/null @@ -1,7 +0,0 @@ -github-policies: - runners: - allowed_groups: - - "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt" - build: - disallow_reruns: false - branch_rulesets: diff --git a/README.ar.md b/README.ar.md index 4c8ac5fcc3..edac204a28 100644 --- a/README.ar.md +++ b/README.ar.md @@ -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 ``` diff --git a/README.br.md b/README.br.md index ee5e85fd44..c185603efb 100644 --- a/README.br.md +++ b/README.br.md @@ -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 ``` diff --git a/README.bs.md b/README.bs.md index 56a1e72fb6..d64a69c0d7 100644 --- a/README.bs.md +++ b/README.bs.md @@ -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 ``` diff --git a/README.da.md b/README.da.md index 79928fd944..7f3d5aa5dd 100644 --- a/README.da.md +++ b/README.da.md @@ -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 ``` diff --git a/README.de.md b/README.de.md index ccb3ad07dc..2aa78657ca 100644 --- a/README.de.md +++ b/README.de.md @@ -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 ``` diff --git a/README.es.md b/README.es.md index e5a7d8e8dd..2b80427ab2 100644 --- a/README.es.md +++ b/README.es.md @@ -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 ``` diff --git a/README.fr.md b/README.fr.md index 5436009903..bc3fe9e757 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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 ``` diff --git a/README.it.md b/README.it.md index cbc8a5f6d2..6da7d51fc6 100644 --- a/README.it.md +++ b/README.it.md @@ -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 l’ultima branch di sviluppo ``` diff --git a/README.ja.md b/README.ja.md index 8827efae88..7a0bbb08f3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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 ブランチ ``` diff --git a/README.ko.md b/README.ko.md index 806dc642c1..1c931c31f3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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 브랜치 ``` diff --git a/README.md b/README.md index 2cd1e2aa01..bd01fc94e8 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/README.no.md b/README.no.md index 90b631fef2..092316bae1 100644 --- a/README.no.md +++ b/README.no.md @@ -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 ``` diff --git a/README.pl.md b/README.pl.md index ae653a7fa0..a225d82539 100644 --- a/README.pl.md +++ b/README.pl.md @@ -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 ``` diff --git a/README.ru.md b/README.ru.md index cf15c6ebce..c13f039d16 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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 ``` diff --git a/README.th.md b/README.th.md index 4077abc011..ba2db8a850 100644 --- a/README.th.md +++ b/README.th.md @@ -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 สำหรับสาขาพัฒนาล่าสุด ``` diff --git a/README.tr.md b/README.tr.md index e3055e7a99..635a5782fe 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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 ``` diff --git a/README.zh.md b/README.zh.md index 6970fe34ef..b2f288f5ba 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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 分支 ``` diff --git a/README.zht.md b/README.zht.md index a045f45490..be4ef053c0 100644 --- a/README.zht.md +++ b/README.zht.md @@ -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 以取得最新開發分支 ``` diff --git a/bun.lock b/bun.lock index 6c65d91d08..4a054c6483 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/install b/install index 22b7ca39ed..b0716d5320 100755 --- a/install +++ b/install @@ -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" diff --git a/nix/hashes.json b/nix/hashes.json index a1b41e2f9a..c493161ee6 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/package.json b/package.json index 61ee419736..c396905d45 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts new file mode 100644 index 0000000000..ce9b1a7a3b --- /dev/null +++ b/packages/app/e2e/prompt/prompt-async.spec.ts @@ -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) + } +}) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 842433891e..52c9007ea1 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -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"]' diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 42534968b2..9fbcf79f5e 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -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() diff --git a/packages/app/package.json b/packages/app/package.json index 90b5a9c300..49ce671b60 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.63", + "version": "1.1.65", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3032a795f8..1121c2e955 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -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 ( + + {props.children} + + ) } export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4d24b23158..90f4f41f7c 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -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 } diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index d4d4af0f10..ace79e38a7 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -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(() => ) } 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 ( { 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 ( + <> + {provider.name} + + setProviderVisibility(provider.id, checked)} + hideLabel + > + {provider.name} + + + + ) + }} sortGroupsBy={(a, b) => { const aRank = providerRank(a.items[0].provider.id) const bRank = providerRank(b.items[0].provider.id) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index abc203aa10..8e8c3c895b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -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 = (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 = (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 = (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 = (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 = (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() diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts index fce8b4b953..15e759f44a 100644 --- a/packages/app/src/components/prompt-input/editor-dom.test.ts +++ b/packages/app/src/components/prompt-input/editor-dom.test.ts @@ -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() + }) }) diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts index 3116ceb126..4850a26ece 100644 --- a/packages/app/src/components/prompt-input/editor-dom.ts +++ b/packages/app/src/components/prompt-input/editor-dom.ts @@ -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")) diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 54be9cb75b..a37fdad677 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -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) + }) }) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index 63164f0ba3..f26f808487 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -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 } diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 49d75a95ec..9a1fba5d5c 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -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, diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index ec4bd2687f..c6e60d3ede 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -15,9 +15,11 @@ interface SessionContextUsageProps { function openSessionContext(args: { view: ReturnType["view"]> + layout: ReturnType tabs: ReturnType["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(), }) } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b85b9a536a..f81a2ec440 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -552,7 +552,7 @@ export function SessionHeader() { - - settings.sounds.errors(), + (id) => settings.sounds.setErrors(id), + )} + /> + diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index f6bb0b48a6..14413dfda6 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -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 | 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 | undefined const cleanup = () => { if (!cleanups.length) return @@ -207,6 +213,43 @@ export const Terminal = (props: TerminalProps) => { const [terminalColors, setTerminalColors] = createSignal(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} diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 346657e2fb..3f93b76a72 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -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((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((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() diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts new file mode 100644 index 0000000000..500f0fc70a --- /dev/null +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -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() + }) +}) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 2feb7fe088..af08c3bd43 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -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 diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index a5d894e62e..b21ec6d3cc 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -57,6 +57,10 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +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 { diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index bf880d115e..04bc2fdaaa 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -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({ diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index a8efb1eace..d72d4ceb1e 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -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) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index f041204dcc..3a85086b48 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -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() } } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c138c7b614..99513edaa1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 59e1431fa8..33c22f099e 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -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" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5f001177ff..7eb064f425 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 101f3312c3..5ce6202eef 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1758,7 +1758,7 @@ export default function Page() { 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 = {} 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 = () => { diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx index 0ee3bd377d..73aebc079a 100644 --- a/packages/app/src/pages/session/session-mobile-tabs.tsx +++ b/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -13,11 +13,21 @@ export function SessionMobileTabs(props: { return ( - - + + {props.t("session.tab.session")} - + {props.hasReview ? props.t("session.review.filesChanged", { count: props.reviewCount }) : props.t("session.review.change.other")} diff --git a/packages/app/src/utils/notification-click.test.ts b/packages/app/src/utils/notification-click.test.ts new file mode 100644 index 0000000000..76535f83a8 --- /dev/null +++ b/packages/app/src/utils/notification-click.test.ts @@ -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"]) + }) +}) diff --git a/packages/app/src/utils/notification-click.ts b/packages/app/src/utils/notification-click.ts new file mode 100644 index 0000000000..1234cd1d62 --- /dev/null +++ b/packages/app/src/utils/notification-click.ts @@ -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) +} diff --git a/packages/app/src/utils/terminal-writer.test.ts b/packages/app/src/utils/terminal-writer.test.ts new file mode 100644 index 0000000000..d48dd4f4ed --- /dev/null +++ b/packages/app/src/utils/terminal-writer.test.ts @@ -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([]) + }) +}) diff --git a/packages/app/src/utils/terminal-writer.ts b/packages/app/src/utils/terminal-writer.ts new file mode 100644 index 0000000000..b6caff789c --- /dev/null +++ b/packages/app/src/utils/terminal-writer.ts @@ -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 } +} diff --git a/packages/console/app/package.json b/packages/console/app/package.json index d80de55a24..3d7ef57851 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.63", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ccc11ba3a0..0676595c70 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -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", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b612f54308..265546fc7f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -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", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 864c233820..0f4bbb6eca 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -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", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 526610e6eb..8e4862b30d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.63", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 3e14250b1a..072567758f 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -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) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index c3019f0b97..2629eb466c 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -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", }, { diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index bec72c04fa..85ea21d38c 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -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() diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 620914dd7e..ff0a093766 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -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): 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() } }) diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx index ee29827227..23a8055c9d 100644 --- a/packages/desktop/src/loading.tsx +++ b/packages/desktop/src/loading.tsx @@ -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?.() }) }) diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index a86a549495..bd2fac19f7 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.63", + "version": "1.1.65", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 475e6a870d..84ae20633e 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/function/package.json b/packages/function/package.json index f2e8e5dc5d..4c10ab05f8 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -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", diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index e35cc00944..d73bbce267 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -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) } diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index c4617527d0..c3b7270764 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -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 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 99a69c3357..03e58c0297 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 163a5820d9..0febec3a20 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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) } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index dbad3f699f..ab3d096892 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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)} > diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 0b57ad29cf..8cebd9cba5 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -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 ( { - 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)} > { - 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} { - 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 + } > value.clear()} size={value.size}> diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts new file mode 100644 index 0000000000..1230852dcc --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -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 + } +} diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index b11058b340..dfcb88bc51 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -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") diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f6902de4e1..c79a62c6c9 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -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 }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d76cc902ae..44bcf8adb3 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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") diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 876a26fce7..8091f731f0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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 { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 7a07e3ef32..a9052a79eb 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -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) => void + close: (code?: number, reason?: string) => void + } + + const sockets = new WeakMap() + 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 + subscribers: Map } 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)) diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index 1085c1175b..10bf51cb99 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/packages/opencode/src/server/routes/pty.ts @@ -160,9 +160,25 @@ export const PtyRoutes = lazy(() => })() let handler: ReturnType if (!Pty.get(id)) throw new Error("Session not found") + + type Socket = { + readyState: number + send: (data: string | Uint8Array | 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() + }, } }), ), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 99d44cd850..be813e823f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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"), diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index b3c8a905c2..a1c2b57812 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -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() diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5ed5a879b4..9a06cb5993 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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, } }), ) diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts new file mode 100644 index 0000000000..201def36a8 --- /dev/null +++ b/packages/opencode/src/util/git.ts @@ -0,0 +1,64 @@ +import { $ } from "bun" +import { Flag } from "../flag/flag" + +export interface GitResult { + exitCode: number + text(): string | Promise + stdout: Buffer | ReadableStream + stderr: Buffer | ReadableStream +} + +/** + * 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 }): Promise { + 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, + } +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 88c778cbb8..85d7f6d0e8 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -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) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 0e99c5648b..581c63b567 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -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 }) => { + 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) }, })) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts new file mode 100644 index 0000000000..32d38fe84d --- /dev/null +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -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) + }) +}) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts new file mode 100644 index 0000000000..b80d373458 --- /dev/null +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -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) + } + }, + }) + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7040059f33..c373083f58 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -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": { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 664f2c9673..bd4ba53049 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -224,4 +224,8 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * Modify tool definitions (description and parameters) sent to LLM + */ + "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 7a47fbfa66..ff8108b7be 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -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": { diff --git a/packages/slack/package.json b/packages/slack/package.json index abede0f9d2..78a5702228 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.63", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 34720215f1..5dbbb4605a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.63", + "version": "1.1.65", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx index 76bde1e156..c1617b265c 100644 --- a/packages/ui/src/components/avatar.tsx +++ b/packages/ui/src/components/avatar.tsx @@ -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 } : {}), }} > - + {(src) => } diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index abe0d7ca9e..837cc53376 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -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 + type SelectionSide = "additions" | "deletions" export type CodeProps = FileOptions & { @@ -160,16 +177,28 @@ export function Code(props: CodeProps) { const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 }) - const file = createMemo( - () => - new File( - { - ...createDefaultOptions("unified"), - ...others, - }, - getWorkerPool("unified"), - ), - ) + let instance: File | VirtualizedFile | 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("unified"), + ...others, + })) const getRoot = () => { const host = container.querySelector("diffs-container") @@ -577,6 +606,14 @@ export function Code(props: CodeProps) { } 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(props: CodeProps) { 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(props: CodeProps) { 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(props: CodeProps) { return { start: range.start, end: range.end } })() - file().setSelectedLines(normalized) + current.setSelectedLines(normalized) return true } @@ -619,9 +656,12 @@ export function Code(props: CodeProps) { 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(props: CodeProps) { } 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(opts, v, codeMetrics, workerPool) : new File(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(props: CodeProps) { onCleanup(() => { observer?.disconnect() + instance?.cleanUp() + instance = undefined + + virtualizer?.cleanUp() + virtualizer = undefined + virtualRoot = undefined + clearOverlayScroll() clearOverlay() if (findCurrent === host) { diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 0966db75e0..0002232b01 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -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(props: DiffProps) { 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, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength"> + + const options = createMemo>(() => { + 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(props: DiffProps) { 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(opts, virtualizer, virtualMetrics, workerPool) @@ -545,12 +565,12 @@ export function Diff(props: DiffProps) { 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, diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index abd5572207..aa2347037e 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -45,6 +45,7 @@ export interface ListProps extends FilteredListProps { 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(props: ListProps & { 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(undefined) @@ -228,7 +229,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- {groupProps.category} + {props.groupHeader?.(groupProps.group) ?? groupProps.group.category}
) } @@ -323,7 +324,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- +
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index 30bfe3b712..46473b75e5 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -222,4 +222,30 @@ --line-comment-popover-z: 30; --line-comment-open-z: 6; } + + [data-slot="session-review-large-diff"] { + padding: 12px; + background: var(--background-stronger); + } + + [data-slot="session-review-large-diff-title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--text-strong); + margin-bottom: 4px; + } + + [data-slot="session-review-large-diff-meta"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + color: var(--text-weak); + word-break: break-word; + } + + [data-slot="session-review-large-diff-actions"] { + display: flex; + gap: 8px; + margin-top: 10px; + } } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index fe2475548e..5f1e6b1aba 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -17,6 +17,26 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" +const MAX_DIFF_LINES = 20_000 +const MAX_DIFF_BYTES = 2_000_000 + +function linesOver(text: string, max: number) { + let lines = 1 + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) !== 10) continue + lines++ + if (lines > max) return true + } + return lines > max +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B" + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10} KB` + return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB` +} + export type SessionReviewDiffStyle = "unified" | "split" export type SessionReviewComment = { @@ -326,12 +346,28 @@ export const SessionReview = (props: SessionReviewProps) => { {(diff) => { let wrapper: HTMLDivElement | undefined + const expanded = createMemo(() => open().includes(diff.file)) + const [force, setForce] = createSignal(false) + const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file)) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof diff.before === "string" ? diff.before : "") const afterText = () => (typeof diff.after === "string" ? diff.after : "") + const tooLarge = createMemo(() => { + if (!expanded()) return false + if (force()) return false + if (isImageFile(diff.file)) return false + + const before = beforeText() + const after = afterText() + + if (before.length > MAX_DIFF_BYTES || after.length > MAX_DIFF_BYTES) return true + if (linesOver(before, MAX_DIFF_LINES) || linesOver(after, MAX_DIFF_LINES)) return true + return false + }) + const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0) const isDeleted = () => diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0) @@ -571,94 +607,114 @@ export const SessionReview = (props: SessionReviewProps) => { scheduleAnchors() }} > - - -
- {diff.file} -
-
- -
- - {i18n.t("ui.sessionReview.change.removed")} - -
-
- -
- - {imageStatus() === "loading" ? "Loading..." : "Image"} - -
-
- - { - props.onDiffRendered?.() - scheduleAnchors() - }} - enableLineSelection={props.onLineComment != null} - onLineSelected={handleLineSelected} - onLineSelectionEnd={handleLineSelectionEnd} - selectedLines={selectedLines()} - commentedLines={commentedLines()} - before={{ - name: diff.file!, - contents: typeof diff.before === "string" ? diff.before : "", - }} - after={{ - name: diff.file!, - contents: typeof diff.after === "string" ? diff.after : "", - }} - /> - -
- - - {(comment) => ( - setSelection({ file: comment.file, range: comment.selection })} - onClick={() => { - if (isCommentOpen(comment)) { - setOpened(null) - return - } - - openComment(comment) - }} - open={isCommentOpen(comment)} - comment={comment.comment} - selection={selectionLabel(comment.selection)} - /> - )} - - - - {(range) => ( - - setCommenting(null)} - onSubmit={(comment) => { - props.onLineComment?.({ - file: diff.file, - selection: range(), - comment, - preview: selectionPreview(diff, range()), - }) - setCommenting(null) + + + +
+ {diff.file} +
+
+ +
+ + {i18n.t("ui.sessionReview.change.removed")} + +
+
+ +
+ + {imageStatus() === "loading" + ? i18n.t("ui.sessionReview.image.loading") + : i18n.t("ui.sessionReview.image.placeholder")} + +
+
+ +
+
+ {i18n.t("ui.sessionReview.largeDiff.title")} +
+
+ Limit: {MAX_DIFF_LINES.toLocaleString()} lines / {formatBytes(MAX_DIFF_BYTES)}. + Current: {formatBytes(Math.max(beforeText().length, afterText().length))}. +
+
+ +
+
+
+ + { + props.onDiffRendered?.() + scheduleAnchors() + }} + enableLineSelection={props.onLineComment != null} + onLineSelected={handleLineSelected} + onLineSelectionEnd={handleLineSelectionEnd} + selectedLines={selectedLines()} + commentedLines={commentedLines()} + before={{ + name: diff.file!, + contents: typeof diff.before === "string" ? diff.before : "", + }} + after={{ + name: diff.file!, + contents: typeof diff.after === "string" ? diff.after : "", }} /> -
- )} + + + + + {(comment) => ( + setSelection({ file: comment.file, range: comment.selection })} + onClick={() => { + if (isCommentOpen(comment)) { + setOpened(null) + return + } + + openComment(comment) + }} + open={isCommentOpen(comment)} + comment={comment.comment} + selection={selectionLabel(comment.selection)} + /> + )} + + + + {(range) => ( + + setCommenting(null)} + onSubmit={(comment) => { + props.onLineComment?.({ + file: diff.file, + selection: range(), + comment, + preview: selectionPreview(diff, range()), + }) + setCommenting(null) + }} + /> + + )} +
diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx index a8600aef44..f4f95baf57 100644 --- a/packages/ui/src/components/switch.tsx +++ b/packages/ui/src/components/switch.tsx @@ -10,7 +10,7 @@ export interface SwitchProps extends ParentProps> export function Switch(props: SwitchProps) { const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) return ( - + diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index de547f9c78..4e6504d061 100644 --- a/packages/ui/src/components/toast.css +++ b/packages/ui/src/components/toast.css @@ -21,6 +21,11 @@ padding: 0; max-height: 100%; overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } } @@ -101,6 +106,11 @@ min-width: 0; overflow-x: hidden; overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } [data-slot="toast-title"] { diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 7ee17e2e01..9a6c8dcbd0 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "مضاف", "ui.sessionReview.change.removed": "محذوف", "ui.sessionReview.change.modified": "معدل", + "ui.sessionReview.image.loading": "جار التحميل...", + "ui.sessionReview.image.placeholder": "صورة", + "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه", + "ui.sessionReview.largeDiff.meta": "الحد: {{lines}} سطر / {{limit}}. الحالي: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال", "ui.lineComment.label.prefix": "تعليق على ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 6d7449d845..148b0ae174 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Adicionado", "ui.sessionReview.change.removed": "Removido", "ui.sessionReview.change.modified": "Modificado", + "ui.sessionReview.image.loading": "Carregando...", + "ui.sessionReview.image.placeholder": "Imagem", + "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar", + "ui.sessionReview.largeDiff.meta": "Limite: {{lines}} linhas / {{limit}}. Atual: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim", "ui.lineComment.label.prefix": "Comentar em ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 24e4c12068..7614af087f 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -12,6 +12,11 @@ export const dict = { "ui.sessionReview.change.added": "Dodano", "ui.sessionReview.change.removed": "Uklonjeno", "ui.sessionReview.change.modified": "Izmijenjeno", + "ui.sessionReview.image.loading": "Učitavanje...", + "ui.sessionReview.image.placeholder": "Slika", + "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linija / {{limit}}. Trenutno: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno", "ui.lineComment.label.prefix": "Komentar na ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 218f3b26a4..2f49a94344 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "Tilføjet", "ui.sessionReview.change.removed": "Fjernet", "ui.sessionReview.change.modified": "Ændret", + "ui.sessionReview.image.loading": "Indlæser...", + "ui.sessionReview.image.placeholder": "Billede", + "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist", + "ui.sessionReview.largeDiff.meta": "Grænse: {{lines}} linjer / {{limit}}. Nuværende: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel", "ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Kommenterer på ", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 921a12c996..44090b7bdb 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -13,6 +13,11 @@ export const dict = { "ui.sessionReview.change.added": "Hinzugefügt", "ui.sessionReview.change.removed": "Entfernt", "ui.sessionReview.change.modified": "Geändert", + "ui.sessionReview.image.loading": "Wird geladen...", + "ui.sessionReview.image.placeholder": "Bild", + "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} Zeilen / {{limit}}. Aktuell: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern", "ui.lineComment.label.prefix": "Kommentar zu ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Kommentiere ", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 631bc660a6..9b6ab0bd6d 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Added", "ui.sessionReview.change.removed": "Removed", "ui.sessionReview.change.modified": "Modified", + "ui.sessionReview.image.loading": "Loading...", + "ui.sessionReview.image.placeholder": "Image", + "ui.sessionReview.largeDiff.title": "Diff too large to render", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} lines / {{limit}}. Current: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Render anyway", "ui.lineComment.label.prefix": "Comment on ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 4fd921b606..c2f8ac3b9d 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Añadido", "ui.sessionReview.change.removed": "Eliminado", "ui.sessionReview.change.modified": "Modificado", + "ui.sessionReview.image.loading": "Cargando...", + "ui.sessionReview.image.placeholder": "Imagen", + "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar", + "ui.sessionReview.largeDiff.meta": "Límite: {{lines}} líneas / {{limit}}. Actual: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos", "ui.lineComment.label.prefix": "Comentar en ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 537d01bba9..679d56fa76 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Ajouté", "ui.sessionReview.change.removed": "Supprimé", "ui.sessionReview.change.modified": "Modifié", + "ui.sessionReview.image.loading": "Chargement...", + "ui.sessionReview.image.placeholder": "Image", + "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché", + "ui.sessionReview.largeDiff.meta": "Limite : {{lines}} lignes / {{limit}}. Actuel : {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même", "ui.lineComment.label.prefix": "Commenter sur ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 6086070bdb..bf85807d00 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "追加", "ui.sessionReview.change.removed": "削除", "ui.sessionReview.change.modified": "変更", + "ui.sessionReview.image.loading": "読み込み中...", + "ui.sessionReview.image.placeholder": "画像", + "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません", + "ui.sessionReview.largeDiff.meta": "上限: {{lines}} 行 / {{limit}}。現在: {{current}}。", + "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する", "ui.lineComment.label.prefix": "", "ui.lineComment.label.suffix": "へのコメント", "ui.lineComment.editorLabel.prefix": "", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index fd394dbb7b..aba793a11b 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "추가됨", "ui.sessionReview.change.removed": "삭제됨", "ui.sessionReview.change.modified": "수정됨", + "ui.sessionReview.image.loading": "로딩 중...", + "ui.sessionReview.image.placeholder": "이미지", + "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다", + "ui.sessionReview.largeDiff.meta": "제한: {{lines}}줄 / {{limit}}. 현재: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링", "ui.lineComment.label.prefix": "", "ui.lineComment.label.suffix": "에 댓글 달기", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index dcb353614d..7982b3ac75 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -11,6 +11,11 @@ export const dict: Record = { "ui.sessionReview.change.added": "Lagt til", "ui.sessionReview.change.removed": "Fjernet", "ui.sessionReview.change.modified": "Endret", + "ui.sessionReview.image.loading": "Laster...", + "ui.sessionReview.image.placeholder": "Bilde", + "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi", + "ui.sessionReview.largeDiff.meta": "Grense: {{lines}} linjer / {{limit}}. Nåværende: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel", "ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index fb10debbb9..2489ac7f2e 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "Dodano", "ui.sessionReview.change.removed": "Usunięto", "ui.sessionReview.change.modified": "Zmodyfikowano", + "ui.sessionReview.image.loading": "Ładowanie...", + "ui.sessionReview.image.placeholder": "Obraz", + "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linii / {{limit}}. Obecnie: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to", "ui.lineComment.label.prefix": "Komentarz do ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Komentowanie: ", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 417fe0ce8b..8e6bb678f2 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "Добавлено", "ui.sessionReview.change.removed": "Удалено", "ui.sessionReview.change.modified": "Изменено", + "ui.sessionReview.image.loading": "Загрузка...", + "ui.sessionReview.image.placeholder": "Изображение", + "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения", + "ui.sessionReview.largeDiff.meta": "Лимит: {{lines}} строк / {{limit}}. Текущий: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно", "ui.lineComment.label.prefix": "Комментарий к ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Комментирование: ", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 68bb0d733d..b036eca2e8 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "เพิ่ม", "ui.sessionReview.change.removed": "ลบ", "ui.sessionReview.change.modified": "แก้ไข", + "ui.sessionReview.image.loading": "กำลังโหลด...", + "ui.sessionReview.image.placeholder": "รูปภาพ", + "ui.sessionReview.largeDiff.title": "Diff มีขนาดใหญ่เกินไปจนไม่สามารถแสดงผลได้", + "ui.sessionReview.largeDiff.meta": "ขีดจำกัด: {{lines}} บรรทัด / {{limit}}. ปัจจุบัน: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป", "ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 53beeb1e4f..dcb8062a33 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -12,6 +12,11 @@ export const dict = { "ui.sessionReview.change.added": "已添加", "ui.sessionReview.change.removed": "已移除", "ui.sessionReview.change.modified": "已修改", + "ui.sessionReview.image.loading": "加载中...", + "ui.sessionReview.image.placeholder": "图片", + "ui.sessionReview.largeDiff.title": "差异过大,无法渲染", + "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。当前:{{current}}。", + "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.lineComment.label.prefix": "评论 ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 1449b0530a..271a6ded32 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -12,6 +12,11 @@ export const dict = { "ui.sessionReview.change.added": "已新增", "ui.sessionReview.change.removed": "已移除", "ui.sessionReview.change.modified": "已修改", + "ui.sessionReview.image.loading": "載入中...", + "ui.sessionReview.image.placeholder": "圖片", + "ui.sessionReview.largeDiff.title": "差異過大,無法渲染", + "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。目前:{{current}}。", + "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.lineComment.label.prefix": "評論 ", "ui.lineComment.label.suffix": "", diff --git a/packages/util/package.json b/packages/util/package.json index 6bc354049b..f37bb5c1d1 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.63", + "version": "1.1.65", "private": true, "type": "module", "license": "MIT", diff --git a/packages/util/src/encode.ts b/packages/util/src/encode.ts index 138cf16086..e4c6e70acb 100644 --- a/packages/util/src/encode.ts +++ b/packages/util/src/encode.ts @@ -28,3 +28,24 @@ export function checksum(content: string): string | undefined { } return (hash >>> 0).toString(36) } + +export function sampledChecksum(content: string, limit = 500_000): string | undefined { + if (!content) return undefined + if (content.length <= limit) return checksum(content) + + const size = 4096 + const points = [ + 0, + Math.floor(content.length * 0.25), + Math.floor(content.length * 0.5), + Math.floor(content.length * 0.75), + content.length - size, + ] + const hashes = points + .map((point) => { + const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2))) + return checksum(content.slice(start, start + size)) ?? "" + }) + .join(":") + return `${content.length}:${hashes}` +} diff --git a/packages/web/package.json b/packages/web/package.json index 0d04a5adfe..7c6698117a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.63", + "version": "1.1.65", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/index.mdx b/packages/web/src/content/docs/ar/index.mdx index fef8844dc0..ff2de9c512 100644 --- a/packages/web/src/content/docs/ar/index.mdx +++ b/packages/web/src/content/docs/ar/index.mdx @@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash - **باستخدام Paru على Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/da/index.mdx b/packages/web/src/content/docs/da/index.mdx index b2623b93d5..65cf34f9a9 100644 --- a/packages/web/src/content/docs/da/index.mdx +++ b/packages/web/src/content/docs/da/index.mdx @@ -84,7 +84,8 @@ Du kan også installere det med følgende kommandoer: - **Brug af Paru på Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/de/index.mdx b/packages/web/src/content/docs/de/index.mdx index e1337254df..1d7e1f4f96 100644 --- a/packages/web/src/content/docs/de/index.mdx +++ b/packages/web/src/content/docs/de/index.mdx @@ -84,7 +84,8 @@ Sie können es auch mit den folgenden Befehlen installieren: - **Verwendung von Paru unter Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/es/index.mdx b/packages/web/src/content/docs/es/index.mdx index fa8c40c7ff..b4d3f95b5c 100644 --- a/packages/web/src/content/docs/es/index.mdx +++ b/packages/web/src/content/docs/es/index.mdx @@ -84,7 +84,8 @@ También puedes instalarlo con los siguientes comandos: - **Usando Paru en Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/fr/index.mdx b/packages/web/src/content/docs/fr/index.mdx index 06d650de7e..4f6b2f2a5b 100644 --- a/packages/web/src/content/docs/fr/index.mdx +++ b/packages/web/src/content/docs/fr/index.mdx @@ -84,7 +84,8 @@ Vous pouvez également l'installer avec les commandes suivantes : - **Via Paru sur Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index bb3b8cb5d0..90e7eafb2f 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -81,10 +81,11 @@ You can also install it with the following commands: > We recommend using the OpenCode tap for the most up to date releases. The official `brew install opencode` formula is maintained by the Homebrew team and is updated less frequently. -- **Using Paru on Arch Linux** +- **Installing on Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/it/index.mdx b/packages/web/src/content/docs/it/index.mdx index 685bdf0c57..1b48d9df61 100644 --- a/packages/web/src/content/docs/it/index.mdx +++ b/packages/web/src/content/docs/it/index.mdx @@ -84,7 +84,8 @@ Puoi anche installarlo con i seguenti comandi: - **Con Paru su Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/ja/index.mdx b/packages/web/src/content/docs/ja/index.mdx index 6a6612715f..e4e84d7b1c 100644 --- a/packages/web/src/content/docs/ja/index.mdx +++ b/packages/web/src/content/docs/ja/index.mdx @@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash - **Arch Linux での Paru の使用** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/ko/acp.mdx b/packages/web/src/content/docs/ko/acp.mdx index 2fbd58013f..a9842f2709 100644 --- a/packages/web/src/content/docs/ko/acp.mdx +++ b/packages/web/src/content/docs/ko/acp.mdx @@ -1,12 +1,12 @@ --- -title: ACP 지원 -description: ACP 호환 편집기에서 opencode를 사용하세요. +title: ACP Support +description: Use OpenCode in any ACP-compatible editor. --- -opencode는 [Agent Client Protocol](https://agentclientprotocol.com) 또는 (ACP)을 지원하며, 호환 편집기 및 IDE에서 직접 사용할 수 있습니다. +OpenCode는 [Agent Client Protocol](https://agentclientprotocol.com)(ACP)을 지원하므로, ACP 호환 편집기와 IDE에서 OpenCode를 직접 사용할 수 있습니다. :::tip -ACP를 지원하는 편집기 및 도구 목록의 경우 [ACP 진행 보고서](https://zed.dev/blog/acp-progress-report#available-now)를 확인하십시오. +ACP를 지원하는 편집기와 tool 목록은 [ACP progress report](https://zed.dev/blog/acp-progress-report#available-now)에서 확인하세요. ::: ACP는 코드 편집기와 AI 코딩 에이전트 간의 통신을 표준화하는 개방형 프로토콜입니다. @@ -15,17 +15,17 @@ ACP는 코드 편집기와 AI 코딩 에이전트 간의 통신을 표준화하 ## 구성 -ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려면 편집기를 구성하십시오. +ACP로 OpenCode를 사용하려면, 편집기에서 `opencode acp` 명령을 실행하도록 config를 설정하세요. -명령은 opencode를 실행하여 JSON-RPC를 통해 편집기와 통신하는 ACP 호환 하위 프로세스로 시작합니다. +이 명령은 OpenCode를 ACP 호환 subprocess로 시작하며, stdio 기반 JSON-RPC를 통해 편집기와 통신합니다. -아래는 ACP를 지원하는 인기있는 편집기의 예입니다. +아래는 ACP를 지원하는 주요 편집기 예시입니다. --- -##### Zed +### Zed -[Zed](https://zed.dev) 구성 (`~/.config/zed/settings.json`)에 추가 : +[Zed](https://zed.dev) config(`~/.config/zed/settings.json`)에 다음을 추가하세요. ```json title="~/.config/zed/settings.json" { @@ -38,9 +38,9 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려 } ``` -그것을 열려면 **Command Palette **에서 `agent: new thread` 동작을 사용하십시오. +열려면 **Command Palette**에서 `agent: new thread` action을 사용하세요. -`keymap.json`를 편집하여 키보드 단축키도 결합할 수 있습니다. +`keymap.json`을 수정해 키보드 단축키를 바인딩할 수도 있습니다. ```json title="keymap.json" [ @@ -67,9 +67,9 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려 --- -#### JetBrains IDEs +### JetBrains IDEs -[JetBrains IDE]에 추가하십시오 (https://www.jetbrains.com/) [documentation]에 따라 acp.json (https://www.jetbrains.com/help/ai-assistant/acp.html): +[JetBrains IDE](https://www.jetbrains.com/)에서는 [documentation](https://www.jetbrains.com/help/ai-assistant/acp.html)에 따라 `acp.json`에 다음을 추가하세요. ```json title="acp.json" { @@ -82,13 +82,13 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려 } ``` -그것을 열려면 AI Chat Agent selector의 새로운 'opencode' 에이전트를 사용하십시오. +열려면 AI Chat agent selector에서 새 `OpenCode` agent를 선택하세요. --- -#### Avante.nvim +### Avante.nvim -[Avante.nvim](https://github.com/yetone/avante.nvim) 구성에 추가하십시오: +[Avante.nvim](https://github.com/yetone/avante.nvim) config에 다음을 추가하세요. ```lua { @@ -101,7 +101,7 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려 } ``` -환경 변수를 전달해야 하는 경우: +환경 변수를 전달해야 한다면 다음과 같이 설정하세요. ```lua {6-8} { @@ -119,9 +119,9 @@ ACP를 통해 opencode를 사용하려면 `opencode acp` 명령을 실행하려 --- -#### CodeCompanion.nvim +### CodeCompanion.nvim -opencode를 [CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim)에서 ACP 에이전트로 사용하려면 Neovim config에 다음을 추가하십시오. +[CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim)에서 OpenCode를 ACP agent로 사용하려면 Neovim config에 다음을 추가하세요. ```lua require("codecompanion").setup({ @@ -136,21 +136,21 @@ require("codecompanion").setup({ }) ``` -이 구성은 CodeCompanion을 설정하여 채팅을 위한 ACP 에이전트로 opencode를 사용합니다. +이 config는 chat에서 OpenCode를 ACP agent로 사용하도록 CodeCompanion을 설정합니다. -환경 변수 (`OPENCODE_API_KEY`와 같은)를 전달해야하는 경우, CodeCompanion.nvim 문서에서 [Configuring Adapters: Environment variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key)를 참조하십시오. +환경 변수(`OPENCODE_API_KEY` 등)를 전달해야 한다면 CodeCompanion.nvim documentation의 [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key)를 참고하세요. -## 지원 기능 +## 지원 -opencode는 터미널과 동일하게 ACP를 통해 작동합니다. 모든 기능은 지원됩니다: +OpenCode는 ACP를 통해서도 터미널과 동일하게 동작합니다. 다음 기능을 모두 지원합니다. :::note `/undo` 및 `/redo`와 같은 일부 내장 슬래시 명령은 현재 지원되지 않습니다. ::: -- 내장 도구 (파일 작업, terminal 명령 등) -- 사용자 정의 도구 및 슬래시 명령 -- opencode config에서 설정된 MCP 서버 -- `AGENTS.md`의 프로젝트 별 규칙 -- 사용자 정의 포맷 및 라이터 -- 에이전트 및 권한 시스템 +- 내장 tool(파일 작업, terminal 명령 등) +- 사용자 정의 tool과 slash command +- OpenCode config에 설정한 MCP 서버 +- `AGENTS.md`의 프로젝트별 규칙 +- 사용자 정의 formatter와 linter +- agent 및 권한 시스템 diff --git a/packages/web/src/content/docs/ko/agents.mdx b/packages/web/src/content/docs/ko/agents.mdx index 5b37122435..34de6250d1 100644 --- a/packages/web/src/content/docs/ko/agents.mdx +++ b/packages/web/src/content/docs/ko/agents.mdx @@ -1,141 +1,141 @@ --- -title: 에이전트 -description: 전문 에이전트를 구성하고 사용하세요. +title: Agents +description: Configure and use specialized agents. --- -에이전트는 특정 작업과 워크플로우를 전문으로 하는 구성 가능한 AI 보조자입니다. 사용자 정의 프롬프트, 모델, 도구 액세스로 집중된 도구를 만들 수 있습니다. +agent는 특정 작업과 워크플로에 맞게 설정할 수 있는 전문 AI assistant입니다. custom prompt, model, tool 접근 권한을 조합해 목적에 맞는 agent를 만들 수 있습니다. :::tip -코드 변경 없이 제안을 검토할 때는 Plan 에이전트를 사용하세요. +코드를 수정하지 않고 분석과 제안 검토만 하고 싶다면 plan agent를 사용하세요. ::: -세션 중에 에이전트를 전환하거나 `@` 멘션으로 호출할 수 있습니다. +세션 중에 agent를 전환하거나 `@` mention으로 호출할 수 있습니다. --- ## 유형 -OpenCode에는 두 가지 유형의 에이전트가 있습니다: 기본 에이전트(Primary Agent)와 서브 에이전트(Subagent). +OpenCode의 agent는 primary agent와 subagent, 두 가지 유형으로 나뉩니다. --- -### 기본 에이전트 +### Primary agents -기본 에이전트는 사용자가 직접 상호 작용하는 주요 보조자입니다. **Tab** 키 또는 설정된 `switch_agent` 키바인드를 사용하여 순환할 수 있습니다. 이 에이전트는 주요 대화를 처리합니다. 도구 액세스는 권한을 통해 구성됩니다 — 예를 들어, Build는 모든 도구를 사용할 수 있지만 Plan은 제한됩니다. +primary agent는 사용자가 직접 상호작용하는 메인 assistant입니다. **Tab** 키 또는 설정한 `switch_agent` keybind로 순환 전환할 수 있습니다. primary agent는 메인 대화를 처리하며, tool 접근은 permission으로 제어합니다. 예를 들어 Build는 모든 tool이 활성화되어 있고 Plan은 제한되어 있습니다. :::tip -세션 중에 **Tab** 키를 사용하여 기본 에이전트를 전환할 수 있습니다. +세션 중 **Tab** 키로 primary agent를 빠르게 전환할 수 있습니다. ::: -OpenCode는 두 가지 내장 기본 에이전트, **Build** 및 **Plan**을 제공합니다. 아래에서 자세히 살펴봅니다. +OpenCode에는 기본 제공 primary agent인 **Build**와 **Plan**이 포함되어 있습니다. 아래에서 각각 살펴보겠습니다. --- -### 서브 에이전트 +### Subagents -서브 에이전트는 기본 에이전트가 특정 작업을 위해 호출할 수 있는 전문 보조자입니다. 또한 메시지에서 **@멘션**을 통해 수동으로 호출할 수도 있습니다. +subagent는 primary agent가 특정 작업을 위해 호출하는 전문 assistant입니다. 메시지에서 **@ mention**으로 직접 호출할 수도 있습니다. -OpenCode는 두 가지 내장 서브 에이전트, **General** 및 **Explore**를 제공합니다. 아래에서 자세히 살펴봅니다. +OpenCode에는 기본 제공 subagent인 **General**과 **Explore**가 포함되어 있습니다. 아래에서 살펴보겠습니다. --- ## 기본 제공 -OpenCode는 기본 에이전트와 두 개의 내장 서브 에이전트를 제공합니다. +OpenCode는 기본적으로 primary agent 2개와 subagent 2개를 제공합니다. --- -### Build +### Use build -_모드_: `primary` +_Mode_: `primary` -Build는 모든 도구가 활성화된 **기본** 에이전트입니다. 파일 조작 및 시스템 명령에 대한 전체 액세스가 필요한 개발 작업을 위한 표준 에이전트입니다. +Build는 모든 tool이 활성화된 **default** primary agent입니다. 파일 작업과 시스템 명령에 대한 전체 접근이 필요한 일반적인 개발 작업에 사용하는 표준 agent입니다. --- -### Plan +### Use plan -_모드_: `primary` +_Mode_: `primary` -계획 및 분석을 위해 설계된 제한된 에이전트입니다. 더 많은 제어권을 부여하고 의도하지 않은 변경을 방지하기 위해 권한 시스템을 사용합니다. -기본적으로 다음은 모두 `ask`로 설정됩니다: +Plan은 계획과 분석에 특화된 제한형 agent입니다. 더 높은 제어력과 의도치 않은 변경 방지를 위해 permission 시스템을 사용합니다. +기본값으로 아래 항목은 모두 `ask`로 설정됩니다. -- `file edits`: 모든 쓰기, 패치 및 편집 +- `file edits`: 모든 write, patch, edit - `bash`: 모든 bash 명령 -이 에이전트는 코드를 분석하거나 변경을 제안받고 싶지만, 코드베이스에 실제 수정 없이 계획만 만들고 싶을 때 유용합니다. +코드베이스를 실제로 수정하지 않고 LLM 분석, 변경 제안, 계획 수립만 진행하고 싶을 때 유용합니다. --- -### General +### Use general -_모드_: `subagent` +_Mode_: `subagent` -복잡한 질문을 연구하고 다단계 작업을 실행하기 위한 범용 에이전트입니다. 전체 도구 액세스(todo 제외)를 가지므로 필요할 때 파일 변경을 수행할 수 있습니다. 여러 단위의 작업을 병렬로 실행할 때 사용하세요. +복잡한 질문을 조사하고 다단계 작업을 수행하기 위한 범용 agent입니다. todo를 제외한 모든 tool 접근이 가능하므로 필요하면 파일 수정도 할 수 있습니다. 여러 작업 단위를 병렬로 처리할 때 사용하세요. --- -### Explore +### Use explore -_모드_: `subagent` +_Mode_: `subagent` -코드베이스를 탐색하는 빠르고 읽기 전용인 에이전트입니다. 파일을 수정할 수 없습니다. 패턴, 키워드로 코드를 검색하거나 코드베이스에 대한 질문에 답하기 위해 파일을 빠르게 찾아야 할 때 사용하세요. +코드베이스 탐색에 최적화된 빠른 읽기 전용 agent입니다. 파일을 수정할 수 없습니다. 패턴 기반 파일 탐색, 키워드 검색, 코드베이스 관련 질의 응답을 빠르게 처리할 때 사용하세요. --- -### Compaction +### Use compaction -_모드_: `primary` +_Mode_: `primary` -긴 컨텍스트를 작은 요약으로 압축하는 숨겨진 시스템 에이전트입니다. 필요한 경우 자동으로 실행되며 UI에서 선택할 수 없습니다. +긴 context를 더 짧은 요약으로 압축하는 숨겨진 시스템 agent입니다. 필요할 때 자동으로 실행되며 UI에서 직접 선택할 수 없습니다. --- -### Title +### Use title -_모드_: `primary` +_Mode_: `primary` -짧은 세션 제목을 생성하는 숨겨진 시스템 에이전트입니다. 자동으로 실행되며 UI에서 선택할 수 없습니다. +짧은 세션 제목을 생성하는 숨겨진 시스템 agent입니다. 자동으로 실행되며 UI에서 직접 선택할 수 없습니다. --- -### Summary +### Use summary -_모드_: `primary` +_Mode_: `primary` -세션 요약을 만드는 숨겨진 시스템 에이전트입니다. 자동으로 실행되며 UI에서 선택할 수 없습니다. +세션 요약을 생성하는 숨겨진 시스템 agent입니다. 자동으로 실행되며 UI에서 직접 선택할 수 없습니다. --- ## 사용법 -1. 기본 에이전트의 경우, 세션 중에 **Tab** 키를 사용하여 순환합니다. 구성된 `switch_agent` 키바인드도 사용할 수 있습니다. +1. primary agent는 세션 중 **Tab** 키로 순환 전환할 수 있습니다. 설정한 `switch_agent` keybind를 사용할 수도 있습니다. -2. 서브 에이전트는 다음과 같이 호출할 수 있습니다: - - 설명에 근거하여 전문적인 작업을 위해 기본 에이전트에 의해 **자동으로** 호출됨. - - 메시지에서 서브 에이전트를 **@멘션**. 예를 들어: +2. subagent 호출 방법: + - **Automatically**: primary agent가 설명(description)을 바탕으로 특화 작업에 자동 호출합니다. + - 수동 호출: 메시지에서 subagent를 **@ mention**하여 호출합니다. 예: ```txt frame="none" @general help me search for this function ``` -3. **세션 간 이동**: 서브 에이전트가 자체 자식 세션을 만들 때, 부모 세션과 자식 세션 간을 탐색할 수 있습니다. - - **\+Right** (또는 부모 → 자식1 → 자식2 순으로 이동하기 위해 설정된 `session_child_cycle` 키바인드) - - **\+Left** (또는 `session_child_cycle_reverse` 키바인드) 부모 방향으로 되돌아가기: 자식2 → 자식1 → 부모 +3. **세션 간 이동**: subagent가 child session을 만들면 아래 키로 parent session과 child session 사이를 이동할 수 있습니다. + - **\+Right** (또는 설정한 `session_child_cycle` keybind): parent → child1 → child2 → ... → parent 순방향 순환 + - **\+Left** (또는 설정한 `session_child_cycle_reverse` keybind): parent ← child1 ← child2 ← ... ← parent 역방향 순환 - 이를 통해 주요 대화와 전문적인 서브 에이전트 작업 간을 원활하게 전환할 수 있습니다. + 이를 통해 메인 대화와 특화 subagent 작업 사이를 자연스럽게 오갈 수 있습니다. --- ## 구성 -내장 에이전트를 사용자 정의하거나 구성을 통해 자신만의 에이전트를 만들 수 있습니다. 에이전트는 두 가지 방법으로 구성될 수 있습니다: +기본 제공 agent를 커스터마이즈하거나 config를 통해 직접 agent를 만들 수 있습니다. agent는 두 가지 방식으로 설정합니다. --- ### JSON -`opencode.json` config 파일에 에이전트 구성: +`opencode.json` config 파일에서 agent를 설정합니다. ```json title="opencode.json" { @@ -178,10 +178,10 @@ _모드_: `primary` ### Markdown -Markdown 파일을 사용하여 에이전트를 정의 할 수 있습니다. 그들에 게: +Markdown 파일로도 agent를 정의할 수 있습니다. 다음 위치에 두세요. -- 글로벌: `~/.config/opencode/agents/` -- 프로젝트: `.opencode/agents/` +- Global: `~/.config/opencode/agents/` +- Per-project: `.opencode/agents/` ```markdown title="~/.config/opencode/agents/review.md" --- @@ -205,19 +205,19 @@ You are in code review mode. Focus on: Provide constructive feedback without making direct changes. ``` -markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.md`는 `review` 에이전트을 만듭니다. +Markdown 파일명은 agent 이름이 됩니다. 예를 들어 `review.md`는 `review` agent를 만듭니다. --- ## 옵션 -이 구성 옵션들을 자세히 살펴봅시다. +각 config 옵션을 자세히 살펴보겠습니다. --- -### 설명 +### Description -`description` 옵션을 사용하여 에이전트가 무엇을 하고 언제 사용해야 하는지에 대한 간단한 설명을 제공합니다. +`description` 옵션으로 agent의 역할과 사용 시점을 간단히 설명하세요. ```json title="opencode.json" { @@ -229,15 +229,15 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m } ``` -\*\* 필수 구성 옵션입니다. +이 옵션은 **필수** config 항목입니다. --- -### 온도 +### Temperature -`temperature` 구성으로 LLM 응답의 무작위성과 창의성을 제어합니다. +`temperature` config로 LLM 응답의 무작위성과 창의성을 제어합니다. -값이 낮을수록 더 집중되고 결정적인 응답을 생성하며, 값이 높을수록 창의성과 가변성이 증가합니다. +값이 낮을수록 응답이 더 집중되고 결정적이며, 값이 높을수록 창의성과 다양성이 커집니다. ```json title="opencode.json" { @@ -252,11 +252,11 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m } ``` -온도 값은 일반적으로 0.0에서 1.0 사이입니다: +Temperature 값은 일반적으로 0.0~1.0 범위를 사용합니다. -- **0.0-0.2**: 매우 집중되고 신중한 응답, 코드 분석 및 계획에 이상적 -- **0.3-0.5**: 창의성과 정확성의 균형, 일반 개발 작업에 좋음 -- **0.6-1.0**: 더 창의적이고 다양한 응답, 브레인스토밍 및 탐색에 유용함 +- **0.0-0.2**: 매우 집중되고 결정적인 응답, 코드 분석/계획에 적합 +- **0.3-0.5**: 적당한 창의성이 섞인 균형형 응답, 일반 개발 작업에 적합 +- **0.6-1.0**: 더 창의적이고 다양한 응답, 브레인스토밍/탐색에 유용 ```json title="opencode.json" { @@ -276,15 +276,15 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m } ``` -온도가 지정되지 않은 경우, OpenCode는 모델별 기본값을 사용합니다. 일반적으로 대부분의 모델은 0, Qwen 모델의 경우 0.55입니다. +temperature를 지정하지 않으면 OpenCode는 model별 기본값을 사용합니다. 일반적으로 대부분의 model은 0, Qwen model은 0.55를 사용합니다. --- -## 최대 단계 +### Max steps -에이전트가 중지하고 사용자와 다시 상호 작용하기 전에 실행할 수 있는 최대 단계 수를 제어합니다. 이를 통해 에이전트의 행동과 비용을 제어할 수 있습니다. +agent가 텍스트 응답만 하도록 강제되기 전까지 수행할 수 있는 agentic iteration의 최대 횟수를 제어합니다. 비용을 관리하려는 사용자에게 agentic action 제한을 제공하기 위한 옵션입니다. -설정되지 않은 경우, 에이전트는 모델이 중지하거나 사용자가 세션을 중단할 때까지 계속됩니다. +이 값을 설정하지 않으면 model이 중단을 선택하거나 사용자가 세션을 중단할 때까지 agent는 계속 반복합니다. ```json title="opencode.json" { @@ -298,17 +298,17 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m } ``` -제한에 도달하면, 에이전트는 작업 요약과 권장되는 남은 작업을 신속하게 응답하도록 지시받습니다. +제한에 도달하면 agent는 작업 요약과 남은 권장 작업을 응답하도록 지시하는 특수 시스템 prompt를 받습니다. :::caution -레거시 `maxSteps` 필드는 더 이상 사용되지 않습니다. 대신 `steps`를 사용하십시오. +레거시 `maxSteps` 필드는 deprecated입니다. 대신 `steps`를 사용하세요. ::: --- -#### 비활성화 +### Disable -`true`로 설정하여 에이전트를 비활성화합니다. +`true`로 설정하면 agent를 비활성화합니다. ```json title="opencode.json" { @@ -322,9 +322,9 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m --- -#### 프롬프트 +### Prompt -`prompt` 구성으로 이 에이전트를 위한 사용자 정의 시스템 프롬프트 파일을 지정하십시오. 프롬프트 파일은 에이전트의 목적에 따른 지시를 포함해야 합니다. +`prompt` config로 해당 agent의 custom 시스템 prompt 파일을 지정합니다. prompt 파일에는 agent 목적에 맞는 지시사항을 작성하세요. ```json title="opencode.json" { @@ -336,16 +336,16 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m } ``` -이 경로는 구성 파일이 있는 위치를 기준으로 합니다. 따라서 글로벌 OpenCode 구성과 프로젝트별 구성 모두에서 작동합니다. +이 경로는 config 파일 위치 기준의 상대 경로입니다. 따라서 전역 OpenCode config와 프로젝트별 config 모두에서 동일하게 동작합니다. --- -### 모델 +### Model -`model` 구성을 사용하여 이 에이전트에 대한 모델을 재정의합니다. 다른 작업에 최적화된 다른 모델을 사용하는 데 유용합니다. 예를 들어, 계획을 위한 빠른 모델, 구현을 위한 더 강력한 모델 등입니다. +`model` config로 해당 agent의 model을 override할 수 있습니다. 작업 특성에 맞춰 model을 달리 쓸 때 유용합니다. 예를 들어 계획에는 더 빠른 model, 구현에는 더 강력한 model을 사용할 수 있습니다. :::tip -모델을 지정하지 않는 경우, 기본 에이전트는 [전역 구성 모델](/docs/config#models)을 사용하며, 서브 에이전트는 자신을 호출한 기본 에이전트의 모델을 사용합니다. +model을 지정하지 않으면 primary agent는 [전역으로 설정한 model](/docs/config#models)을 사용하고, subagent는 해당 subagent를 호출한 primary agent의 model을 사용합니다. ::: ```json title="opencode.json" @@ -358,13 +358,13 @@ markdown 파일 이름은 에이전트 이름입니다. 예를 들어, `review.m } ``` -OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다. 예를 들어, [OpenCode Zen](/docs/zen)을 사용한다면, GPT 5.1 Codex에 `opencode/gpt-5.1-codex`를 사용할 수 있습니다. +OpenCode config의 model ID는 `provider/model-id` 형식을 사용합니다. 예를 들어 [OpenCode Zen](/docs/zen)을 사용한다면 GPT 5.1 Codex에 `opencode/gpt-5.1-codex`를 사용합니다. --- -## 도구 +### Tools -`tools` 구성으로 이 에이전트가 사용할 수 있는 도구를 제어합니다. `true` 또는 `false`로 설정하여 특정 도구를 활성화하거나 비활성화할 수 있습니다. +`tools` config로 agent에서 사용할 tool을 제어합니다. 각 tool을 `true` 또는 `false`로 설정해 활성화/비활성화할 수 있습니다. ```json title="opencode.json" {3-6,9-12} { @@ -385,10 +385,10 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다. ``` :::note -에이전트별 구성은 글로벌 구성을 덮어씁니다. +agent별 config는 전역 config를 override합니다. ::: -한 번에 여러 도구를 제어하기 위해 와일드카드를 사용할 수 있습니다. 예를 들어, MCP 서버에서 모든 도구를 비활성화하려면: +와일드카드를 사용하면 여러 tool을 한 번에 제어할 수 있습니다. 예를 들어 MCP 서버의 모든 tool을 비활성화하려면 다음과 같이 설정합니다. ```json title="opencode.json" { @@ -405,17 +405,17 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다. } ``` -[도구에 대해 더 알아보기](/docs/tools). +[tool에 대해 더 알아보기](/docs/tools). --- -## 권한 +### Permissions -에이전트가 수행할 수 있는 작업을 관리할 수 있는 권한을 구성할 수 있습니다. 현재 `edit`, `bash` 및 `webfetch` 도구에 대한 권한은 다음과 같습니다. +permission을 설정해 agent가 수행할 수 있는 action을 제어할 수 있습니다. 현재 `edit`, `bash`, `webfetch` tool의 permission은 다음 값으로 설정할 수 있습니다. -- `"ask"` - 도구를 실행하기 전에 승인 요청 -- `"allow"` - 승인 없이 모든 작업 허용 -- `"deny"` - 도구 비활성화 +- `"ask"` — tool 실행 전에 승인 요청 +- `"allow"` — 승인 없이 모든 작업 허용 +- `"deny"` — tool 비활성화 ```json title="opencode.json" { @@ -426,7 +426,7 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다. } ``` -에이전트별로 이 권한을 재정의(override)할 수 있습니다. +이 permission은 agent별로 override할 수 있습니다. ```json title="opencode.json" {3-5,8-10} { @@ -444,7 +444,7 @@ OpenCode 구성의 모델 ID는 `provider/model-id` 형식을 사용합니다. } ``` -Markdown 에이전트에서도 권한을 설정할 수 있습니다. +Markdown agent에서도 permission을 설정할 수 있습니다. ```markdown title="~/.config/opencode/agents/review.md" --- @@ -463,7 +463,7 @@ permission: Only analyze code and suggest changes. ``` -특정 bash 명령에 대한 권한을 설정할 수 있습니다. +특정 bash 명령에 대해서도 permission을 설정할 수 있습니다. ```json title="opencode.json" {7} { @@ -481,7 +481,7 @@ Only analyze code and suggest changes. } ``` -이것은 glob 패턴을 사용할 수 있습니다. +여기에는 glob 패턴을 사용할 수 있습니다. ```json title="opencode.json" {7} { @@ -498,8 +498,8 @@ Only analyze code and suggest changes. } ``` -또한 `*` 와일드카드를 사용하여 모든 명령에 대한 권한을 관리할 수 있습니다. -마지막 일치 규칙이 우선하므로, `*` 와일드카드를 먼저 두고 특정 규칙을 나중에 두십시오. +또한 `*` 와일드카드로 모든 명령의 permission을 제어할 수 있습니다. +마지막으로 일치한 규칙이 우선하므로 `*` 와일드카드를 먼저 두고, 구체적인 규칙을 뒤에 두세요. ```json title="opencode.json" {8} { @@ -517,13 +517,13 @@ Only analyze code and suggest changes. } ``` -[권한에 대해 더 알아보기](/docs/permissions). +[permission에 대해 더 알아보기](/docs/permissions). --- -### 모드 +### Mode -`mode` 구성으로 에이전트 모드를 제어합니다. `mode` 옵션은 에이전트가 어떻게 사용될 수 있는지 결정하는 데 사용됩니다. +`mode` config로 agent 모드를 제어합니다. `mode` 옵션은 agent를 어떤 방식으로 사용할지 결정합니다. ```json title="opencode.json" { @@ -535,13 +535,13 @@ Only analyze code and suggest changes. } ``` -`mode` 옵션은 `primary`, `subagent`, 또는 `all`로 설정할 수 있습니다. `mode`가 지정되지 않은 경우 `all`이 기본값입니다. +`mode`는 `primary`, `subagent`, `all` 중 하나로 설정할 수 있습니다. 설정하지 않으면 기본값은 `all`입니다. --- -## 숨김 +### Hidden -`hidden: true`를 사용하여 `@` 자동 완성 메뉴에서 에이전트를 숨깁니다. 작업 도구를 통해 다른 에이전트에 의해 프로그래밍 방식으로 호출되어야 하는 내부 에이전트에 유용합니다. +`hidden: true`를 설정하면 `@` 자동완성 메뉴에서 subagent를 숨길 수 있습니다. 다른 agent가 Task tool을 통해 programmatic으로만 호출해야 하는 내부 subagent에 유용합니다. ```json title="opencode.json" { @@ -554,17 +554,17 @@ Only analyze code and suggest changes. } ``` -자동 완성 메뉴의 사용자 가시성에만 영향을 미칩니다. 숨겨진 에이전트는 권한이 허용된다면 여전히 작업 도구를 통해 모델에 의해 호출될 수 있습니다. +이 설정은 자동완성 메뉴에서의 사용자 가시성에만 영향을 줍니다. permission이 허용되면 hidden agent도 모델이 Task tool을 통해 호출할 수 있습니다. :::note -`mode: subagent` 에이전트에만 적용됩니다. +`mode: subagent` agent에만 적용됩니다. ::: --- -## 작업 권한 +### Task permissions -`permission.task`와 작업 도구를 통해 에이전트가 호출할 수 있는 서브 에이전트를 제어합니다. 유연한 일치를 위한 glob 패턴을 사용합니다. +`permission.task`로 Task tool을 통해 해당 agent가 호출할 수 있는 subagent 범위를 제어합니다. 유연한 매칭을 위해 glob 패턴을 사용합니다. ```json title="opencode.json" { @@ -583,23 +583,23 @@ Only analyze code and suggest changes. } ``` -`deny`로 설정할 때, 서브 에이전트는 작업 도구 설명에서 완전히 제거됩니다. 따라서 모델은 그것을 호출하려고 시도하지 않습니다. +`deny`로 설정되면 해당 subagent는 Task tool 설명에서 완전히 제거되므로 모델이 호출을 시도하지 않습니다. :::tip -규칙은 순서대로 평가되며, **마지막 일치 규칙**이 우선합니다. 위의 예에서 `orchestrator-planner`는 `*` (deny)와 `orchestrator-*` (allow) 모두 일치하지만 `orchestrator-*`가 `*` 뒤에 오므로 결과는 `allow`입니다. +규칙은 선언 순서대로 평가되며, **마지막으로 일치한 규칙이 승리합니다**. 위 예시에서 `orchestrator-planner`는 `*`(deny)와 `orchestrator-*`(allow) 모두에 일치하지만, `orchestrator-*`가 뒤에 있으므로 결과는 `allow`입니다. ::: :::tip -사용자는 에이전트의 작업 권한이 거부되더라도 `@` 자동 완성 메뉴를 통해 직접 어떤 서브 에이전트든 호출할 수 있습니다. +사용자는 agent의 task permission이 deny여도 `@` 자동완성 메뉴를 통해 어떤 subagent든 직접 호출할 수 있습니다. ::: --- -### 색상 +### Color -`color` 옵션으로 UI에서 에이전트의 시각적 모양을 사용자 정의합니다. +`color` 옵션으로 UI에서 agent의 시각 스타일을 지정할 수 있습니다. 인터페이스에서 agent가 표시되는 방식에 영향을 줍니다. -유효한 hex 색상(예: `#FF5733`) 또는 테마 색상을 사용하십시오: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`. +유효한 hex 색상(예: `#FF5733`) 또는 theme 색상(`primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`)을 사용하세요. ```json title="opencode.json" { @@ -618,7 +618,7 @@ Only analyze code and suggest changes. ### Top P -`top_p` 옵션으로 응답의 다양성을 제어합니다. 무작위성 제어를 위한 온도의 대안입니다. +`top_p` 옵션으로 응답 다양성을 제어합니다. 무작위성을 제어하는 Temperature의 대안입니다. ```json title="opencode.json" { @@ -630,15 +630,15 @@ Only analyze code and suggest changes. } ``` -값은 0.0에서 1.0 사이입니다. 낮은 값은 더 집중되고, 높은 값은 더 다양합니다. +값 범위는 0.0~1.0입니다. 값이 낮을수록 집중되고, 높을수록 다양해집니다. --- -### 추가 옵션 +### Additional -에이전트 구성에 지정하는 다른 옵션은 모델 옵션으로 공급자에게 **직접 전달**됩니다. 이를 통해 공급자별 기능 및 매개변수를 사용할 수 있습니다. +agent config에 지정한 나머지 옵션은 모델 옵션으로 provider에 **그대로 전달(pass through)** 됩니다. 이를 통해 provider별 기능과 파라미터를 활용할 수 있습니다. -예를 들어, OpenAI의 추론 모델과 함께, 추론 노력을 제어할 수 있습니다: +예를 들어 OpenAI reasoning model에서는 reasoning effort를 제어할 수 있습니다. ```json title="opencode.json" {6,7} { @@ -653,55 +653,55 @@ Only analyze code and suggest changes. } ``` -이 추가 옵션은 모델과 공급자별로 다릅니다. 사용 가능한 매개변수는 공급자의 문서를 확인하십시오. +이 추가 옵션은 model 및 provider마다 다릅니다. 사용 가능한 파라미터는 provider 문서를 확인하세요. :::tip -`opencode models`를 실행하여 사용 가능한 모델 목록을 볼 수 있습니다. +사용 가능한 model 목록은 `opencode models` 명령으로 확인할 수 있습니다. ::: --- -## 에이전트 만들기 +## 에이전트 생성 -다음 명령을 사용하여 새로운 에이전트를 만들 수 있습니다: +아래 명령으로 새 agent를 만들 수 있습니다. ```bash opencode agent create ``` -이 대화형 명령은: +이 인터랙티브 명령은 다음을 수행합니다. -1. 에이전트를 저장할 위치를 묻습니다 (전역 또는 프로젝트별). -2. 에이전트가 해야 할 일에 대한 설명을 묻습니다. -3. 적절한 시스템 프롬프트 및 식별자를 생성합니다. -4. 에이전트가 접근할 수 있는 도구를 선택하게 합니다. -5. 마지막으로, 에이전트 구성을 가진 markdown 파일을 생성합니다. +1. agent 저장 위치를 묻습니다(전역/프로젝트). +2. agent가 수행할 작업의 설명을 받습니다. +3. 적절한 시스템 prompt와 식별자를 생성합니다. +4. agent가 접근할 tool을 선택하게 합니다. +5. 마지막으로 agent config가 담긴 Markdown 파일을 생성합니다. --- ## 사용 사례 -다른 에이전트를 위한 몇 가지 일반적인 사용 사례는 다음과 같습니다. +서로 다른 agent의 대표적인 사용 사례는 다음과 같습니다. -- **Build Agent**: 모든 도구와 함께 전체 개발 작업 -- **Plan Agent**: 변경 없이 분석 및 계획 -- **Review Agent**: 읽기 전용 액세스 및 문서 도구와 함께 코드 리뷰 -- **Debug Agent**: bash 및 읽기 도구와 함께 조사에 집중 -- **Docs Agent**: 파일 작업과 문서 작성을 하지만 시스템 명령 없음 +- **Build agent**: 모든 tool을 활성화한 전체 개발 작업 +- **Plan agent**: 코드 변경 없이 분석과 계획 수행 +- **Review agent**: 읽기 전용 접근 + 문서화 tool 기반 코드 리뷰 +- **Debug agent**: bash/read tool 중심의 조사 작업 +- **Docs agent**: 파일 작업은 가능하지만 시스템 명령은 없는 문서 작성 작업 --- -## 예제 +## 예시 -여기에 유용 할 수있는 몇 가지 예 에이전트가 있습니다. +실제로 유용하게 쓸 수 있는 예시 agent를 소개합니다. :::tip -공유하고 싶은 에이전트가 있습니까? [PR](https://github.com/anomalyco/opencode). +공유하고 싶은 agent가 있나요? [PR 제출하기](https://github.com/anomalyco/opencode). ::: --- -### 문서 에이전트 +### Documentation agent ```markdown title="~/.config/opencode/agents/docs-writer.md" --- @@ -723,7 +723,7 @@ Focus on: --- -## 보안 감사 +### Security auditor ```markdown title="~/.config/opencode/agents/security-auditor.md" --- diff --git a/packages/web/src/content/docs/ko/config.mdx b/packages/web/src/content/docs/ko/config.mdx index 0357ded785..e906eaf47b 100644 --- a/packages/web/src/content/docs/ko/config.mdx +++ b/packages/web/src/content/docs/ko/config.mdx @@ -1,15 +1,15 @@ --- -title: 구성 -description: OpenCode JSON 구성을 사용합니다. +title: Config +description: Using the OpenCode JSON config. --- -JSON 구성 파일을 사용하여 OpenCode를 구성할 수 있습니다. +JSON config 파일로 OpenCode를 설정할 수 있습니다. --- ## 형식 -OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합니다. +OpenCode는 **JSON**과 **JSONC**(주석이 포함된 JSON) 형식을 모두 지원합니다. ```jsonc title="opencode.jsonc" { @@ -25,44 +25,44 @@ OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합 ## 위치 -구성을 여러 위치에 배치할 수 있으며, 이들은 서로 다른 우선 순위(precedence)를 가집니다. +config 파일은 여러 위치에 둘 수 있으며, 각 위치에는 우선순위가 있습니다. :::note -구성 파일은 **병합**되며, 대체되지 않습니다. +config 파일은 **교체되지 않고 병합**됩니다. ::: -구성 파일은 함께 병합되며 대체되지 않습니다. 다음 구성 위치의 설정이 결합됩니다. 나중의 구성은 충돌하는 키에 대해 이전 구성을 덮어씁니다. 모든 구성의 설정이 보존됩니다. +config 파일은 서로 대체되는 방식이 아니라 병합됩니다. 아래 config 위치의 설정이 결합되며, 충돌하는 key에 대해서만 나중에 로드된 config가 앞선 값을 override합니다. 충돌하지 않는 설정은 모두 유지됩니다. -예를 들어, 전역 구성이 `theme: "opencode"` 및 `autoupdate: true`를 설정하고 프로젝트 구성이 `model: "anthropic/claude-sonnet-4-5"`를 설정하면 최종 구성에는 세 가지 설정이 모두 포함됩니다. +예를 들어, 전역 config에 `theme: "opencode"`와 `autoupdate: true`가 있고 프로젝트 config에 `model: "anthropic/claude-sonnet-4-5"`가 있으면 최종 config에는 이 세 설정이 모두 포함됩니다. --- -### 우선 순위 +### 우선순위 -구성 소스는 다음 순서로 로드됩니다 (나중 소스가 이전 소스를 덮어씀): +config source는 다음 순서로 로드됩니다(나중 source가 앞선 source를 override). -1. **원격 구성** (`.well-known/opencode`에서) - 조직 기본값 -2. **전역 구성** (`~/.config/opencode/opencode.json`) - 사용자 환경설정 -3. **사용자 정의 구성** (`OPENCODE_CONFIG` 환경 변수) - 사용자 정의 재정의 -4. **프로젝트별 구성** (`opencode.json`) - 프로젝트별 설정 -5. **`.opencode` 디렉토리** - 에이전트, 명령, 플러그인 -6. **인라인 구성** (`OPENCODE_CONFIG_CONTENT` 환경 변수) - 런타임 재정의 +1. **Remote config**(`.well-known/opencode`) - 조직 기본값 +2. **Global config**(`~/.config/opencode/opencode.json`) - 사용자 기본 설정 +3. **Custom config**(`OPENCODE_CONFIG` env var) - custom override +4. **Project config**(프로젝트의 `opencode.json`) - 프로젝트별 설정 +5. **`.opencode` directories** - agents, commands, plugins +6. **Inline config**(`OPENCODE_CONFIG_CONTENT` env var) - 런타임 override -이것은 프로젝트 구성이 전역 기본값을 덮어쓸 수 있고, 전역 구성이 원격 조직 기본값을 덮어쓸 수 있음을 의미합니다. +즉, 프로젝트 config는 전역 기본값을 override할 수 있고, 전역 config는 조직의 Remote 기본값을 override할 수 있습니다. :::note -`.opencode`와 `~/.config/opencode` 디렉토리는 하위 디렉토리에 대해 **복수형 이름**을 사용합니다: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, 그리고 `themes/`. 단수형 이름(예: `agent/`)도 하위 호환성을 위해 지원됩니다. +`.opencode` 및 `~/.config/opencode` 디렉토리는 하위 디렉토리에 **복수형 이름**을 사용합니다: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, `themes/`. 단수형 이름(예: `agent/`)도 하위 호환성을 위해 지원합니다. ::: --- -### 원격 +### Remote -조직은 `.well-known/opencode` 엔드포인트를 통해 기본 구성을 제공할 수 있습니다. 이를 지원하는 공급자로 인증할 때 자동으로 가져옵니다. +조직은 `.well-known/opencode` endpoint로 기본 config를 제공할 수 있습니다. 이를 지원하는 provider로 인증하면 자동으로 가져옵니다. -원격 구성은 기본 레이어로 가장 먼저 로드됩니다. 다른 구성 소스(전역, 프로젝트)는 이러한 기본값을 무시(override)할 수 있습니다. +Remote config는 가장 먼저 로드되어 기본 레이어 역할을 합니다. 이후의 모든 config source(전역, 프로젝트)는 이 기본값을 override할 수 있습니다. -예를 들어, 조직이 기본적으로 비활성화된 MCP 서버를 제공하는 경우: +예를 들어, 조직에서 기본 비활성화된 MCP 서버를 제공하는 경우: ```json title="Remote config from .well-known/opencode" { @@ -76,7 +76,7 @@ OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합 } ``` -로컬 설정에서 특정 서버를 활성화할 수 있습니다: +로컬 config에서 특정 서버를 활성화할 수 있습니다. ```json title="opencode.json" { @@ -92,65 +92,65 @@ OpenCode는 **JSON** 및 **JSONC** (주석이 있는 JSON) 형식을 지원합 --- -## 전역 +### Global -`~/.config/opencode/opencode.json`에 전역 OpenCode 구성을 배치합니다. 테마, 공급자, 키바인드와 같은 사용자 전체 기본 설정에 전역 구성을 사용하십시오. +전역 OpenCode config는 `~/.config/opencode/opencode.json`에 두세요. theme, provider, keybind 같은 사용자 전체 기본 설정은 전역 config로 관리하세요. -전역 구성은 원격 조직 기본값을 덮어씁니다. +전역 config는 조직의 Remote 기본값을 override합니다. --- -## 프로젝트별 +### Per project -프로젝트 루트에 `opencode.json`을 추가합니다. 프로젝트 구성은 표준 구성 파일 중 가장 높은 우선순위를 가집니다. 이는 전역 및 원격 구성을 모두 덮어씁니다. +프로젝트 루트에 `opencode.json`을 추가하세요. 프로젝트 config는 표준 config 파일 중 우선순위가 가장 높아 전역 및 Remote config를 모두 override합니다. :::tip -프로젝트의 루트에 특정 설정을 둡니다. +프로젝트별 config는 프로젝트 루트에 두세요. ::: -OpenCode가 시작될 때, 현재 디렉토리의 설정 파일이나 가장 가까운 Git 디렉토리를 찾습니다. +OpenCode 시작 시 현재 디렉토리에서 config 파일을 찾고, 없으면 가장 가까운 Git 디렉토리까지 상위로 탐색합니다. -이것은 Git으로 관리되며 전역 구성과 동일한 스키마를 사용합니다. +이 파일은 Git에 커밋해도 안전하며 전역 config와 동일한 schema를 사용합니다. --- -### 사용자 정의 경로 +### Custom path -`OPENCODE_CONFIG` 환경 변수를 사용하여 사용자 정의 구성 파일 경로를 지정합니다. +`OPENCODE_CONFIG` 환경 변수로 custom config 파일 경로를 지정하세요. ```bash export OPENCODE_CONFIG=/path/to/my/custom-config.json opencode run "Hello world" ``` -사용자 정의 구성은 우선 순위에서 전역 구성과 프로젝트 구성 사이에 로드됩니다. +Custom config는 우선순위상 전역 config와 프로젝트 config 사이에서 로드됩니다. --- -## 사용자 정의 디렉토리 +### Custom directory -`OPENCODE_CONFIG_DIR` 환경 변수를 사용하여 사용자 정의 구성 디렉토리를 지정할 수 있습니다. 이 디렉토리는 표준 `.opencode` 디렉토리와 마찬가지로 에이전트, 명령, 모드 및 플러그인을 검색하며 동일한 구조를 따라야 합니다. +`OPENCODE_CONFIG_DIR` 환경 변수로 custom config 디렉토리를 지정할 수 있습니다. 이 디렉토리는 표준 `.opencode` 디렉토리와 동일하게 agents, commands, modes, plugins를 검색하며, 동일한 구조를 따라야 합니다. ```bash export OPENCODE_CONFIG_DIR=/path/to/my/config-directory opencode run "Hello world" ``` -사용자 정의 디렉토리는 전역 구성 이후 및 `.opencode` 디렉토리 이전에 로드됩니다. +custom 디렉토리는 전역 config와 `.opencode` 디렉토리 뒤에 로드되므로 해당 설정을 **override할 수 있습니다**. --- -## 스키마 +## Schema -구성 파일에는 [**`opencode.ai/config.json`**](https://opencode.ai/config.json)에 정의된 스키마가 있습니다. +config 파일의 schema는 [**`opencode.ai/config.json`**](https://opencode.ai/config.json)에 정의되어 있습니다. -편집기는 스키마에 따라 유효성 검사 및 자동 완성을 제공해야 합니다. +편집기에서 이 schema를 기반으로 validation과 autocomplete를 사용할 수 있습니다. --- -#### TUI +### TUI -`tui` 옵션을 통해 TUI 관련 설정을 구성할 수 있습니다. +`tui` 옵션으로 TUI 관련 설정을 구성할 수 있습니다. ```json title="opencode.json" { @@ -165,19 +165,19 @@ opencode run "Hello world" } ``` -유효한 옵션: +사용 가능한 옵션: - `scroll_acceleration.enabled` - macOS 스타일 스크롤 가속을 활성화합니다. **`scroll_speed`보다 우선합니다.** -- `scroll_speed` - 사용자 정의 스크롤 속도 배수 (기본값: `3`, 최소값: `1`). `scroll_acceleration.enabled`가 `true`이면 무시됩니다. -- `diff_style` - diff 렌더링을 제어합니다. `"auto"`는 터미널 너비에 맞추고, `"stacked"`는 항상 단일 열을 보여줍니다. +- `scroll_speed` - 사용자 정의 스크롤 속도 배수(기본: `3`, 최소: `1`). `scroll_acceleration.enabled`가 `true`이면 무시됩니다. +- `diff_style` - diff 렌더링 방식을 제어합니다. `"auto"`는 터미널 너비에 맞춰 조정되고, `"stacked"`는 항상 단일 컬럼으로 표시합니다. -[TUI 사용법에 대해 더 알아보기](/docs/tui). +[TUI에 대해 더 알아보기](/docs/tui). --- -## 서버 +### Server -`opencode serve` 및 `opencode web` 명령에 대한 서버 설정을 구성할 수 있습니다. +`server` 옵션으로 `opencode serve`와 `opencode web` 명령의 server 설정을 구성할 수 있습니다. ```json title="opencode.json" { @@ -192,21 +192,21 @@ opencode run "Hello world" } ``` -유효한 옵션: +사용 가능한 옵션: -- `port` - 리스닝 포트. -- `hostname` - 리스닝 호스트 이름. `mdns`가 활성화되고 hostname이 설정되지 않으면 `0.0.0.0`이 기본값이 됩니다. -- `mdns` - mDNS 서비스 발견 활성화. 로컬 네트워크의 다른 장치가 OpenCode 서버를 찾을 수 있습니다. -- `mdnsDomain` - mDNS 서비스를 위한 사용자 정의 도메인 이름. 기본값은 `opencode.local`입니다. 동일한 네트워크에서 여러 인스턴스를 실행할 때 유용합니다. -- `cors` - 브라우저 기반 클라이언트에서 HTTP 서버를 사용할 때 CORS를 허용할 추가 출처(Origin). 값은 전체 출처(스킴 + 호스트 + 선택적 포트)여야 합니다. 예: `https://app.example.com`. +- `port` - 수신할 포트입니다. +- `hostname` - 수신할 호스트명입니다. `mdns`가 활성화되어 있고 hostname이 없으면 기본값은 `0.0.0.0`입니다. +- `mdns` - mDNS service discovery를 활성화합니다. 네트워크 내 다른 기기에서 OpenCode server를 찾을 수 있습니다. +- `mdnsDomain` - mDNS service의 custom 도메인 이름입니다. 기본값은 `opencode.local`입니다. 같은 네트워크에서 여러 인스턴스를 실행할 때 유용합니다. +- `cors` - 브라우저 기반 client에서 HTTP server를 사용할 때 허용할 추가 CORS origin입니다. 값은 전체 origin(scheme + host + optional port) 형식이어야 하며, 예: `https://app.example.com`. -[서버에 대해 더 알아보기](/docs/server). +[server에 대해 더 알아보기](/docs/server). --- -## 도구 +### Tools -`tools` 옵션을 통해 LLM이 사용할 수 있는 도구를 구성할 수 있습니다. +`tools` 옵션으로 LLM이 사용할 수 있는 tool을 관리할 수 있습니다. ```json title="opencode.json" { @@ -218,13 +218,13 @@ opencode run "Hello world" } ``` -[도구에 대해 더 알아보기](/docs/tools). +[tool에 대해 더 알아보기](/docs/tools). --- -## 모델 +### Models -`provider`, `model`, `small_model` 옵션을 통해 OpenCode 구성에서 사용할 공급자와 모델을 구성할 수 있습니다. +OpenCode config의 `provider`, `model`, `small_model` 옵션으로 사용할 provider와 model을 설정할 수 있습니다. ```json title="opencode.json" { @@ -235,9 +235,9 @@ opencode run "Hello world" } ``` -`small_model` 옵션은 제목 생성과 같은 가벼운 작업을 위한 별도의 모델을 구성합니다. 기본적으로, OpenCode는 공급자에게서 사용 가능한 더 저렴한 모델이 있다면 그것을 사용하고, 그렇지 않으면 주 모델로 돌아갑니다. +`small_model`은 제목 생성 같은 경량 작업에 사용할 별도 model을 설정합니다. 기본적으로 OpenCode는 provider에서 더 저렴한 model을 사용할 수 있으면 우선 사용하고, 없으면 메인 model로 fallback합니다. -공급자 옵션은 `timeout`과 `setCacheKey`를 포함할 수 있습니다: +provider 옵션에는 `timeout`, `setCacheKey`를 포함할 수 있습니다. ```json title="opencode.json" { @@ -253,20 +253,20 @@ opencode run "Hello world" } ``` -- `timeout` - 요청 타임아웃(밀리초) (기본값: 300000). `false`로 설정하여 비활성화할 수 있습니다. -- `setCacheKey` - 지정된 공급자에 대해 캐시 키가 항상 설정되도록 강제합니다. +- `timeout` - 요청 timeout(밀리초, 기본값: 300000). `false`로 비활성화할 수 있습니다. +- `setCacheKey` - 지정된 provider에 대해 cache key가 항상 설정되도록 보장합니다. -[로컬 모델](/docs/models#local)을 구성할 수도 있습니다. [더 알아보기](/docs/models). +[local model](/docs/models#local)도 설정할 수 있습니다. [더 알아보기](/docs/models). --- -### 공급자별 옵션 +#### Provider-Specific Options -일반적인 `timeout` 및 `apiKey` 외에도 일부 공급자는 추가 구성 옵션을 지원합니다. +일부 provider는 공통 옵션인 `timeout`, `apiKey` 외에 추가 config 옵션을 지원합니다. ##### Amazon Bedrock -Amazon Bedrock는 AWS 관련 구성을 지원합니다: +Amazon Bedrock은 AWS 전용 config를 지원합니다. ```json title="opencode.json" { @@ -283,21 +283,21 @@ Amazon Bedrock는 AWS 관련 구성을 지원합니다: } ``` -- `region` - Bedrock를 위한 AWS 리전 (`AWS_REGION` 환경 변수 또는 `us-east-1`이 기본값) -- `profile` - `~/.aws/credentials`의 AWS 프로필 이름 (`AWS_PROFILE` 환경 변수가 기본값) -- `endpoint` - VPC 엔드포인트 등을 위한 사용자 정의 엔드포인트 URL. 이는 AWS 관련 용어를 사용한 일반적인 `baseURL` 옵션의 별칭입니다. 둘 다 지정된 경우 `endpoint`가 우선합니다. +- `region` - Bedrock용 AWS 리전(`AWS_REGION` env var 또는 기본값 `us-east-1`) +- `profile` - `~/.aws/credentials`의 AWS named profile(기본값: `AWS_PROFILE` env var) +- `endpoint` - VPC endpoint용 custom endpoint URL입니다. AWS 용어를 사용한 일반 `baseURL` 옵션의 별칭(alias)입니다. 둘 다 지정하면 `endpoint`가 우선합니다. :::note -Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 인증보다 우선합니다. 자세한 내용은 [인증 우선 순위](/docs/providers#authentication-precedence)를 참조하십시오. +Bearer token(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 profile 기반 인증보다 우선합니다. 자세한 내용은 [authentication precedence](/docs/providers#authentication-precedence)를 참고하세요. ::: -[Amazon Bedrock에 대해 더 알아보기](/docs/providers#amazon-bedrock). +[Amazon Bedrock config 더 알아보기](/docs/providers#amazon-bedrock). --- -## 테마 +### Themes -`theme` 옵션을 통해 OpenCode 구성에서 사용할 테마를 설정할 수 있습니다. +`theme` 옵션으로 OpenCode config에서 사용할 theme를 설정할 수 있습니다. ```json title="opencode.json" { @@ -310,9 +310,9 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 --- -## 에이전트 +### Agents -`agent` 옵션을 통해 특정 작업을 전문으로 하는 에이전트를 구성할 수 있습니다. +`agent` 옵션으로 특정 작업용 전문 agent를 구성할 수 있습니다. ```jsonc title="opencode.jsonc" { @@ -332,13 +332,13 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 } ``` -`~/.config/opencode/agents/` 또는 `.opencode/agents/`에서 Markdown 파일을 사용하여 에이전트를 정의할 수도 있습니다. [더 알아보기](/docs/agents). +`~/.config/opencode/agents/` 또는 `.opencode/agents/`의 Markdown 파일로 agent를 정의할 수도 있습니다. [더 알아보기](/docs/agents). --- -### 기본 에이전트 +### Default agent -`default_agent` 옵션을 사용하여 기본 에이전트를 설정할 수 있습니다. 명시적으로 지정되지 않았을 때 어떤 에이전트가 사용될지 결정합니다. +`default_agent` 옵션으로 기본 agent를 설정할 수 있습니다. 별도 지정이 없을 때 어떤 agent를 사용할지 결정합니다. ```json title="opencode.json" { @@ -347,15 +347,15 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 } ``` -기본 에이전트는 기본(primary) 에이전트여야 합니다(서브 에이전트 불가). `"build"` 또는 `"plan"`과 같은 내장 에이전트이거나 정의된 [사용자 정의 에이전트](./agents)일 수 있습니다. 지정된 에이전트가 존재하지 않는 경우, OpenCode는 경고와 함께 `"build"`로 돌아갑니다. +기본 agent는 primary agent여야 합니다(subagent 불가). `"build"`, `"plan"` 같은 내장 agent나 직접 정의한 [custom agent](/docs/agents)를 지정할 수 있습니다. 지정한 agent가 없거나 subagent이면 OpenCode는 경고와 함께 `"build"`로 fallback합니다. -이 설정은 모든 인터페이스에 적용됩니다: TUI, CLI (`opencode run`), 데스크톱 앱 및 GitHub Action. +이 설정은 TUI, CLI(`opencode run`), 데스크톱 앱, GitHub Action 등 모든 인터페이스에 적용됩니다. --- -## 공유 +### Sharing -`share` 옵션을 통해 [공유](/docs/share) 기능을 구성할 수 있습니다. +`share` 옵션으로 [share](/docs/share) 기능을 설정할 수 있습니다. ```json title="opencode.json" { @@ -364,19 +364,19 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 } ``` -값은 다음과 같습니다: +허용 값: -- `"manual"` - 명령을 통한 수동 공유 허용 (기본값) -- `"auto"` - 새로운 대화를 자동으로 공유 -- `"disabled"` - 공유 기능 완전히 비활성화 +- `"manual"` - 명령으로 수동 공유 허용(기본값) +- `"auto"` - 새 대화를 자동 공유 +- `"disabled"` - 공유 기능 완전 비활성화 -기본적으로 `/share` 명령을 사용하여 대화를 명시적으로 공유해야 하는 수동 모드로 설정됩니다. +기본값은 manual 모드이며, `/share` 명령으로 명시적으로 공유해야 합니다. --- -## 명령 +### Commands -`command` 옵션을 통해 반복 작업을 위한 사용자 정의 명령을 구성할 수 있습니다. +`command` 옵션으로 반복 작업용 custom command를 구성할 수 있습니다. ```jsonc title="opencode.jsonc" { @@ -387,8 +387,6 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 "description": "Run tests with coverage", "agent": "build", "model": "anthropic/claude-haiku-4-5", - "agent": "build", - "model": "anthropic/claude-haiku-4-5", }, "component": { "template": "Create a new React component named $ARGUMENTS with TypeScript support.\nInclude proper typing and basic structure.", @@ -398,13 +396,13 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 } ``` -`~/.config/opencode/commands/` 또는 `.opencode/commands/`에서 Markdown 파일을 사용하여 명령을 정의할 수도 있습니다. [더 알아보기](/docs/commands). +`~/.config/opencode/commands/` 또는 `.opencode/commands/`의 Markdown 파일로 command를 정의할 수도 있습니다. [더 알아보기](/docs/commands). --- -## 키바인드 +### Keybinds -`keybinds` 옵션을 통해 키바인드를 사용자 정의할 수 있습니다. +`keybinds` 옵션으로 keybind를 커스터마이즈할 수 있습니다. ```json title="opencode.json" { @@ -417,9 +415,9 @@ Bearer 토큰(`AWS_BEARER_TOKEN_BEDROCK` 또는 `/connect`)은 프로필 기반 --- -## 자동 업데이트 +### Autoupdate -OpenCode는 시작될 때 자동으로 새로운 업데이트를 다운로드합니다. `autoupdate` 옵션으로 이를 비활성화할 수 있습니다. +OpenCode는 시작 시 새 업데이트를 자동으로 다운로드합니다. `autoupdate` 옵션으로 비활성화할 수 있습니다. ```json title="opencode.json" { @@ -428,14 +426,14 @@ OpenCode는 시작될 때 자동으로 새로운 업데이트를 다운로드합 } ``` -업데이트를 원하지 않지만 새 버전을 알림받고 싶다면 `autoupdate`를 `"notify"`로 설정하십시오. -Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경우에만 작동합니다. +업데이트를 자동 적용하지 않고 새 버전 알림만 받고 싶다면 `autoupdate`를 `"notify"`로 설정하세요. +이 옵션은 Homebrew 같은 패키지 매니저로 설치하지 않은 경우에만 동작합니다. --- -## 포매터 +### Formatters -`formatter` 옵션을 통해 코드 포매터를 구성할 수 있습니다. +`formatter` 옵션으로 코드 formatter를 설정할 수 있습니다. ```json title="opencode.json" { @@ -455,15 +453,15 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 } ``` -[포매터에 대해 더 알아보기](/docs/formatters). +[formatter에 대해 더 알아보기](/docs/formatters). --- -## 권한 +### Permissions -기본적으로, OpenCode는 **명시적 승인 없이 모든 작업을 허용**합니다. `permission` 옵션을 사용하여 이를 변경할 수 있습니다. +기본적으로 OpenCode는 **명시적 승인 없이 모든 작업을 허용**합니다. `permission` 옵션으로 이 동작을 바꿀 수 있습니다. -예를 들어, `edit` 및 `bash` 도구가 사용자 승인을 요구하도록 설정하려면: +예를 들어 `edit`, `bash` tool이 사용자 승인을 요구하게 하려면: ```json title="opencode.json" { @@ -475,32 +473,34 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 } ``` -[권한에 대해 더 알아보기](/docs/permissions). +[permission에 대해 더 알아보기](/docs/permissions). --- -### 압축 +### Compaction -`compaction` 옵션을 통해 컨텍스트 압축 동작을 제어할 수 있습니다. +`compaction` 옵션으로 context compaction 동작을 제어할 수 있습니다. ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", "compaction": { "auto": true, - "prune": true + "prune": true, + "reserved": 10000 } } ``` -- `auto` - 컨텍스트가 꽉 차면 자동으로 세션을 압축합니다 (기본값: `true`). -- `prune` - 토큰을 절약하기 위해 오래된 도구 출력을 제거합니다 (기본값: `true`). +- `auto` - context가 가득 찼을 때 세션을 자동 compact합니다(기본값: `true`). +- `prune` - token 절약을 위해 오래된 tool 출력을 제거합니다(기본값: `true`). +- `reserved` - compaction용 token buffer입니다. compaction 중 overflow가 나지 않도록 충분한 window를 남깁니다. --- -### 파일 감시자 +### Watcher -`watcher` 옵션을 통해 파일 감시자가 무시할 패턴을 설정할 수 있습니다. +`watcher` 옵션으로 파일 watcher ignore 패턴을 설정할 수 있습니다. ```json title="opencode.json" { @@ -511,13 +511,13 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 } ``` -패턴은 glob 구문을 따릅니다. 잡음이 많은 디렉토리를 제외하는 데 사용하십시오. +패턴은 glob 문법을 따릅니다. 파일 감시에서 노이즈가 많은 디렉토리를 제외할 때 유용합니다. --- -### MCP 서버 +### MCP servers -`mcp` 옵션을 통해 사용하려는 MCP 서버를 구성할 수 있습니다. +`mcp` 옵션으로 사용할 MCP server를 설정할 수 있습니다. ```json title="opencode.json" { @@ -530,11 +530,11 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 --- -### 플러그인 +### Plugins -[플러그인](/docs/plugins)은 사용자 정의 도구, 훅(hook), 통합으로 OpenCode를 확장합니다. +[Plugins](/docs/plugins)는 custom tool, hook, integration으로 OpenCode를 확장합니다. -`.opencode/plugins/` 또는 `~/.config/opencode/plugins/`에 플러그인 파일을 배치하십시오. `plugin` 옵션을 통해 npm에서 플러그인을 로드할 수 있습니다. +plugin 파일은 `.opencode/plugins/` 또는 `~/.config/opencode/plugins/`에 두세요. `plugin` 옵션으로 npm plugin을 로드할 수도 있습니다. ```json title="opencode.json" { @@ -547,9 +547,9 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 --- -### 지침 +### Instructions -`instructions` 옵션을 통해 모델에 대한 지침(Rules)을 구성할 수 있습니다. +`instructions` 옵션으로 사용 중인 model에 제공할 지침 파일을 설정할 수 있습니다. ```json title="opencode.json" { @@ -558,13 +558,13 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 } ``` -지침 파일에 대한 경로와 glob 패턴의 배열을 사용합니다. [규칙에 대해 더 알아보기](/docs/rules). +이 옵션은 지침 파일 경로 및 glob 패턴 배열을 받습니다. [rules에 대해 더 알아보기](/docs/rules). --- -## 비활성화된 공급자 +### Disabled providers -`disabled_providers` 옵션을 통해 자동으로 로드되는 공급자를 비활성화할 수 있습니다. 자격 증명이 유효하더라도 특정 공급자가 로드되는 것을 방지할 때 유용합니다. +`disabled_providers` 옵션으로 자동 로드되는 provider를 비활성화할 수 있습니다. credential이 있어도 특정 provider를 로드하지 않게 하고 싶을 때 유용합니다. ```json title="opencode.json" { @@ -577,17 +577,17 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 `disabled_providers`는 `enabled_providers`보다 우선합니다. ::: -`disabled_providers` 옵션은 공급자 ID의 배열을 허용합니다. 공급자가 비활성화되면: +`disabled_providers`는 provider ID 배열을 받습니다. provider가 비활성화되면: -- 환경 변수가 설정된 경우에도 로드되지 않습니다. -- API 키가 `/connect` 명령을 통해 구성되는 경우에도 로드되지 않습니다. -- 공급자의 모델은 모델 선택 목록에 표시되지 않습니다. +- 환경 변수가 설정되어 있어도 로드되지 않습니다. +- `/connect` 명령으로 API key를 설정해도 로드되지 않습니다. +- 해당 provider의 model은 model 선택 목록에 표시되지 않습니다. --- -### 활성화된 공급자 +### Enabled providers -`enabled_providers` 옵션을 통해 허용할 공급자를 지정할 수 있습니다. 설정하면 지정된 공급자만 활성화되고 다른 모든 공급자는 무시됩니다. +`enabled_providers` 옵션으로 provider allowlist를 지정할 수 있습니다. 이 값을 설정하면 지정한 provider만 활성화되고 나머지는 무시됩니다. ```json title="opencode.json" { @@ -596,19 +596,19 @@ Homebrew와 같은 패키지 관리자를 사용하여 설치되지 않은 경 } ``` -OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합니다. +provider를 하나씩 비활성화하는 대신, OpenCode가 특정 provider만 사용하도록 제한하고 싶을 때 유용합니다. :::note `disabled_providers`는 `enabled_providers`보다 우선합니다. ::: -공급자가 `enabled_providers`와 `disabled_providers` 둘 다에 나타나면, 하위 호환성을 위해 `disabled_providers`가 우선합니다. +동일 provider가 `enabled_providers`와 `disabled_providers`에 모두 있으면 하위 호환성을 위해 `disabled_providers`가 우선합니다. --- -### 실험적 기능 +### Experimental -`experimental` 키는 활발히 개발 중인 옵션을 포함합니다. +`experimental` key에는 현재 활발히 개발 중인 옵션이 포함됩니다. ```json title="opencode.json" { @@ -618,20 +618,20 @@ OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합 ``` :::caution -실험적 옵션은 안정적이지 않습니다. 예고 없이 변경되거나 제거될 수 있습니다. +experimental 옵션은 안정적이지 않습니다. 예고 없이 변경되거나 제거될 수 있습니다. ::: --- -## 변수 +## Variables -구성 파일에서 환경 변수를 참조하고 파일 내용에 대한 변수 대체를 사용할 수 있습니다. +config 파일에서 환경 변수와 파일 내용을 참조할 수 있도록 변수 치환을 사용할 수 있습니다. --- -##### 환경 변수 +### Env vars -`{env:VARIABLE_NAME}`을 사용하여 환경 변수를 대체합니다. +`{env:VARIABLE_NAME}` 형식으로 환경 변수를 치환할 수 있습니다. ```json title="opencode.json" { @@ -648,13 +648,13 @@ OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합 } ``` -환경 변수가 설정되지 않으면 빈 문자열로 대체됩니다. +환경 변수가 설정되지 않았으면 빈 문자열로 치환됩니다. --- -## 파일 +### Files -`{file:path/to/file}`를 사용하여 파일의 내용을 대체합니다. +`{file:path/to/file}` 형식으로 파일 내용을 치환할 수 있습니다. ```json title="opencode.json" { @@ -670,13 +670,13 @@ OpenCode를 제한하여 특정 공급자만 사용하도록 할 때 유용합 } ``` -파일 경로는: +파일 경로는 다음을 지원합니다. -- 구성 파일 디렉토리에 상대적이거나 -- `/` 또는 `~`로 시작하는 절대 경로여야 합니다. +- config 파일 디렉토리 기준 상대 경로 +- `/` 또는 `~`로 시작하는 절대 경로 -이것은 다음에 유용합니다: +이 기능은 다음 상황에 유용합니다. -- API 키와 같은 민감한 데이터를 별도의 파일에 유지할 때. -- 구성을 어지럽히지 않고 큰 지침 파일을 포함할 때. -- 여러 구성 파일에서 공통 구성 스니펫을 공유할 때. +- API key 같은 민감 정보를 별도 파일로 분리 +- 큰 지침 파일을 config를 복잡하게 만들지 않고 포함 +- 여러 config 파일에서 공통 설정 스니펫 재사용 diff --git a/packages/web/src/content/docs/ko/custom-tools.mdx b/packages/web/src/content/docs/ko/custom-tools.mdx index 5da050f49b..77310557fa 100644 --- a/packages/web/src/content/docs/ko/custom-tools.mdx +++ b/packages/web/src/content/docs/ko/custom-tools.mdx @@ -1,30 +1,30 @@ --- -title: 사용자 정의 도구 -description: OpenCode에서 LLM이 호출할 수 있는 도구를 만듭니다. +title: Custom Tools +description: Create tools the LLM can call in OpenCode. --- -사용자 정의 도구는 LLM이 대화 중에 호출 할 수있는 기능을 만듭니다. 그들은 `read`, `write` 및 `bash`와 같은 opencode의 [붙박이 도구](./tools)와 함께 작동합니다. +custom tool은 대화 중 LLM이 호출할 수 있도록 사용자가 직접 만든 함수입니다. `read`, `write`, `bash` 같은 OpenCode의 [built-in tools](/docs/tools)와 함께 동작합니다. --- ## 도구 만들기 -도구는 **TypeScript** 또는 **JavaScript** 파일로 정의됩니다. 그러나 도구 정의는 ** 어떤 언어로 작성된 스크립트를 호출 할 수 있습니다 ** - TypeScript 또는 JavaScript는 도구 정의 자체에서만 사용됩니다. +tool은 **TypeScript** 또는 **JavaScript** 파일로 정의합니다. 다만 tool 정의에서 호출하는 스크립트는 **어떤 언어든** 사용할 수 있습니다. 즉, TypeScript/JavaScript는 tool 정의 자체에만 필요합니다. --- -## 위치 +### 위치 -그들은 정의 할 수 있습니다: +tool은 다음 위치에 둘 수 있습니다. -- 프로젝트의 `.opencode/tools/` 디렉토리에 배치하여 로컬. -- 또는 전 세계적으로 `~/.config/opencode/tools/`에 배치하여. +- 프로젝트의 `.opencode/tools/` 디렉토리(로컬) +- `~/.config/opencode/tools/` 디렉토리(전역) --- -## 구조 +### 구조 -도구를 만드는 가장 쉬운 방법은 `tool()` helper를 사용하여 유형 안전 및 검증을 제공합니다. +tool을 가장 쉽게 만드는 방법은 타입 안정성과 validation을 제공하는 `tool()` helper를 사용하는 것입니다. ```ts title=".opencode/tools/database.ts" {1} import { tool } from "@opencode-ai/plugin" @@ -41,13 +41,13 @@ export default tool({ }) ``` -**파일 이름**는 **tool name**가 됩니다. 위는 `database` 공구를 만듭니다. +**파일 이름**이 **tool 이름**이 됩니다. 위 예시는 `database` tool을 생성합니다. --- -### 파일당 여러 도구 +#### 파일 하나에 여러 tool 정의 -단일 파일에서 여러 도구를 수출할 수 있습니다. 각 수출은 ** 별도의 도구 ** 이름 ** `_`**: +하나의 파일에서 여러 tool을 export할 수도 있습니다. 각 export는 **별도의 tool**이 되며 이름은 **`_`** 형식을 사용합니다. ```ts title=".opencode/tools/math.ts" import { tool } from "@opencode-ai/plugin" @@ -75,13 +75,13 @@ export const multiply = tool({ }) ``` -이것은 2개의 공구를 만듭니다: `math_add`와 `math_multiply`. +이 경우 `math_add`, `math_multiply` 두 tool이 생성됩니다. --- -#### 스키마 +### 인자 -`tool.schema`를 사용할 수 있습니다, 그냥 [Zod](https://zod.dev), 인수 유형을 정의합니다. +인자 타입은 `tool.schema`로 정의할 수 있습니다. `tool.schema`는 [Zod](https://zod.dev) 기반입니다. ```ts "tool.schema" args: { @@ -89,7 +89,7 @@ args: { } ``` -[Zod](https://zod.dev)를 직접 가져오고 일반 객체를 반환할 수 있습니다. +[Zod](https://zod.dev)를 직접 import해서 일반 객체를 반환하는 방식도 사용할 수 있습니다. ```ts {6} import { z } from "zod" @@ -108,9 +108,9 @@ export default { --- -### 컨텍스트 +### Context -도구는 현재 세션에 대한 컨텍스트를받습니다. +tool은 현재 세션의 context 정보를 전달받습니다. ```ts title=".opencode/tools/project.ts" {8} import { tool } from "@opencode-ai/plugin" @@ -126,18 +126,18 @@ export default tool({ }) ``` -세션 작업 디렉토리에 `context.directory`를 사용합니다. -git worktree 루트에 `context.worktree`를 사용합니다. +세션 작업 디렉토리는 `context.directory`를 사용하세요. +git worktree 루트는 `context.worktree`를 사용하세요. --- -## 예제 +## 예시 -### Python 도구 작성 +### Python으로 tool 작성 -원하는 모든 언어로 도구를 쓸 수 있습니다. 여기에 Python을 사용하여 두 개의 숫자를 추가하는 예입니다. +tool은 원하는 언어로 작성할 수 있습니다. 아래는 Python으로 두 숫자를 더하는 예시입니다. -먼저 Python 스크립트로 도구를 만듭니다. +먼저 Python 스크립트로 tool을 만듭니다. ```python title=".opencode/tools/add.py" import sys @@ -147,7 +147,7 @@ b = int(sys.argv[2]) print(a + b) ``` -그런 다음 도구 정의를 만듭니다. +그다음 이 스크립트를 호출하는 tool 정의를 만듭니다. ```ts title=".opencode/tools/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" @@ -167,4 +167,4 @@ export default tool({ }) ``` -여기에 우리는 [`Bun.$`](https://bun.com/docs/runtime/shell) 유틸리티를 사용하여 Python 스크립트를 실행합니다. +여기서는 Python 스크립트를 실행하기 위해 [`Bun.$`](https://bun.com/docs/runtime/shell) 유틸리티를 사용합니다. diff --git a/packages/web/src/content/docs/ko/index.mdx b/packages/web/src/content/docs/ko/index.mdx index d5bd6a979c..b94d0750f6 100644 --- a/packages/web/src/content/docs/ko/index.mdx +++ b/packages/web/src/content/docs/ko/index.mdx @@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash - **Arch Linux에서 Paru 사용** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/nb/index.mdx b/packages/web/src/content/docs/nb/index.mdx index b10662537d..0e6846d2b9 100644 --- a/packages/web/src/content/docs/nb/index.mdx +++ b/packages/web/src/content/docs/nb/index.mdx @@ -84,7 +84,8 @@ Du kan også installere den med følgende kommandoer: - **Bruke Paru på Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/pl/index.mdx b/packages/web/src/content/docs/pl/index.mdx index 1c4ea7ea2b..fc571ec5d9 100644 --- a/packages/web/src/content/docs/pl/index.mdx +++ b/packages/web/src/content/docs/pl/index.mdx @@ -84,7 +84,8 @@ Możesz też użyć poniższych metod instalacji: - **Korzystanie z Paru na Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e7befcf026..db473ad36b 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1478,6 +1478,39 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon --- +### STACKIT + +STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. + +1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project. + + :::tip + You need a STACKIT customer account, user account, and project before creating auth tokens. + ::: + +2. Run the `/connect` command and search for **STACKIT**. + + ```txt + /connect + ``` + +3. Enter your STACKIT AI Model Serving auth token. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select from available models like _Qwen3-VL 235B_ or _Llama 3.3 70B_. + + ```txt + /models + ``` + +--- + ### OVHcloud AI Endpoints 1. Head over to the [OVHcloud panel](https://ovh.com/manager). Navigate to the `Public Cloud` section, `AI & Machine Learning` > `AI Endpoints` and in `API Keys` tab, click **Create a new API key**. diff --git a/packages/web/src/content/docs/pt-br/index.mdx b/packages/web/src/content/docs/pt-br/index.mdx index acbf12f2aa..6f23f4ebc5 100644 --- a/packages/web/src/content/docs/pt-br/index.mdx +++ b/packages/web/src/content/docs/pt-br/index.mdx @@ -84,7 +84,8 @@ Você também pode instalá-lo com os seguintes comandos: - **Usando Paru no Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/ru/index.mdx b/packages/web/src/content/docs/ru/index.mdx index b4a115250f..0b17c23785 100644 --- a/packages/web/src/content/docs/ru/index.mdx +++ b/packages/web/src/content/docs/ru/index.mdx @@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash - **Использование Paru в Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/th/index.mdx b/packages/web/src/content/docs/th/index.mdx index fbf1f7e575..5336db3c9e 100644 --- a/packages/web/src/content/docs/th/index.mdx +++ b/packages/web/src/content/docs/th/index.mdx @@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash - **ใช้ Paru บน Arch Linux** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/tr/index.mdx b/packages/web/src/content/docs/tr/index.mdx index 7c31f5bd34..291d3d490c 100644 --- a/packages/web/src/content/docs/tr/index.mdx +++ b/packages/web/src/content/docs/tr/index.mdx @@ -84,7 +84,8 @@ Ayrıca aşağıdaki komutlarla da yükleyebilirsiniz: - **Paru'yu Arch Linux'ta kullanma** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/zh-cn/index.mdx b/packages/web/src/content/docs/zh-cn/index.mdx index bc71221372..5bff7628ac 100644 --- a/packages/web/src/content/docs/zh-cn/index.mdx +++ b/packages/web/src/content/docs/zh-cn/index.mdx @@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash - **在 Arch Linux 上使用 Paru** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index 1be9d66901..86190a4e06 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -24,7 +24,7 @@ Tools allow the LLM to perform actions in your codebase. opencode comes with a s } ``` -您还可以使用万用字元同时控制多个工具。例如,要求 MCP 服务器批准所有工具: +您还可以使用通配符同时控制多个工具。例如,要求 MCP 服务器批准所有工具: ```json title="opencode.json" { @@ -39,15 +39,15 @@ Tools allow the LLM to perform actions in your codebase. opencode comes with a s --- -## 內建 +## 內建工具 以下是 opencode 中可用的所有内置工具。 --- -### 巴什 +### Bash -在专案环境中执行shell命令。 +在专项任务环境中执行shell命令。 ```json title="opencode.json" {4} { @@ -58,13 +58,13 @@ Tools allow the LLM to perform actions in your codebase. opencode comes with a s } ``` -This tool allows the LLM to run terminal commands like `npm install`, `git status`, or any other shell command. +这个工具允许 LLM 运行终端命令,例如:`npm install`, `git status`,或者其他任何终端命令。 --- -### 編輯 +### 编辑 -使用精確的字符串替換修改現有文件。 +使用精确的字符串替换来修改现有文件。 ```json title="opencode.json" {4} { @@ -75,13 +75,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -该工具取消替换精确的文字来匹配对文件执行精确编辑。这是 LLM 修改代码的主要方式。 +该工具通过替换完全匹配的文本来对文件进行精确编辑。这是 LLM 修改代码的主要方式。 --- -### 寫 +### 写入 -建立新文件或覆盖現有文件。 +创建新文件或覆盖现有文件。 ```json title="opencode.json" {4} { @@ -92,17 +92,17 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -使用它允许 LLM 创建新文件。如果现有文件已经存在,将会覆盖它们。 +使用此功能可允许 LLM 创建新文件。如果文件已存在,则会覆盖现有文件。 :::note -`write`工具由`edit`许可权控制,该许可权主题所有文件修改(`edit`、`write`、`patch`、`multiedit`)。 +`写入`工具由`编辑`权限控制,涵盖所有文件修改(`编辑`、`写入`、`修补`、`多重编辑`)。 ::: --- -### 讀 +### 读取 -從程式碼庫中讀取文件內容。 +读取代码库中的文件内容。 ```json title="opencode.json" {4} { @@ -113,13 +113,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -该工具讀取文件并返回其內容。它支持讀取大文件的特定行范围。 +该工具读取文件并返回其内容。它支持读取大型文件中的特定行范围。 --- ### grep -使用正規表示式搜索文件內容。 +使用正则表达式搜索文件内容。 ```json title="opencode.json" {4} { @@ -130,13 +130,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -在您的程式碼庫中快速進行內容搜索。支持完整的正規表示式語法和文件模式过濾。 +快速搜索代码库中的内容。支持完整的正则表达式语法和文件模式过滤。 --- -### 全域性 +### 通配符 -通过模式匹配查询文件。 +通过模式匹配查找文件。 ```json title="opencode.json" {4} { @@ -147,13 +147,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -使用 `**/*.js` 或 `src/**/*.ts` 等全域性模式搜索档案。返回按时间排序的匹配档案路径修改。 +使用类似 **/\*.js 或 src/**/\*.ts 的通配符模式搜索文件。返回按修改时间排序的匹配文件路径。 --- -### 列表 +### 罗列 -列出給定路徑中的文件和目录。 +列出给定路径下的文件和目录。 ```json title="opencode.json" {4} { @@ -164,16 +164,16 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -该工具列出目录內容。它接受全域性模式來过濾結果。 +此工具用于列出目录内容。它接受通配符模式来筛选结果。 --- ### lsp(实验性) -与您配置的LSP服务器交互,通知计划码智慧功能,例如定义、引用、悬停资讯和呼叫层次结构。 +与已配置的 LSP 服务器交互,以获取代码智能功能,例如定义、引用、悬停信息和调用层次结构。 :::note -This tool is only available when `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (or `OPENCODE_EXPERIMENTAL=true`). +只有当 OPENCODE_EXPERIMENTAL_LSP_TOOL=true(或 OPENCODE_EXPERIMENTAL=true)时,此工具才可用。 ::: ```json title="opencode.json" {4} @@ -187,13 +187,13 @@ This tool is only available when `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (or `OPEN 支持的操作包括 `goToDefinition`、`findReferences`、`hover`、`documentSymbol`、`workspaceSymbol`、`goToImplementation`、`prepareCallHierarchy`、`incomingCalls` 和 `outgoingCalls`。 -To configure which LSP servers are available for your project, see [LSP Servers](/docs/lsp). +要配置哪些 LSP 服务器可用于您的项目,请参阅 [LSP Servers](/docs/lsp). --- -### 修補 +### 修补 -对文件应用補丁。 +对文件应用补丁。 ```json title="opencode.json" {4} { @@ -204,17 +204,17 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -该工具将補丁文件应用到您的程式碼庫。对于应用來自各種來源的差異和補丁很有帮助。 +此工具可将补丁文件应用到您的代码库。它可用于应用来自各种来源的差异和补丁。 :::note -`patch`工具由`edit`许可权控制,该许可权主题所有文件修改(`edit`、`write`、`patch`、`multiedit`)。 +`修补`工具由`编辑`权限控制,涵盖所有文件修改(`编辑`、`写入`、`修补`、`多重编辑`)。 ::: --- ### 技能 -加载[skill](/docs/skills)(`SKILL.md` 档案)并在对话中返回其内容。 +加载[技能](/docs/skills)(`SKILL.md` 文件)并在对话中返回其内容。 ```json title="opencode.json" {4} { @@ -227,9 +227,9 @@ To configure which LSP servers are available for your project, see [LSP Servers] --- -### 待辦寫入 +### 写入待办 -在編碼会话期間管理待辦事項列表。 +在编码会话过程中管理待办事项列表。 ```json title="opencode.json" {4} { @@ -240,17 +240,17 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -建立和更新任务列表以跟踪复杂操作期间的详细信息。LLM 使用它来组织多步骤任务。 +创建和更新任务列表,以跟踪复杂操作的进度。LLM 利用此功能来组织多步骤任务。 :::note -默认情况下,子代理取消此工具,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) +此工具默认情况下对子代理禁用,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) ::: --- -### 託多雷德 +### 读取待办 -閱讀現有的待辦事項列表。 +阅读现有的待办事项清单。 ```json title="opencode.json" {4} { @@ -261,17 +261,17 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -读取当前完成待办事项列表状态。由 LLM 用于跟踪哪些任务待处理或已已。 +读取当前待办事项列表状态。LLM 使用此信息来跟踪哪些任务处于待处理状态或已完成状态。 :::note -默认情况下,子代理取消此工具,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) +此工具默认情况下对子代理禁用,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) ::: --- -### 網頁抓取 +### 网页获取 -获取網頁內容。 +获取网页内容。 ```json title="opencode.json" {4} { @@ -282,18 +282,18 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -允许 LLM 获取和读取网页。对于查询文件或研究线上资源很有帮助。 +允许LLM获取并读取网页。可用于查找文档或研究在线资源。 --- -### 網路搜索 +### 网页搜索 -在網路上搜索資訊。 +在网上搜索信息。 :::note -仅当使用 opencode 提供或 `OPENCODE_ENABLE_EXA` 程序环境变量设置为任何真值(例如 `true` 或 `1`)时,此工具才可用。 +只有在使用 OpenCode 提供程序时,或者当 OPENCODE_ENABLE_EXA 环境变量被设置为任何真值(例如 true 或 1)时,此工具才可用。 -要在启动 opencode 时启用: +在启动 OpenCode 时启用: ```bash OPENCODE_ENABLE_EXA=1 opencode @@ -310,19 +310,19 @@ OPENCODE_ENABLE_EXA=1 opencode } ``` -使用 Exa AI 执行网路搜索以线上查询相关资讯。对于研究主题、查询时事或收集训练超出数据范围的资讯很有帮助。 +利用 Exa AI 进行网络搜索,查找相关信息。可用于研究特定主题、了解时事新闻或收集超出训练数据范围的信息。 -不需要 API 密钥 — 该工具消耗身份验证即可直接连线到 Exa AI 的托管 MCP 服务。 +无需 API 密钥——该工具无需身份验证即可直接连接到 Exa AI 托管的 MCP 服务。 :::tip -当您需要查询资讯(发现)时,请使用 `websearch`;当您需要从特定 URL 检索内容(搜索)时,请使用 `webfetch`。 +当您需要查找信息时,请使用`网页搜索`;当您需要从特定 URL 检索内容时,请使用`网页获取`。 ::: --- -### 問題 +### 提问 -在执行过程中詢問用户問題。 +在执行过程中向用户提问。 ```json title="opencode.json" {4} { @@ -333,20 +333,20 @@ OPENCODE_ENABLE_EXA=1 opencode } ``` -该工具允许 LLM 在任务期间询问用户问题。它适用于: +该工具允许 LLM 在执行任务期间向用户提问。它在以下方面很有用: -- 收集用户偏好或要求 -- 澄清不明確的指令 -- 就實施选择做出決策 -- 提供选择方向 +- 收集用户偏好或需求 +- 澄清含糊不清的指示 +- 就实施方案做出决定 +- 提供关于选择下一步方向的选项 -每个問題都包含標題、問題文字和選項列表。用户可以從提供的選項中進行选择或輸入自定義答案。当存在多个問題時,用户可以在提交所有答案之前在这些問題之间导航。 +每个问题都包含标题、问题正文和选项列表。用户可以从提供的选项中选择答案,也可以输入自定义答案。如果有多个问题,用户可以在提交所有答案之前在不同问题之间切换。 --- -## 定製工具 +## 自定义工具 -自定义工具可以让您定义LLM可以调用自己的函式。这些是在您的配置文件中定义的并且可以执行任何代码。 +自定义工具允许您定义LLM可以调用的自定义函数。这些函数在您的配置文件中定义,并且可以执行任意代码。 [了解更多](/docs/custom-tools)关于创建自定义工具。 @@ -360,15 +360,15 @@ MCP(模型上下文协议)服务器允许您集成外部工具和服务。 --- -## 内部結構 +## 内部规则 -Internally, tools like `grep`, `glob`, and `list` use [ripgrep](https://github.com/BurntSushi/ripgrep) under the hood. By default, ripgrep respects `.gitignore` patterns, which means files and directories listed in your `.gitignore` will be excluded from searches and listings. +在内部,`grep`、 `通配符` 和 `罗列` 等工具底层都使用了 ripgrep。默认情况下,ripgrep 会遵循 .gitignore 文件中的规则,这意味着 .gitignore 文件中列出的文件和目录将被排除在搜索和列表之外。 --- ### 忽略模式 -要包含通常会被忽略的文件,请在专案根目录中建立 `.ignore` 文件。该文件可以明确允许某些路径。 +为了使工具不跳过那些通常会被忽略的文件,请在项目根目录下创建一个 `.ignore` 文件。该文件内定义的目录可以不会被跳过。 ```text title=".ignore" !node_modules/ @@ -376,4 +376,4 @@ Internally, tools like `grep`, `glob`, and `list` use [ripgrep](https://github.c !build/ ``` -例如,此 `.ignore` 档案允许 ripgrep 在 `node_modules/`、`dist/` 和 `build/` 目录中搜索,即使它们列在 `.gitignore` 中。 +例如,这个 `.ignore` 文件允许 ripgrep 在 `node_modules/`、`dist/` 和 `build/` 目录中搜索,即使它们已在 `.gitignore` 中列出。 diff --git a/packages/web/src/content/docs/zh-tw/index.mdx b/packages/web/src/content/docs/zh-tw/index.mdx index ab22bfb5c7..5de780503c 100644 --- a/packages/web/src/content/docs/zh-tw/index.mdx +++ b/packages/web/src/content/docs/zh-tw/index.mdx @@ -84,7 +84,8 @@ curl -fsSL https://opencode.ai/install | bash - **在 Arch Linux 上使用 Paru** ```bash - paru -S opencode-bin + sudo pacman -S opencode # Arch Linux (Stable) + paru -S opencode-bin # Arch Linux (Latest from AUR) ``` #### Windows diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 97d085dfbf..cf9f97b0b1 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -12,7 +12,28 @@ function docsAlias(pathname: string) { const next = locale === "root" ? `/docs${tail}` : `/docs/${locale}${tail}` if (next === pathname) return null - return next + return { + path: next, + locale, + } +} + +function cookie(locale: string) { + const value = locale === "root" ? "en" : locale + return `oc_locale=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +function redirect(url: URL, path: string, locale?: string) { + const next = new URL(url.toString()) + next.pathname = path + const headers = new Headers({ + Location: next.toString(), + }) + if (locale) headers.set("Set-Cookie", cookie(locale)) + return new Response(null, { + status: 302, + headers, + }) } function localeFromCookie(header: string | null) { @@ -59,9 +80,7 @@ function localeFromAcceptLanguage(header: string | null) { export const onRequest = defineMiddleware((ctx, next) => { const alias = docsAlias(ctx.url.pathname) if (alias) { - const url = new URL(ctx.request.url) - url.pathname = alias - return ctx.redirect(url.toString(), 302) + return redirect(ctx.url, alias.path, alias.locale) } if (ctx.url.pathname !== "/docs" && ctx.url.pathname !== "/docs/") return next() @@ -71,7 +90,5 @@ export const onRequest = defineMiddleware((ctx, next) => { localeFromAcceptLanguage(ctx.request.headers.get("accept-language")) if (!locale || locale === "root") return next() - const url = new URL(ctx.request.url) - url.pathname = `/docs/${locale}/` - return ctx.redirect(url.toString(), 302) + return redirect(ctx.url, `/docs/${locale}/`) }) diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 61ccf91b44..d1980decac 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.63", + "version": "1.1.65", "publisher": "sst-dev", "repository": { "type": "git",