Merge remote-tracking branch 'origin/main' into docs/customize-tabs

This commit is contained in:
Josh Lambert
2026-03-27 15:25:23 -04:00
335 changed files with 17990 additions and 2861 deletions
+3 -3
View File
@@ -7,9 +7,9 @@ on:
jobs:
call-webhook:
if: >
github.event.pull_request.merged == true &&
(startsWith(github.event.pull_request.title, 'feat:') ||
startsWith(github.event.pull_request.title, 'feat('))
github.event.pull_request.merged == true &&
(startsWith(github.event.pull_request.title, 'feat:') ||
startsWith(github.event.pull_request.title, 'feat('))
runs-on: ubuntu-latest
steps:
- name: Send webhook safely
+1
View File
@@ -16,6 +16,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- **Single test**: `bun test test/tool/tool.test.ts` from `packages/opencode/`
- **SDK regen**: After changing server endpoints in `packages/opencode/src/server/`, run `./script/generate.ts` from root to regenerate `packages/sdk/js/`
- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing.
- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale.
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
## Products
+5 -5
View File
@@ -1,9 +1,9 @@
<p align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
<a href="https://x.com/kilocode"><img src="https://img.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)"></a>
<a href="https://blog.kilo.ai"><img src="https://img.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog"></a>
<a href="https://kilo.ai/discord"><img src="https://img.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://img.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit"></a>
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://raster.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
<a href="https://x.com/kilocode"><img src="https://raster.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)"></a>
<a href="https://blog.kilo.ai"><img src="https://raster.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog"></a>
<a href="https://kilo.ai/discord"><img src="https://raster.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://raster.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit"></a>
</p>
# 🚀 Kilo
+28 -19
View File
@@ -27,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
@@ -79,7 +79,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -112,7 +112,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -142,7 +142,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -171,8 +171,11 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/openai": "2.0.101",
"@ai-sdk/openai-compatible": "1.0.32",
"@clack/prompts": "1.0.0-alpha.1",
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -203,7 +206,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.1.2",
"version": "7.1.8",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -213,7 +216,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
@@ -233,7 +236,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/util": "workspace:*",
@@ -268,7 +271,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
@@ -321,7 +324,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.1.2",
"version": "7.1.8",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -341,7 +344,7 @@
"@ai-sdk/google-vertex": "3.0.106",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
"@ai-sdk/openai": "2.0.101",
"@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
@@ -443,7 +446,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"zod": "catalog:",
@@ -457,14 +460,14 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.1.2",
"version": "7.1.8",
"devDependencies": {
"@types/bun": "catalog:",
},
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.1.2",
"version": "7.1.8",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -475,7 +478,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.1.2",
"version": "7.1.8",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -498,7 +501,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -545,7 +548,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "7.1.2",
"version": "7.1.8",
"dependencies": {
"zod": "catalog:",
},
@@ -662,7 +665,7 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gaptHgaXjMw3+eA0Q4FABcsj5nQNP6EpFaGUR+Pj5WJy7Kn6mApl975/x57224MfeJIShNpt8wFKK3tvh5ewKg=="],
"@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=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.101", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kQ52HLV45T3bQbRzWExXW6+pkg3Nvq4dUnZHUPJXWgkUUsAhZjxHrXqPOc/0yfn/4+Dn2uLmIgAkP9IfzMMcNg=="],
"@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=="],
@@ -4354,6 +4357,8 @@
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@ai-sdk/azure/@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=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
@@ -4378,7 +4383,7 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "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-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
@@ -4832,6 +4837,8 @@
"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=="],
"ai-gateway-provider/@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=="],
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -5596,6 +5603,8 @@
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-h4RHYUp03Kpy2GCPv5B7pjfcHxZ9M9R475svzkwzpH4=",
"aarch64-linux": "sha256-zLCL5Z5NRvWBfsxnjdMGEWAYBNU120G2/YQ0KFaq+ZA=",
"aarch64-darwin": "sha256-KZPpNosE6S3rW0RSCiADi26ysPIt74TeULQdYF7dDOE=",
"x86_64-darwin": "sha256-XkEYBrRiD1DRhWtQ73afzBNRk0bZHRFpxqAcwnsraE8="
"x86_64-linux": "sha256-YjB3YfaA79nztpZoYEAlFBakS//G/Guprlqk4BYFgOA=",
"aarch64-linux": "sha256-ru470M8RVwyOPNGr6I4VfApQRHhYFFMPjOBneTRBJtc=",
"aarch64-darwin": "sha256-M6LIPBHnYH9AtIRAKyW44VN71Kb28nztzFteCkL0588=",
"x86_64-darwin": "sha256-HURoot79BFHYwoEp0XqVdI6cRdV7KGvmRRqOf+hUa9Q="
}
}
+1 -1
View File
@@ -112,6 +112,6 @@
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
},
"version": "7.1.2",
"version": "7.1.8",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.1.2",
"version": "7.1.8",
"description": "",
"type": "module",
"exports": {
+1 -2
View File
@@ -127,7 +127,7 @@ const INTL: Record<Locale, string> = {
th: "th",
bs: "bs",
nl: "nl",
tr: "tr",
}
@@ -149,7 +149,6 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
th: "language.th",
bs: "language.bs",
nl: "language.nl",
tr: "language.tr",
}
+1 -2
View File
@@ -136,8 +136,7 @@ export const dict = {
"provider.connect.oauth.code.invalid": "رمز التفويض غير صالح",
"provider.connect.oauth.auto.visit.prefix": "قم بزيارة ",
"provider.connect.oauth.auto.visit.link": "هذا الرابط",
"provider.connect.oauth.auto.visit.suffix":
" وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.",
"provider.connect.oauth.auto.visit.suffix": " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.",
"provider.connect.oauth.auto.confirmationCode": "رمز التأكيد",
"provider.connect.toast.connected.title": "تم توصيل {{provider}}",
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
+2 -4
View File
@@ -389,8 +389,7 @@ export const dict = {
"toast.session.unshare.failed.description": "Une erreur s'est produite lors de l'annulation du partage de la session",
"toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}",
"toast.update.title": "Mise à jour disponible",
"toast.update.description":
"Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.",
"toast.update.description": "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.",
"toast.update.action.installRestart": "Installer et redémarrer",
"toast.update.action.notYet": "Pas encore",
"error.page.title": "Quelque chose s'est mal passé",
@@ -525,8 +524,7 @@ export const dict = {
"sidebar.workspaces.enable": "Activer les espaces de travail",
"sidebar.workspaces.disable": "Désactiver les espaces de travail",
"sidebar.gettingStarted.title": "Commencer",
"sidebar.gettingStarted.line1":
"Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.",
"sidebar.gettingStarted.line1": "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.",
"sidebar.gettingStarted.line2":
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessions récentes",
+1 -2
View File
@@ -148,8 +148,7 @@ export const dict = {
"provider.connect.oauth.code.invalid": "รหัสการอนุญาตไม่ถูกต้อง",
"provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ",
"provider.connect.oauth.auto.visit.link": "ลิงก์นี้",
"provider.connect.oauth.auto.visit.suffix":
" และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo",
"provider.connect.oauth.auto.visit.suffix": " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo",
"provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน",
"provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว",
"provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว",
+1 -2
View File
@@ -143,8 +143,7 @@ export const dict = {
"provider.connect.opencodeZen.visit.prefix": "",
"provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
"provider.connect.opencodeZen.visit.suffix": " adresini ziyaret ederek API anahtarınızı alın.",
"provider.connect.oauth.code.visit.prefix":
"Hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için ",
"provider.connect.oauth.code.visit.prefix": "Hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için ",
"provider.connect.oauth.code.visit.link": "bu bağlantıya",
"provider.connect.oauth.code.visit.suffix": " tıklayarak yetkilendirme kodunuzu alın.",
"provider.connect.oauth.code.label": "{{method}} yetkilendirme kodu",
+1 -2
View File
@@ -149,8 +149,7 @@ export const dict = {
"provider.connect.oauth.code.invalid": "授權碼無效",
"provider.connect.oauth.auto.visit.prefix": "造訪 ",
"provider.connect.oauth.auto.visit.link": "此連結",
"provider.connect.oauth.auto.visit.suffix":
" 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。",
"provider.connect.oauth.auto.visit.suffix": " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。",
"provider.connect.oauth.auto.confirmationCode": "確認碼",
"provider.connect.toast.connected.title": "{{provider}} 已連線",
"provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。",
@@ -43,9 +43,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={
props.project.id === KILO_PROJECT_ID ? "https://kilo.ai/favicon.svg" : props.project.icon?.override
}
src={props.project.id === KILO_PROJECT_ID ? "https://kilo.ai/favicon.svg" : props.project.icon?.override}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
classList={{ "badge-mask": notify() }}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "7.1.2",
"version": "7.1.8",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "7.1.2",
"version": "7.1.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -2
View File
@@ -45,8 +45,7 @@ export const dict = {
"desktop.menu.help.shareFeedback": "Feedback teilen",
"desktop.menu.help.reportBug": "Einen Fehler melden",
"desktop.cli.error.unsupportedPlatform": "Die CLI-Installation wird nur unter macOS und Linux unterstützt.",
"desktop.cli.error.sidecarMissing":
"Das Kilo CLI-Binary fehlt. Versuchen Sie, die Desktop-App neu zu installieren.",
"desktop.cli.error.sidecarMissing": "Das Kilo CLI-Binary fehlt. Versuchen Sie, die Desktop-App neu zu installieren.",
"desktop.cli.error.scriptWriteFailed": "Das CLI-Installationsskript konnte nicht vorbereitet werden.",
"desktop.cli.error.scriptPermissionFailed": "Das CLI-Installationsskript konnte nicht ausführbar gemacht werden.",
"desktop.cli.error.scriptRunFailed": "Das CLI-Installationsskript konnte nicht ausgeführt werden.",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.1.2"
version = "7.1.8"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.2/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.8/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.2/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.8/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.2/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.8/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.2/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.8/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.2/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.8/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
@@ -0,0 +1,45 @@
import React from "react"
import type { Platform } from "../lib/types"
interface Props {
platform?: Platform
}
export function PageVersionSwitcher({ platform }: Props) {
if (!platform || platform === "all") return null
const legacy = platform === "legacy"
return (
<div className="version-banner">
<span className="version-banner-icon">{legacy ? "\u24D8" : "\u2728"}</span>
<span>
{legacy
? "This page applies to the legacy VSCode extension."
: "This page applies to the current VSCode extension & CLI."}
</span>
<style jsx>{`
.version-banner {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
margin-top: 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-color);
}
.version-banner-icon {
flex-shrink: 0;
font-size: 1rem;
line-height: 1.5;
}
`}</style>
</div>
)
}
+37 -1
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react"
import { useRouter } from "next/router"
import Link from "next/link"
import { SectionNav } from "../lib/types"
import { Platform, SectionNav } from "../lib/types"
import { Nav } from "../lib/nav"
// Define navigation items for each major section
@@ -67,6 +67,16 @@ const ChevronLeft = () => (
</svg>
)
function PlatformBadge({ platform }: { platform?: Platform }) {
if (!platform || platform === "all") return null
const label = platform === "legacy" ? "Legacy" : "New"
return (
<span className="platform-badge" data-platform={platform}>
{label}
</span>
)
}
interface SideNavProps {
isMobileOpen?: boolean
onMobileClose?: () => void
@@ -268,6 +278,7 @@ export function SideNav({ isMobileOpen = false, onMobileClose }: SideNavProps) {
<div className="nav-item-with-toggle">
<Link href={link.href} onClick={handleLinkClick}>
{link.children}
<PlatformBadge platform={link.platform} />
</Link>
<button
type="button"
@@ -290,6 +301,7 @@ export function SideNav({ isMobileOpen = false, onMobileClose }: SideNavProps) {
<li key={subLink.href} className={subActive ? "active" : ""}>
<Link href={subLink.href} onClick={handleLinkClick}>
{subLink.children}
<PlatformBadge platform={subLink.platform} />
</Link>
</li>
)
@@ -300,6 +312,7 @@ export function SideNav({ isMobileOpen = false, onMobileClose }: SideNavProps) {
) : (
<Link href={link.href} onClick={handleLinkClick}>
{link.children}
<PlatformBadge platform={link.platform} />
</Link>
)}
</li>
@@ -478,6 +491,29 @@ export function SideNav({ isMobileOpen = false, onMobileClose }: SideNavProps) {
color: var(--accent-color);
font-weight: 500;
}
.nav-links :global(.platform-badge) {
display: inline-block;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.0625rem 0.3125rem;
border-radius: 0.1875rem;
margin-left: 0.375rem;
vertical-align: middle;
line-height: 1.4;
}
.nav-links :global(.platform-badge[data-platform="legacy"]) {
background-color: rgba(139, 92, 246, 0.12);
color: rgb(139, 92, 246);
}
.nav-links :global(.platform-badge[data-platform="new"]) {
background-color: rgba(34, 197, 94, 0.12);
color: rgb(34, 197, 94);
}
`}</style>
</div>
)
+2 -2
View File
@@ -27,16 +27,16 @@ export function Tabs({ children }: TabsProps) {
)
const indexFromHash = () => {
if (typeof window === "undefined") return 0
const hash = window.location.hash.slice(1)
if (!hash) return 0
const found = tabs.findIndex((tab) => slugify(tab.props.label) === hash)
return found >= 0 ? found : 0
}
const [activeIndex, setActiveIndex] = useState(indexFromHash)
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
setActiveIndex(indexFromHash())
const onHashChange = () => setActiveIndex(indexFromHash())
window.addEventListener("hashchange", onHashChange)
return () => window.removeEventListener("hashchange", onHashChange)
+2 -3
View File
@@ -336,9 +336,8 @@ export function TopNav({ onMobileMenuToggle, isMobileMenuOpen = false, showMobil
{/* Announcement banner */}
<div className="announcement-banner">
<p>
We're <Link href="https://blog.kilo.ai/p/kilo-cli">replatforming our extensions on the new Kilo CLI</Link>.
Contribute to the new CLI and pre-release extensions at{" "}
<Link href="https://github.com/Kilo-Org/kilocode">Kilo-Org/kilocode</Link>.
We've <Link href="https://blog.kilo.ai/p/kilo-cli">replatformed our extensions on the new Kilo CLI</Link>.
Contribute at <Link href="https://github.com/Kilo-Org/kilocode">Kilo-Org/kilocode</Link>.
</p>
</div>
+1
View File
@@ -7,6 +7,7 @@ export * from "./Icon"
export * from "./Image"
export * from "./KiloCodeIcon"
export * from "./PageFooter"
export * from "./PageVersionSwitcher"
export * from "./SideNav"
export * from "./Table"
export * from "./TableOfContents"
@@ -30,8 +30,12 @@ export const GettingStartedNav: NavSection[] = [
children: "Settings",
subLinks: [
{ href: "/getting-started/settings/auto-approving-actions", children: "Auto-Approving Actions" },
{ href: "/getting-started/settings/auto-cleanup", children: "Auto Cleanup" },
{ href: "/getting-started/settings/system-notifications", children: "System Notifications" },
{ href: "/getting-started/settings/auto-cleanup", children: "Auto Cleanup", platform: "legacy" },
{
href: "/getting-started/settings/system-notifications",
children: "System Notifications",
platform: "legacy",
},
],
},
{ href: "/getting-started/adding-credits", children: "Adding Credits" },
+3
View File
@@ -1,6 +1,9 @@
export type Platform = "legacy" | "new" | "all"
export interface NavLink {
href: string
children: string
platform?: Platform // "legacy" = stable VSCode only, "new" = new VSCode + CLI only, omitted = universal
subLinks?: NavLink[] // Optional nested links for second-level navigation
}
@@ -18,7 +18,7 @@ Before installing the Kilo Code plugin, ensure you have:
1. If you don't have a JetBrains IDE installed, download one from [jetbrains.com](https://www.jetbrains.com/)
2. Then, you can click the button below to install Kilo Code directly from the JetBrains Marketplace:
[![Install Kilo Code](https://img.shields.io/badge/Install%20Kilo%20Code-F8F674?style=for-the-badge)](https://plugins.jetbrains.com/plugin/28350-kilo-code)
[![Install Kilo Code](https://raster.shields.io/badge/Install%20Kilo%20Code-F8F674?style=for-the-badge)](https://plugins.jetbrains.com/plugin/28350-kilo-code)
### Install from JetBrains Marketplace
@@ -3,7 +3,7 @@
1. If you don't have VS Code installed, download it from [code.visualstudio.com](https://code.visualstudio.com/)
2. Then, you can click the button below to install Kilo Code directly from the VS Code Marketplace:
[![Install Kilo Code](https://img.shields.io/badge/Install%20Kilo%20Code-F8F674?style=for-the-badge)](vscode:extension/kilocode.kilo-code)
[![Install Kilo Code](https://raster.shields.io/badge/Install%20Kilo%20Code-F8F674?style=for-the-badge)](vscode:extension/kilocode.kilo-code)
### Install from VS Code Marketplace
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.1.2",
"version": "7.1.8",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
+4 -1
View File
@@ -3,7 +3,7 @@ import Head from "next/head"
import { useRouter } from "next/router"
import posthog from "posthog-js"
import { CopyPageButton, PageFooter, SideNav, TableOfContents, TopNav } from "../components"
import { CopyPageButton, PageFooter, PageVersionSwitcher, SideNav, TableOfContents, TopNav } from "../components"
import "prismjs"
import "prismjs/components/prism-bash.min"
@@ -129,6 +129,8 @@ export default function MyApp({ Component, pageProps }: AppProps<MyAppProps>) {
const toc = pageProps.markdoc?.content ? collectHeadings(pageProps.markdoc.content) : []
const platform = markdoc?.frontmatter?.platform
return (
<>
<Head>
@@ -179,6 +181,7 @@ export default function MyApp({ Component, pageProps }: AppProps<MyAppProps>) {
<main className="main-content">
<div className="content-wrapper">
<div className="article-content mt-5">
{markdoc && <PageVersionSwitcher platform={platform} />}
<Component {...pageProps} />
{markdoc && <PageFooter />}
</div>
@@ -45,28 +45,28 @@ That's it. No configuration needed.
## Auto Balanced
`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses more cost-effective models — Kimi K2.5 for reasoning-heavy modes and Minimax M2.5 (Free) for implementation modes.
`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses more cost-effective models — Kimi K2.5 for reasoning-heavy modes and Minimax M2.7 for implementation modes.
### Mode-to-Model Mapping
| Mode | Model Used | Best For |
| -------------- | ------------------- | ---------------------------- |
| `architect` | Kimi K2.5 | System design, planning |
| `orchestrator` | Kimi K2.5 | Multi-step task coordination |
| `ask` | Kimi K2.5 | Questions, explanations |
| `plan` | Kimi K2.5 | Planning, reasoning |
| `general` | Kimi K2.5 | General assistance |
| `debug` | Kimi K2.5 | Debugging and fixing issues |
| `code` | Minimax M2.5 (Free) | Writing and editing code |
| `build` | Minimax M2.5 (Free) | Implementation tasks |
| `explore` | Minimax M2.5 (Free) | Codebase exploration |
| Mode | Model Used | Best For |
| -------------- | ------------ | ---------------------------- |
| `architect` | Kimi K2.5 | System design, planning |
| `orchestrator` | Kimi K2.5 | Multi-step task coordination |
| `ask` | Kimi K2.5 | Questions, explanations |
| `plan` | Kimi K2.5 | Planning, reasoning |
| `general` | Kimi K2.5 | General assistance |
| `debug` | Kimi K2.5 | Debugging and fixing issues |
| `code` | Minimax M2.7 | Writing and editing code |
| `build` | Minimax M2.7 | Implementation tasks |
| `explore` | Minimax M2.7 | Codebase exploration |
**Planning and reasoning tasks** use Kimi K2.5, a strong open-weight reasoning model from Moonshot AI.
**Implementation tasks** use Minimax M2.5, which provides fast, capable code generation at a fraction of frontier model costs.
**Implementation tasks** use Minimax M2.7, which provides fast, capable code generation at a fraction of frontier model costs.
{% callout type="info" title="Image support" %}
Auto Balanced does not support image inputs, since Minimax M2.5 does not have vision capabilities.
Auto Balanced does not support image inputs, since Minimax M2.7 does not have vision capabilities.
{% /callout %}
## Benefits
@@ -82,11 +82,11 @@ Routes to the most capable paid models optimizing for cost, performance, and cap
Follows the same mode-based routing as Frontier but uses more cost-effective models.
| Mode | Resolved Model |
| -------------------------------------------------------------- | --------------------------- |
| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `moonshotai/kimi-k2.5` |
| `build`, `explore`, `code` | `minimax/minimax-m2.5:free` |
| Default (no mode specified) | `minimax/minimax-m2.5:free` |
| Mode | Resolved Model |
| -------------------------------------------------------------- | ---------------------- |
| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `moonshotai/kimi-k2.5` |
| `build`, `explore`, `code` | `minimax/minimax-m2.7` |
| Default (no mode specified) | `minimax/minimax-m2.7` |
### `kilo-auto/free`
@@ -20,10 +20,11 @@ Kilo Gateway currently supports BYOK keys for these providers:
- Anthropic
- AWS Bedrock
- OpenAI
- Google AI Studio
- Inception
- Minimax
- Mistral AI
- OpenAI
- xAI
- Z.AI
@@ -14,54 +14,16 @@ Get started with Kilo Code by installing it on your preferred platform. Choose y
## VS Code Extension
{% partial file="install-vscode.md" /%}
{% /tab %}
{% tab label="VS Code (Preview)" %}
## VS Code Preview Extension
{% callout type="info" %}
We're rebuilding Kilo Code from the ground up on the new [Kilo CLI](https://github.com/Kilo-Org/kilocode). The pre-release extension is available for users who want to try the latest architecture and provide feedback, and don't mind some missing features and rough edges.
{% /callout %}
The pre-release extension is a complete rebuild featuring:
- A new Solid.js-based UI
- Deep integration with the Kilo CLI backend
- Improved session management and model switching
### Current Status
This is an early pre-release. Core features like chat, markdown rendering, authentication, and model/mode switching are working. Some features from the stable extension are still being implemented.
For the full feature status, see the [feature parity tracking document](https://github.com/Kilo-Org/kilocode/blob/main/packages/kilo-vscode/docs/opencode-migration-plan.md).
### Installing the Pre-Release
The current Kilo Code extension is built on the [Kilo CLI](https://github.com/Kilo-Org/kilocode) and is distributed as the **pre-release version** on the VS Code Marketplace.
1. Open VS Code
2. Go to Extensions (`Ctrl+Shift+X` / `Cmd+Shift+X`)
3. Search for "Kilo Code"
4. Click the dropdown arrow next to **Install** and select **Install Pre-Release Version**
### Switching Back to Stable
If you need to return to the stable version:
1. Open Extensions in VS Code
2. Find Kilo Code
3. Click the dropdown and select **Switch to Release Version**
### Feedback and Issues
Report issues or provide feedback in the [Kilo-Org/kilocode repository](https://github.com/Kilo-Org/kilocode/issues).
{% /tab %}
{% tab label="JetBrains" %}
## JetBrains IDEs
{% partial file="install-jetbrains.md" /%}
{% callout type="info" %}
The "pre-release" label is a VS Code Marketplace distribution channel — the extension is stable and recommended for all users.
{% /callout %}
{% /tab %}
{% tab label="CLI" %}
@@ -70,6 +32,27 @@ Report issues or provide feedback in the [Kilo-Org/kilocode repository](https://
{% partial file="install-cli.md" /%}
{% /tab %}
{% tab label="VS Code (Legacy)" %}
## VS Code Legacy Extension
The legacy extension is the previous version of Kilo Code for VS Code. It is still available but is no longer actively developed. We recommend installing the current extension (see the **VS Code** tab).
To install or switch back to the legacy version:
1. Open VS Code
2. Go to Extensions (`Ctrl+Shift+X` / `Cmd+Shift+X`)
3. Search for "Kilo Code"
4. Click the dropdown arrow next to **Install** and select **Switch to Release Version**
{% /tab %}
{% tab label="JetBrains" %}
## JetBrains IDEs
{% partial file="install-jetbrains.md" /%}
{% /tab %}
{% tab label="Slack" %}
@@ -5,12 +5,112 @@ description: "Get up and running with Kilo Code in minutes"
# Quickstart
After you [set up Kilo Code](/docs/getting-started/setup-authentication), follow the guide for your platform below.
{% tabs %}
{% tab label="VSCode" %}
## Step by Step Guide
### Step 1: Open Kilo Code
Click the Kilo Code icon in the VS Code Primary Side Bar to open the chat panel. If you don't see the icon, verify the [extension is installed](/docs/getting-started/installing).
### Step 2: Type Your Task
Type a clear, concise description of what you want Kilo Code to do in the chat box. The same examples work here:
- "Create a file named `hello.txt` containing 'Hello, world!'."
- "Write a Python function that adds two numbers."
- "Create an HTML file for a simple website with the title 'Kilo test'"
No special commands or syntax needed—just use plain English.
### Step 3: Send Your Task
Press **Enter** to send.
### Step 4: Review & Approve Actions
Kilo Code analyzes your request and proposes actions. By default, most tools are auto-approved — only shell commands, external directory access, and sensitive file reads will prompt for confirmation. You'll see the tool name, arguments, and can approve or reject each action.
To change which actions require approval, open **Settings** (gear icon) and go to the **Auto-Approve** tab. You can set each tool to Allow, Ask, or Deny. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for details.
### Step 5: Iterate
Kilo Code works iteratively. Continue giving feedback or follow-up instructions until your task is complete.
### Key Differences from Legacy
- **Settings** are managed via `kilo.jsonc` config files (the Settings webview reads and writes the same files)
- **Permissions** use a granular per-tool system instead of broad approval categories
- **Modes** are called "agents" and configured as `.md` files or via the `agent` config key
- **Autocomplete** uses FIM (Fill-in-the-Middle) with Codestral
{% /tab %}
{% tab label="CLI" %}
## CLI Quickstart
### Step 1: Open a Terminal
Navigate to your project directory:
```bash
cd /path/to/your/project
```
### Step 2: Launch Kilo
Run the `kilo` command to start the interactive TUI (terminal user interface):
```bash
kilo
```
If this is your first time, run `kilo auth login` first to authenticate (see [Setup & Authentication](/docs/getting-started/setup-authentication)).
### Step 3: Type Your Task
Type your request in natural language at the prompt. The same examples work here:
- "Create a file named `hello.txt` containing 'Hello, world!'."
- "Write a Python function that adds two numbers."
- "Create an HTML file for a simple website with the title 'Kilo test'"
Press **Enter** to send.
### Step 4: Review & Approve Actions
Kilo analyzes your request and proposes actions. By default, most tools are auto-approved — only shell commands, external directory access, and sensitive file reads will prompt for confirmation. You'll see the tool name, arguments, and can approve or reject each action.
To change permission defaults, configure the `permission` key in your `kilo.jsonc` config file. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for details.
### Step 5: Iterate
Kilo works iteratively. Continue giving feedback or follow-up instructions until your task is complete.
### One-Shot Mode
For quick, non-interactive tasks, use `kilo run`:
```bash
kilo run "add error handling to src/api.ts"
```
Add `--auto` to auto-approve all permissions (use carefully):
```bash
kilo run --auto "fix the failing tests in test/auth.test.ts"
```
{% /tab %}
{% tab label="VSCode (Legacy)" %}
## Video Tour
{% youtube url="https://www.youtube.com/watch?v=pO7zRLQS-p0" caption="This quick tour shows how Kilo Code handles a simple request from start to finish" /%}
After you [set up Kilo Code](/docs/getting-started/setup-authentication), follow these steps:
## Step by Step Guide
### Step 1: Open Kilo Code
@@ -66,6 +166,9 @@ Kilo Code works iteratively. After each action, it waits for your feedback befor
{% image src="/docs/img/your-first-task/your-first-task-8.png" alt="Final result of a completed task showing the iteration process" width="500" caption="After completing the task, Kilo Code shows the final result and awaits your next instruction." /%}
{% /tab %}
{% /tabs %}
## Conclusion
You've completed your first task. Along the way you learned:
@@ -9,7 +9,243 @@ description: "Configure automatic approval settings for Kilo Code operations"
**Security Warning:** Auto-approve settings bypass confirmation prompts, giving Kilo Code direct access to your system. This can result in data loss, file corruption, or worse. Command line access is particularly dangerous, as it can potentially execute harmful operations that could damage your system or compromise security. Only enable auto-approval for actions you fully trust.
{% /callout %}
Auto-approve settings speed up your workflow by eliminating repetitive confirmation prompts, but they significantly increase security risks.
Auto-approve settings speed up your workflow by eliminating repetitive confirmation prompts, but they significantly increase security risks. The **VSCode (Legacy)**, **VSCode**, and **CLI** versions each handle permissions differently — choose the tab that matches your setup.
{% tabs %}
{% tab label="VSCode" %}
## Overview
The extension uses a granular, per-tool permission system. You can configure permissions through the **Settings → Auto Approve** tab, which provides a UI with per-tool **Allow / Ask / Deny** dropdowns.
The UI reads and writes to the same `kilo.jsonc` config files used by the CLI, so changes made in either place are reflected in both.
## Permission Levels
Each tool permission can be set to one of three values:
| Value | Behavior |
| --------- | --------------------------------------------------------- |
| `"allow"` | The tool runs automatically without prompting |
| `"ask"` | Kilo pauses and asks for approval before running the tool |
| `"deny"` | The tool is blocked entirely |
When no rule matches a permission check, the default action is `ask`.
## Available Tool Permissions
The Auto Approve tab lists the following tool-specific permissions. Some tools are grouped together in the UI and share a single permission level:
| Permission | Controls |
| -------------------------- | ------------------------------------------------------ |
| `external_directory` | Accessing files outside the project directory |
| `bash` | Executing shell commands |
| `read` | Reading file contents |
| `edit` | Editing existing files |
| `glob` | File pattern matching / searching by name |
| `grep` | Searching file contents by regex |
| `list` | Listing directory contents |
| `task` | Launching sub-agents |
| `skill` | Loading specialized skills |
| `lsp` | Language server protocol operations |
| `todoread` / `todowrite` | Reading and updating the todo list |
| `websearch` / `codesearch` | Performing web or code searches |
| `webfetch` | Fetching content from URLs |
| `doom_loop` | Allowing the agent to continue after repeated failures |
## Runtime Permission Requests
When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt with two options:
| Option | Behavior |
| -------- | ------------------------------ |
| **Run** | Allow this specific invocation |
| **Deny** | Block this specific invocation |
Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed or denied lists. These rules are then appended to the bottom of the approval rules in settings and the config file.
## Defaults
Most tools default to `"*": "allow"` for a smooth out-of-the-box experience. Notable exceptions that prompt by default:
- **`.env` files** — reading `.env` files prompts for approval. Files matching `*.env.*` (e.g., `.env.local`, `.env.production`) also trigger an ask, while `*.env.example` is explicitly allowed.
- **`external_directory`** — accessing files outside the project prompts for approval
- **`doom_loop`** — prompts when the agent enters a repeated failure cycle
{% /tab %}
{% tab label="CLI" %}
## Overview
The CLI uses a granular, per-tool permission system configured in `kilo.jsonc`. Instead of broad categories like "read" or "write," each tool has its own permission level with glob-pattern rules for fine-grained control.
## Permission Levels
Each tool permission can be set to one of three values:
| Value | Behavior |
| --------- | --------------------------------------------------------- |
| `"allow"` | The tool runs automatically without prompting |
| `"ask"` | Kilo pauses and asks for approval before running the tool |
| `"deny"` | The tool is blocked entirely |
When no rule matches a permission check, the default action is `ask`.
## Available Tool Permissions
Permissions are configured under the `permission` key in `kilo.jsonc`. The following tool-specific permission levels are available:
| Permission | Controls |
| -------------------------- | ------------------------------------------------------ |
| `external_directory` | Accessing files outside the project directory |
| `bash` | Executing shell commands |
| `read` | Reading file contents |
| `edit` | Editing existing files |
| `glob` | File pattern matching / searching by name |
| `grep` | Searching file contents by regex |
| `list` | Listing directory contents |
| `task` | Launching sub-agents |
| `skill` | Loading specialized skills |
| `lsp` | Language server protocol operations |
| `todoread` / `todowrite` | Reading and updating the todo list |
| `websearch` / `codesearch` | Performing web or code searches |
| `webfetch` | Fetching content from URLs |
| `doom_loop` | Allowing the agent to continue after repeated failures |
## Glob-Pattern Rules
Instead of a simple `"allow"` or `"deny"`, each tool can use glob-pattern rules for granular control. Patterns are matched against the tool's arguments (command strings, file paths, etc.), and the last matching rule wins.
### Example: Shell Commands
Allow git commands automatically, but prompt for everything else:
```json
{
"permission": {
"bash": {
"git *": "allow",
"*": "ask"
}
}
}
```
### Example: File Reading
Prompt before reading `.env` files, but allow all other reads:
```json
{
"permission": {
"read": {
"*.env": "ask",
"*": "allow"
}
}
}
```
### Example: Blocking Dangerous Commands
Deny `rm -rf` commands, allow common dev commands, and ask for anything else:
```json
{
"permission": {
"bash": {
"rm -rf *": "deny",
"npm *": "allow",
"bun *": "allow",
"git *": "allow",
"*": "ask"
}
}
}
```
## Per-Agent Permission Overrides
Different agents can have different permission levels. Override the default permissions for a specific agent under the `agent.<name>.permission` key:
```json
{
"permission": {
"bash": { "*": "ask" }
},
"agent": {
"code": {
"permission": {
"bash": { "git *": "allow", "*": "ask" }
}
},
"plan": {
"permission": {
"bash": { "*": "deny" }
}
}
}
}
```
In this example, the `code` agent can run `git` commands automatically and asks for other shell commands, while the `plan` agent cannot run shell commands at all.
## Runtime Permission Requests
When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt. You have three options:
| Option | Behavior |
| ---------------- | -------------------------------------------------------- |
| **Allow once** | Allow this specific invocation only |
| **Allow always** | Allow this tool (or pattern) for the rest of the session |
| **Reject** | Block this specific invocation |
## Defaults
Most tools default to `"*": "allow"` for a smooth out-of-the-box experience. Notable exceptions that prompt by default:
- **`.env` files** — reading `.env` files prompts for approval. Files matching `*.env.*` (e.g., `.env.local`, `.env.production`) also trigger an ask, while `*.env.example` is explicitly allowed.
- **`external_directory`** — accessing files outside the project prompts for approval
- **`doom_loop`** — prompts when the agent enters a repeated failure cycle
## Full Configuration Example
{% callout type="info" %}
This is a custom example showing the available configuration options — it does not represent the shipped defaults.
{% /callout %}
```json
{
"permission": {
"read": { "*.env": "ask", "*": "allow" },
"edit": { "*.env": "ask", "*": "allow" },
"glob": { "*": "allow" },
"grep": { "*": "allow" },
"list": { "*": "allow" },
"bash": { "git *": "allow", "npm *": "allow", "*": "ask" },
"task": { "*": "allow" },
"skill": { "*": "allow" },
"lsp": { "*": "allow" },
"todoread": { "*": "allow" },
"todowrite": { "*": "allow" },
"webfetch": { "*": "allow" },
"websearch": { "*": "allow" },
"codesearch": { "*": "allow" },
"external_directory": { "*": "ask" },
"doom_loop": { "*": "ask" }
},
"agent": {
"code": {
"permission": {
"bash": { "git *": "allow", "npm *": "allow", "*": "ask" }
}
}
}
}
```
{% /tab %}
{% tab label="VSCode (Legacy)" %}
## Quick Start Guide
@@ -343,3 +579,6 @@ You can optionally enable an AI Safety Gatekeeper, which reviews every intended
This is the fastest way to work with Kilo Code, but also the riskiest. Use it only when you fully trust the AI and are prepared for the consequences.
{% /callout %}
{% /tab %}
{% /tabs %}
@@ -1,3 +1,7 @@
---
platform: legacy
---
# Auto Cleanup
Auto Cleanup automatically manages your task history by removing old tasks to free up disk space and improve performance. Tasks are intelligently classified and retained based on their type and age, ensuring important work is preserved while temporary or experimental tasks are cleaned up.
@@ -5,13 +5,88 @@ description: "Configure Kilo Code settings and preferences"
# Settings
The VS Code extension can be configured through the Settings window, opened by pressing the gear icon. Both the CLI and the extension can also be configured through interactions with the agent. The current VS Code extension and CLI share the same underlying settings, so changes in one are reflected in the other.
## Managing Settings
{% tabs %}
{% tab label="VSCode" %}
The VS Code extension provides a **Settings webview UI** accessible from the extension sidebar by clicking the gear icon ({% codicon name="gear" /%}). The UI is organized into tabs including Providers, Auto-Approve, Models, and more.
This UI reads and writes to the same underlying JSONC config files used by the CLI, so changes made in either place are reflected in both.
### Config File Locations
There are two primary config files:
- **Global config:** `~/.config/kilo/kilo.jsonc` — applies to all projects. On Windows, this is `C:\Users\<username>\.config\kilo\kilo.jsonc`.
- **Project config:** `kilo.jsonc` in your project root, or `.kilo/kilo.jsonc` for a cleaner setup. The `.kilo/` version takes priority if both exist.
{% callout type="warning" %}
If you check config files into version control, make sure they do not contain API keys or other secrets (e.g., `provider.*.options.apiKey`). Use environment variables for credentials instead.
{% /callout %}
### Export and Import
Config files are plain-text and portable — copy them between machines and you're done.
{% /tab %}
{% tab label="CLI" %}
In the CLI, settings are managed via **JSONC config files** directly. Config files are plain-text and portable -- you can copy them between machines.
{% callout type="warning" %}
If you check `kilo.jsonc` into version control, make sure it does not contain API keys or other secrets (e.g., `provider.*.options.apiKey`). Use environment variables for credentials instead.
{% /callout %}
### Config File Locations
There are two primary config files:
- **Global config:** `~/.config/kilo/kilo.jsonc` -- applies to all projects. On Windows, this is `C:\Users\<username>\.config\kilo\kilo.jsonc`.
- **Project config:** `kilo.jsonc` in the root of your project -- overrides global settings for that project.
Both files use the [JSONC](https://code.visualstudio.com/docs/languages/json#_json-with-comments) format (JSON with comments).
### Config File Precedence
Settings are resolved through an 8-level precedence system (lowest to highest priority):
1. **Legacy Kilocode** -- migrated settings from the VSCode extension
2. **Remote well-known** -- remotely fetched defaults
3. **Global** -- `~/.config/kilo/kilo.jsonc`
4. **Custom** -- additional custom config paths
5. **Project** -- `kilo.jsonc` in the project root
6. **`.kilo` directory** -- config from a `.kilo/` directory in the project
7. **Inline environment** -- environment variable overrides
8. **Managed / Enterprise** -- enterprise-managed configuration (highest priority)
Higher-priority levels override lower ones. This allows organizations to enforce settings at the enterprise level while still letting individual developers customize their local environment.
### Schema Auto-Injection
When you create or open a `kilo.jsonc` file, the CLI automatically injects a `$schema` property pointing to the config JSON schema. This gives you **autocompletion and validation** in any editor that supports JSON Schema (VS Code, JetBrains, etc.).
### Export and Import
There is no traditional export/import of settings -- the JSONC config files themselves are portable. Copy `~/.config/kilo/kilo.jsonc` or `kilo.jsonc` to another machine and you're done.
For **session** export and import, use the CLI commands:
- `kilo export` -- export session data
- `kilo import` -- import session data
{% /tab %}
{% tab label="VSCode (Legacy)" %}
Kilo Code allows you to manage your configuration settings effectively through export, import, and reset options. These features are useful for backing up your setup, sharing configurations with others, or restoring default settings if needed.
You can find these options at the bottom of the Kilo Code settings page, accessible via the gear icon ({% codicon name="gear" /%}) in the Kilo Code chat view.
{% image src="/docs/img/settings-management/settings-management.png" alt="Export, Import, and Reset buttons in Kilo Code settings" width="800" caption="Export, Import, and Reset buttons" /%}
## Export Settings
### Export Settings
Clicking the **Export** button saves your current Kilo Code settings to a JSON file.
@@ -24,7 +99,7 @@ Clicking the **Export** button saves your current Kilo Code settings to a JSON f
This creates a backup of your configuration or a file you can share.
## Import Settings
### Import Settings
Clicking the **Import** button allows you to load settings from a previously exported JSON file.
@@ -35,7 +110,7 @@ Clicking the **Import** button allows you to load settings from a previously exp
- **Merging:** Importing settings **merges** the configurations. It adds new API profiles and updates existing ones and global settings based on the file content. It does **not** delete configurations present in your current setup but missing from the imported file.
- **Validation:** Only valid settings matching the internal schema can be imported, preventing configuration errors. A success notification appears upon completion.
## Reset Settings
### Reset Settings
Clicking the **Reset** button completely clears all Kilo Code configuration data and returns the extension to its default state. This is a destructive action intended for troubleshooting or starting fresh.
@@ -57,8 +132,24 @@ Clicking the **Reset** button completely clears all Kilo Code configuration data
Use this option only if you are certain you want to remove all Kilo Code data or if instructed during troubleshooting. Consider exporting your settings first if you might want to restore them later.
{% /tab %}
{% /tabs %}
## Experimental Features
{% tabs %}
{% tab label="VSCode" %}
The extension does not currently expose the same experimental feature toggles as the **VSCode (Legacy)** version. Advanced options are configured via the JSONC config files that the Settings webview reads and writes. Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of available options.
{% /tab %}
{% tab label="CLI" %}
The CLI does not currently expose the same experimental feature toggles as the **VSCode (Legacy)** version. Configuration of model behavior, file editing strategies, and other advanced options is handled directly in the JSONC config files. Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of available options.
{% /tab %}
{% tab label="VSCode (Legacy)" %}
{% callout type="info" %}
These features are experimental and may change in future releases. They provide advanced control over Kilo Code's behavior for specific use cases.
{% /callout %}
@@ -106,3 +197,6 @@ This setting controls the number of lines read from a file in one batch. To mana
**Default:** Set in Advanced Settings
You can find this setting in the Kilo Code settings under 'Advanced Settings'.
{% /tab %}
{% /tabs %}
@@ -1,6 +1,7 @@
---
title: "System Notifications"
description: "Configure native OS notifications for Kilo Code"
platform: legacy
---
# System Notifications
@@ -9,6 +9,31 @@ When you install Kilo Code, you'll be prompted to sign in or create a free accou
## Quick Start with Kilo Account
{% tabs %}
{% tab label="VSCode" %}
The extension prompts you to sign in when you first open the sidebar. Click **Sign In** and complete the browser-based flow. The extension communicates with the CLI backend, so authentication is shared between the CLI and extension.
{% /tab %}
{% tab label="CLI" %}
Run the auth command and follow the browser-based sign-in flow:
```bash
kilo auth login
```
This may open your browser to complete authentication. Once signed in, your credentials are stored locally and used for all future sessions.
To verify your auth status:
```bash
kilo auth list
```
{% /tab %}
{% tab label="VSCode (Legacy)" %}
1. Click **"Try Kilo Code for Free"** in the extension
2. Sign in with your Google account
3. Allow VS Code to open the authorization URL
@@ -17,6 +42,9 @@ When you install Kilo Code, you'll be prompted to sign in or create a free accou
That's it! You're ready to [start your first task](/docs/getting-started/quickstart).
{% /tab %}
{% /tabs %}
{% callout type="tip" title="Bonus Credits" %}
[Add credits to your account](https://app.kilo.ai/profile) and get $20 bonus credits, or sign up for [Kilo Pass](https://kilo.ai/features/kilo-pass).
{% /callout %}
@@ -75,12 +103,48 @@ Already have a ChatGPT subscription? You can use it with Kilo Code through the [
### Configuring Your Provider
{% tabs %}
{% tab label="VSCode" %}
1. Open the Kilo Code sidebar in VS Code
2. Click the gear icon ({% codicon name="gear" /%}) to open **Settings**
3. Go to the **Providers** tab
4. Select your provider and enter your API key
5. Choose your model
You can also use `kilo auth login` for providers that support OAuth (like GitHub Copilot). The extension reads from the same underlying config files as the CLI, so provider settings are shared.
{% /tab %}
{% tab label="CLI" %}
Set the API key as an environment variable:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
```
Or use `kilo auth login` for providers that support OAuth (like GitHub Copilot).
To set a default model:
```jsonc
{
"model": "anthropic/claude-sonnet-4-20250514",
}
```
{% /tab %}
{% tab label="VSCode (Legacy)" %}
1. Click the {% kilo-code-icon /%} icon in the VS Code sidebar
2. Select your API provider from the dropdown
3. Paste your API key
4. Choose your model:
4. Choose your model
5. Click **"Let's go!"**
{% /tab %}
{% /tabs %}
{% callout type="info" title="Need Help?" %}
Reach out to our [support team](mailto:hi@kilo.ai) or join our [Discord community](https://kilo.ai/discord).
{% /callout %}
@@ -74,6 +74,10 @@ If the gateway crashes, it's automatically restarted. The machine itself can be
Gateway process info is only available when the machine is running.
{% /callout %}
## Instance Specs
The specs of your instance, including number of CPUs, memory, and storage, are visible at the top right of the instance controls section.
## Settings
### Changing the Model
@@ -77,11 +77,21 @@ You can access instance files in `/root/.openclaw/` directly from the [KiloClaw
For security reasons, SSH access is currently disabled for all KiloClaw instances. Our primary goal is to provide a secure environment for all users, and restricting direct SSH access is one of the many measures we take to ensure the platform remains safe and protected for everyone.
### How can I update my OpenClaw?
### Can I install tailscale on my KiloClaw instance?
Do **not** click **Update Now** inside the OpenClaw Control UI — this is not supported for KiloClaw instances and may break your setup.
Not at this time.
Updates are managed by the KiloClaw platform team to ensure stability. When a new version is available, it will be announced in the **Changelog** on your dashboard. To apply the update, click **Upgrade & Redeploy** from the [KiloClaw Dashboard](/docs/kiloclaw/dashboard#redeploy).
### Can I update the version of Node on my instance?
Not at this time.
### How do I upgrade the OpenClaw version? Can I upgrade it myself?
Updates are managed by the KiloClaw platform team to ensure stability and dependency availability. When a new version is available, it will be announced in the **Changelog** on your dashboard.
To apply the update, click **Upgrade & Redeploy** from the [KiloClaw Dashboard](https://app.kilo.ai/claw). Do **not** click **Update Now** inside the OpenClaw Control UI — this is not supported for KiloClaw instances and may break your setup.
If you need to roll back, use the [version pinning](/docs/kiloclaw/version-pinning) dialog in the Settings tab.
## Architecture Notes
+3 -2
View File
@@ -1,15 +1,16 @@
# Source Code Links
<!-- Auto-generated by script/extract-source-links.ts — DO NOT EDIT -->
<!-- 75 unique URLs extracted from extension and CLI source -->
<!-- 76 unique URLs extracted from extension and CLI source -->
- <https://api.kilo.ai>
<!-- packages/opencode/src/cli/cmd/github.ts -->
- <https://app.kilo.ai>
<!-- packages/opencode/src/kilocode/kilo-commands.tsx -->
<!-- packages/opencode/src/session/retry.ts -->
- <https://app.kilo.ai/config.json>
<!-- packages/opencode/src/config/config.ts -->
- <https://app.kilo.ai/credits>
<!-- packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts -->
- <https://app.kilo.ai/profile>
<!-- packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx -->
- <https://app.kilo.ai/usage>
+4 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.1.2",
"version": "7.1.8",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
@@ -29,6 +29,9 @@
"dependencies": {
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/openai": "2.0.101",
"@ai-sdk/openai-compatible": "1.0.32",
"@openrouter/ai-sdk-provider": "1.5.4",
"@clack/prompts": "1.0.0-alpha.1",
"ai": "catalog:",
@@ -69,3 +69,7 @@ export const HEADER_FEATURE = "X-KILOCODE-FEATURE"
/** Environment variable name for feature override */
export const ENV_FEATURE = "KILOCODE_FEATURE"
export const PROMPTS = ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] as const
export const AI_SDK_PROVIDERS = ["anthropic", "openai", "openai-compatible", "openrouter"] as const
+9 -7
View File
@@ -1,7 +1,7 @@
import { z } from "zod"
import { getKiloUrlFromToken } from "../auth/token.js"
import { DEFAULT_HEADERS } from "../headers.js"
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS } from "./constants.js"
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js"
/**
* OpenRouter model schema
@@ -34,11 +34,9 @@ const openRouterModelSchema = z.object({
opencode: z
.object({
family: z.string().optional(),
prompt: z
.enum(["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"])
.optional()
.catch(undefined),
prompt: z.enum(PROMPTS).optional().catch(undefined),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
ai_sdk_provider: z.enum(AI_SDK_PROVIDERS).optional().catch(undefined),
})
.optional(),
})
@@ -50,12 +48,15 @@ const openRouterModelsResponseSchema = z.object({
type OpenRouterModel = z.infer<typeof openRouterModelSchema>
/**
* Parse API price string to number (e.g. "0.00001" -> 0.00001)
* Parse API price string to number, converting from per-token to per-million-tokens.
* The API returns prices in $/token, but downstream cost calculation (getUsage)
* divides by 1,000,000 expecting $/M tokens.
*/
function parseApiPrice(price: string | null | undefined): number | undefined {
if (!price) return undefined
const parsed = parseFloat(price)
return isNaN(parsed) ? undefined : parsed
if (isNaN(parsed)) return undefined
return parsed * 1_000_000 // Convert $/token → $/M tokens
}
/**
@@ -162,6 +163,7 @@ function transformToModelDevFormat(model: OpenRouterModel): any {
recommendedIndex: model.preferredIndex,
variants: model.opencode?.variants,
prompt: model.opencode?.prompt,
ai_sdk_provider: model.opencode?.ai_sdk_provider,
tool_call: supportsTools,
isFree: model.isFree,
...(inputPrice !== undefined &&
+99
View File
@@ -0,0 +1,99 @@
import { z } from "zod"
import { KILO_API_BASE, MODELS_FETCH_TIMEOUT_MS } from "./constants.js"
import { DEFAULT_HEADERS } from "../headers.js"
/**
* Group entry in an organization mode config.
* Either a simple group name or a tuple for edit with file restrictions.
*/
const EditGroupConfigSchema = z.object({
fileRegex: z.string().optional(),
description: z.string().optional(),
})
const GroupEntrySchema = z.union([z.string(), z.tuple([z.string(), EditGroupConfigSchema])])
const OrganizationModeConfigSchema = z.object({
roleDefinition: z.string().optional(),
whenToUse: z.string().optional(),
description: z.string().optional(),
customInstructions: z.string().optional(),
groups: z.array(GroupEntrySchema).optional(),
})
const OrganizationModeSchema = z.object({
id: z.string(),
organization_id: z.string(),
name: z.string(),
slug: z.string(),
created_by: z.string(),
created_at: z.string(),
updated_at: z.string(),
config: OrganizationModeConfigSchema,
})
const ResponseSchema = z.object({
modes: z.array(OrganizationModeSchema),
})
export type OrganizationModeConfig = z.infer<typeof OrganizationModeConfigSchema>
export type OrganizationMode = z.infer<typeof OrganizationModeSchema>
/**
* In-memory cache for organization modes, keyed by organizationId.
*/
const cache = new Map<string, { modes: OrganizationMode[]; timestamp: number }>()
const TTL = 5 * 60 * 1000 // 5 minutes
/**
* Clear the organization modes cache.
* Should be called when switching organizations.
*/
export function clearModesCache() {
cache.clear()
}
/**
* Fetch custom modes for an organization from the Kilo Cloud API.
*
* @param token - Bearer authentication token
* @param organizationId - Organization UUID
* @returns Array of organization modes, or empty array on error
*/
export async function fetchOrganizationModes(token: string, organizationId: string): Promise<OrganizationMode[]> {
const cached = cache.get(organizationId)
if (cached && Date.now() - cached.timestamp < TTL) {
return cached.modes
}
try {
const url = `${KILO_API_BASE}/api/organizations/${encodeURIComponent(organizationId)}/modes`
const response = await fetch(url, {
headers: {
...DEFAULT_HEADERS,
Authorization: `Bearer ${token}`,
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
})
if (!response.ok) {
console.warn(`[Kilo Gateway] Failed to fetch organization modes: ${response.status}`)
return []
}
const json = await response.json()
const parsed = ResponseSchema.safeParse(json)
if (!parsed.success) {
console.warn("[Kilo Gateway] Organization modes response validation failed:", parsed.error.format())
return []
}
const modes = parsed.data.modes
cache.set(organizationId, { modes, timestamp: Date.now() })
return modes
} catch (err) {
console.warn("[Kilo Gateway] Error fetching organization modes:", err)
return []
}
}
+9
View File
@@ -34,6 +34,12 @@ export {
promptOrganizationSelection,
} from "./api/profile.js"
export { fetchKiloModels } from "./api/models.js"
export {
fetchOrganizationModes,
clearModesCache,
type OrganizationMode,
type OrganizationModeConfig,
} from "./api/modes.js"
export { fetchKilocodeNotifications, type KilocodeNotification } from "./api/notifications.js"
// ============================================================================
@@ -60,6 +66,7 @@ export type {
PollOptions,
PollResult,
// Provider types
KiloProvider,
KiloProviderOptions,
KiloMetadata,
CustomLoaderResult,
@@ -96,4 +103,6 @@ export {
ENV_VERSION,
TESTER_SUPPRESS_VALUE,
ENV_FEATURE,
PROMPTS,
AI_SDK_PROVIDERS,
} from "./api/constants.js"
+33 -6
View File
@@ -1,6 +1,8 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import type { Provider as SDK } from "ai"
import type { KiloProviderOptions } from "./types.js"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import type { KiloProvider, KiloProviderOptions } from "./types.js"
import { getKiloUrlFromToken, getApiKey } from "./auth/token.js"
import { buildKiloHeaders, DEFAULT_HEADERS } from "./headers.js"
import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js"
@@ -21,7 +23,7 @@ import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js"
* const model = provider.languageModel("anthropic/claude-sonnet-4")
* ```
*/
export function createKilo(options: KiloProviderOptions = {}): SDK {
export function createKilo(options: KiloProviderOptions = {}): KiloProvider {
// Get API key from options or environment
const apiKey = getApiKey(options)
@@ -66,11 +68,36 @@ export function createKilo(options: KiloProviderOptions = {}): SDK {
})
}
// Create OpenRouter provider with KiloCode configuration
return createOpenRouter({
const sdkOptions = {
baseURL: openRouterUrl,
apiKey: apiKey ?? ANONYMOUS_API_KEY,
headers: customHeaders,
fetch: wrappedFetch as typeof fetch,
})
}
const openrouter = createOpenRouter(sdkOptions)
const anthropic = createAnthropic(sdkOptions)
const openai = createOpenAI(sdkOptions)
const openaiCompatible = createOpenAICompatible({ ...sdkOptions, name: "openaiCompatible" })
return {
languageModel(modelId) {
return openrouter(modelId)
},
textEmbeddingModel(modelId) {
return openrouter.textEmbeddingModel(modelId)
},
imageModel(modelId) {
return openrouter.imageModel(modelId)
},
anthropic(modelId) {
return anthropic(modelId)
},
openai(modelId) {
return openai(modelId)
},
openaiCompatible(modelId) {
return openaiCompatible(modelId)
},
}
}
@@ -7,6 +7,7 @@
import { fetchProfile, fetchBalance } from "../api/profile.js"
import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js"
import { fetchOrganizationModes, clearModesCache } from "../api/modes.js"
import { KILO_API_BASE, HEADER_FEATURE } from "../api/constants.js"
import { buildKiloHeaders } from "../headers.js"
import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js"
@@ -204,10 +205,84 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
})
ModelCache.clear("kilo")
clearModesCache()
return c.json(true)
},
)
.get(
"/modes",
describeRoute({
summary: "Get organization custom modes",
description: "Fetch custom modes defined for the current organization",
operationId: "kilo.modes",
responses: {
200: {
description: "Organization modes list",
content: {
"application/json": {
schema: resolver(
z.object({
modes: z.array(
z.object({
id: z.string(),
organization_id: z.string(),
name: z.string(),
slug: z.string(),
created_by: z.string(),
created_at: z.string(),
updated_at: z.string(),
config: z.object({
roleDefinition: z.string().optional(),
whenToUse: z.string().optional(),
description: z.string().optional(),
customInstructions: z.string().optional(),
groups: z
.array(
z.union([
z.string(),
z.tuple([
z.string(),
z.object({ fileRegex: z.string().optional(), description: z.string().optional() }),
]),
]),
)
.optional(),
}),
}),
),
}),
),
},
},
},
},
}),
async (c: any) => {
const auth = await Auth.get("kilo")
if (!auth || auth.type !== "oauth") {
return c.json({ modes: [] })
}
const token = auth.access
if (!token) {
return c.json({ modes: [] })
}
const orgId = auth.accountId
if (!orgId) {
return c.json({ modes: [] })
}
try {
const modes = await fetchOrganizationModes(token, orgId)
return c.json({ modes })
} catch {
return c.json({ modes: [] })
}
},
)
.post(
"/fim",
describeRoute({
+7 -1
View File
@@ -1,4 +1,4 @@
import type { Provider as SDK } from "ai"
import type { LanguageModel, Provider, Provider as SDK } from "ai"
import type { LanguageModelV2 } from "@openrouter/ai-sdk-provider"
// ============================================================================
@@ -159,5 +159,11 @@ export interface ProviderInfo {
models: Record<string, any>
}
export type KiloProvider = Provider & {
anthropic(modelId: string): LanguageModel
openai(modelId: string): LanguageModel
openaiCompatible(modelId: string): LanguageModel
}
// Re-export LanguageModelV2 for convenience
export type { LanguageModelV2 }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.1.2",
"version": "7.1.8",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.1.2",
"version": "7.1.8",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.1.2",
"version": "7.1.8",
"type": "module",
"license": "MIT",
"exports": {
@@ -150,15 +150,15 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
margin-bottom: 0px;
}
/* User message copy button: tiny reserved space, button overflows on hover.
* height:2px keeps layout stable; overflow:hidden clips the invisible button;
* on hover overflow:visible lets the button appear without shifting content. */
/* User message copy button: collapse wrapper when idle, show on hover.
* The wrapper must be tall enough to fully contain the button (20px)
* so that hovering the button keeps [data-component="user-message"]
* hovered — otherwise :hover is lost and pointer-events/opacity revert. */
[data-component="user-message"] {
[data-slot="user-message-copy-wrapper"] {
min-height: 0;
height: 10px;
height: 20px;
margin-top: 0;
overflow: hidden;
[data-slot="user-message-meta-wrap"] {
display: none;
@@ -169,11 +169,6 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
height: 20px;
}
}
&:hover [data-slot="user-message-copy-wrapper"],
&:focus-within [data-slot="user-message-copy-wrapper"] {
overflow: visible;
}
}
[data-component="question-answers"] {
@@ -291,3 +286,26 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
opacity: 1;
}
}
[data-component="tool-hint"] {
display: flex;
align-items: start;
gap: 6px;
padding: 4px 8px;
border-left: 2px solid var(--border-weak-base);
[data-component="icon"] {
flex-shrink: 0;
color: var(--icon-weak-base);
margin-top: 1px;
}
[data-slot="tool-hint-message"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: italic;
color: var(--text-weak);
opacity: 0.9;
line-height: var(--line-height-large);
}
}
@@ -1077,6 +1077,20 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</div>
)
}
const hint =
cleaned.includes("before overwriting it. Use the Read tool first") ||
cleaned.includes("has been modified since it was last read") ||
cleaned.includes("oldString and newString are identical") ||
cleaned.includes("must match exactly, including whitespace") ||
cleaned.includes("Found multiple matches for oldString")
if (hint) {
return (
<div data-component="tool-hint">
<Icon name="arrow-right" size="small" />
<span data-slot="tool-hint-message">{cleaned}</span>
</div>
)
}
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
@@ -1292,6 +1306,15 @@ function useToolReveal(pending: () => boolean, animate?: () => boolean) {
function WebfetchMeta(props: { url: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
const data = useData()
const open = (event: MouseEvent) => {
event.stopPropagation()
event.preventDefault()
const handler = data.openUrl
if (handler) return handler(props.url)
window.open(props.url, "_blank", "noopener,noreferrer")
}
return (
<span ref={ref} data-slot="webfetch-meta">
@@ -1302,11 +1325,11 @@ function WebfetchMeta(props: { url: string; animate?: boolean }) {
href={props.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
onClick={open}
>
{props.url}
</a>
<div data-component="tool-action">
<div data-component="tool-action" onClick={open} style={{ cursor: "pointer" }}>
<Icon name="square-arrow-top-right" size="small" />
</div>
</span>
@@ -1993,8 +2016,8 @@ interface ApplyPatchFile {
relativePath: string
type: "add" | "update" | "delete" | "move"
diff: string
before: string
after: string
before?: string
after?: string
additions: number
deletions: number
movePath?: string
@@ -2049,7 +2072,6 @@ ToolRegistry.register({
path={file().relativePath.includes("/") ? getDirectory(file().relativePath) : undefined}
changes={{ additions: file().additions, deletions: file().deletions }}
animate={reveal()}
soft
onClick={
data.openFile && file().filePath
? (e: MouseEvent) => {
@@ -2145,7 +2167,7 @@ ToolRegistry.register({
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<Show when={visible() && file.before !== undefined}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
@@ -2193,14 +2215,16 @@ ToolRegistry.register({
</Switch>
}
>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/>
</div>
<Show when={file().before !== undefined}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/>
</div>
</Show>
</ToolFileAccordion>
)}
</Show>
@@ -1 +1 @@
export * from "@opencode-ai/ui/text-shimmer"
export * from "@opencode-ai/ui/text-shimmer"
@@ -1 +1 @@
export * from "@opencode-ai/ui/tool-count-summary"
export * from "@opencode-ai/ui/tool-count-summary"
@@ -7,7 +7,6 @@ const DEBOUNCE_MS = 100
export interface AutoScrollOptions {
working: () => boolean
onUserInteracted?: () => void
overflowAnchor?: "none" | "auto" | "dynamic"
bottomThreshold?: number
}
@@ -15,10 +14,10 @@ export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let autoTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let auto: { time: number } | undefined
let userInitiated = false
let lastScrollTop: number | undefined
const threshold = () => options.bottomThreshold ?? 10
@@ -37,36 +36,18 @@ export function createAutoScroll(options: AutoScrollOptions) {
return el.scrollHeight - el.clientHeight > 1
}
// Browsers can dispatch scroll events asynchronously. If new content arrives
// between us calling `scrollTo()` and the subsequent `scroll` event firing,
// the handler can see a non-zero `distanceFromBottom` and incorrectly assume
// the user scrolled.
const markAuto = (_el: HTMLElement) => {
auto = { time: Date.now() }
if (autoTimer) clearTimeout(autoTimer)
autoTimer = setTimeout(() => {
auto = undefined
autoTimer = undefined
}, 250)
}
const isAuto = (_el: HTMLElement) => {
const a = auto
if (!a) return false
if (Date.now() - a.time > 250) {
auto = undefined
return false
const markUser = (e: Event) => {
if (e instanceof WheelEvent) {
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
if (scroll && nested && nested !== scroll) return
}
return true
userInitiated = true
}
const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
markAuto(el)
if (behavior === "smooth") {
el.scrollTo({ top: el.scrollHeight, behavior })
return
@@ -74,6 +55,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
// `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
el.scrollTop = el.scrollHeight
lastScrollTop = el.scrollTop
}
const scrollToBottom = (force: boolean) => {
@@ -121,19 +103,28 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
const byUser = userInitiated
userInitiated = false
const distance = distanceFromBottom(el)
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
return
}
if (distanceFromBottom(el) < threshold()) {
if (distance < threshold()) {
if (store.userScrolled) setStore("userScrolled", false)
lastScrollTop = el.scrollTop
return
}
// Ignore scroll events triggered by our own scrollToBottom calls.
if (!store.userScrolled && isAuto(el)) {
scrollToBottom(false)
if (!store.userScrolled && !byUser) {
if (el.scrollTop < (lastScrollTop ?? el.scrollTop)) {
stop()
} else {
scrollToBottomNow("auto")
}
lastScrollTop = el.scrollTop
return
}
@@ -145,7 +136,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
const cur = scroll
if (!cur) return
if (distanceFromBottom(cur) < threshold()) return
if (!store.userScrolled && isAuto(cur)) return
stop()
}, DEBOUNCE_MS)
}
@@ -155,22 +145,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
stop()
}
const updateOverflowAnchor = (el: HTMLElement) => {
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
el.style.overflowAnchor = "none"
return
}
if (mode === "auto") {
el.style.overflowAnchor = "auto"
return
}
el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
}
createResizeObserver(
() => store.contentRef,
() => {
@@ -179,8 +153,16 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (store.userScrolled) setStore("userScrolled", false)
return
}
if (!active()) return
if (store.userScrolled) return
if (!active()) {
if (!store.userScrolled && el && distanceFromBottom(el) > threshold()) {
scrollToBottomNow("auto")
return
}
return
}
if (store.userScrolled) {
return
}
// ResizeObserver fires after layout, before paint.
// Keep the bottom locked in the same frame to avoid visible
// "jump up then catch up" artifacts while streaming content.
@@ -206,18 +188,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
}),
)
createEffect(() => {
// Track `userScrolled` even before `scrollRef` is attached, so we can
// update overflow anchoring once the element exists.
store.userScrolled
const el = scroll
if (!el) return
updateOverflowAnchor(el)
})
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
if (autoTimer) clearTimeout(autoTimer)
if (stopTimer) clearTimeout(stopTimer)
if (cleanup) cleanup()
})
@@ -233,11 +205,19 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (!el) return
updateOverflowAnchor(el)
el.style.overflowAnchor = "auto"
el.addEventListener("wheel", handleWheel, { passive: true })
el.addEventListener("wheel", markUser, { passive: true, capture: true })
el.addEventListener("pointerdown", markUser, { passive: true })
el.addEventListener("keydown", markUser, { passive: true })
el.addEventListener("touchstart", markUser, { passive: true })
cleanup = () => {
el.removeEventListener("wheel", handleWheel)
el.removeEventListener("wheel", markUser, { capture: true })
el.removeEventListener("pointerdown", markUser)
el.removeEventListener("keydown", markUser)
el.removeEventListener("touchstart", markUser)
}
},
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
@@ -40,12 +40,7 @@ export const NoChildren: Story = {
export const Locked: Story = {
render: () => (
<BasicTool
icon="folder"
trigger={{ title: "Writing file", subtitle: "output.txt" }}
defaultOpen
locked
>
<BasicTool icon="folder" trigger={{ title: "Writing file", subtitle: "output.txt" }} defaultOpen locked>
<pre style={{ padding: "8px", margin: 0, "font-size": "12px" }}>{"Hello, world!"}</pre>
</BasicTool>
),
@@ -353,3 +353,91 @@ export const WithContextGroup: Story = {
</AllProviders>
),
}
// --- All 5 tool hint error types in a single screenshot ---
const hintErrors: ToolPart[] = [
{
id: "part-hint-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-hint-001",
tool: "write",
state: {
status: "error",
input: { filePath: "src/config.ts" },
error:
"Error: File already exists — read it before overwriting it. Use the Read tool first to see its current contents.",
time: { start: now - 6000, end: now - 5500 },
},
},
{
id: "part-hint-002",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-hint-002",
tool: "edit",
state: {
status: "error",
input: { filePath: "src/app.tsx" },
error: "Error: File has been modified since it was last read. Please read it again before editing.",
time: { start: now - 5500, end: now - 5000 },
},
},
{
id: "part-hint-003",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-hint-003",
tool: "edit",
state: {
status: "error",
input: { filePath: "src/utils.ts" },
error: "Error: oldString and newString are identical. No changes were made.",
time: { start: now - 5000, end: now - 4500 },
},
},
{
id: "part-hint-004",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-hint-004",
tool: "edit",
state: {
status: "error",
input: { filePath: "src/index.ts" },
error:
"Error: oldString not found in file. The oldString must match exactly, including whitespace and indentation.",
time: { start: now - 4500, end: now - 4000 },
},
},
{
id: "part-hint-005",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-hint-005",
tool: "edit",
state: {
status: "error",
input: { filePath: "src/counter.tsx" },
error:
"Error: Found multiple matches for oldString. Provide more surrounding lines to identify the correct match.",
time: { start: now - 4000, end: now - 3500 },
},
},
]
const mockDataHintErrors = createMockData(hintErrors)
export const ToolHintErrors: Story = {
render: () => (
<AllProviders data={mockDataHintErrors}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
}
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9cad4954b9ac5c0d1a245d14b3ab51836217479870c39589a55eb862c4247aa9
size 18902
oid sha256:81fd232992e804e8e0b3453c4e77d358f8c24dd7495b354b8d7de24c7a43724c
size 18986
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:958b3b690c8e19c8c4ffc451da476954e9334f5619a1b8b40ce30da848240f6d
size 15354
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:876fba84b341bffa55bedb3c6d3384c5099c9a1d9336a3090b429b87e1256110
size 4149
oid sha256:4b690e1cba9e1a7a4eef86162c762092f5908f2d498f0d77f48c44fef1fd44ff
size 4242
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0fc349b25120ba40eaf3f6e6cb9adf268a0433c6a4b4e723f5a9957c555aec25
size 29267
oid sha256:71a4c5ce0d2bd00ac8428a65e3c4db6c0455720cac5467f8f483a9708cea8765
size 29370
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c67bd29525bc38133cc5271d0467e85e131a0bd41313a4033836e7a6bc5e02e2
size 26312
oid sha256:71a4c5ce0d2bd00ac8428a65e3c4db6c0455720cac5467f8f483a9708cea8765
size 29370
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:640fabcec21e9e8e2251e1b347966790d347af9c0b438ca4dc9bc118819069f2
size 4562
oid sha256:4272e7e538d95ee3c7010dca5711a53643573c0efed2c527d6db58ef23d6c086
size 4627
+10
View File
@@ -201,6 +201,16 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw
This package is entirely Kilo-specific — `kilocode_change` markers are NOT needed in any files under `packages/kilo-vscode/`. The markers are only necessary when modifying shared upstream opencode files.
## Process Spawning (Windows)
On Windows, any `spawn`/`execFile`/`exec` call that does not set `windowsHide: true` will flash a cmd.exe console window at the user. To prevent this, **never import `spawn`, `execFile`, or `exec` from `child_process` directly**. Use the wrappers in `src/util/process.ts` instead — they enforce `windowsHide: true` automatically:
```ts
import { spawn, exec } from "../util/process"
```
The `spawn` wrapper covers long-lived processes (e.g. `kilo serve`). The `exec` wrapper covers short commands (e.g. `git`, `tar`). If you need the raw callback form of `execFile` for some reason, pass `windowsHide: true` explicitly in the options object.
## Style
Follow monorepo root AGENTS.md style guide:
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Kilo Code
Copyright (c) 2025 opencode
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -5
View File
@@ -1,9 +1,9 @@
<p align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
<a href="https://x.com/kilocode"><img src="https://img.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)"></a>
<a href="https://blog.kilo.ai"><img src="https://img.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog"></a>
<a href="https://kilo.ai/discord"><img src="https://img.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://img.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit"></a>
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://raster.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
<a href="https://x.com/kilocode"><img src="https://raster.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)"></a>
<a href="https://blog.kilo.ai"><img src="https://raster.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog"></a>
<a href="https://kilo.ai/discord"><img src="https://raster.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://raster.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit"></a>
</p>
# 🚀 Kilo
@@ -0,0 +1,59 @@
# MCP Servers Sub-Tab Parity
**Priority:** P2
The legacy MCP Servers sub-tab (842 lines) had full server lifecycle management. The new sub-tab can only view and remove servers.
## Side-by-Side Comparison
| Feature | Legacy | New |
| ----------------------- | -------------------------------- | ------------------------------------------------------------- |
| View server list | Name + source badge + status dot | Name + command/URL |
| Add server | Via edit config file buttons | Not available |
| Remove server | Delete button + confirmation | Remove button + confirmation |
| Edit server config | Via edit config file buttons | Not available |
| Connection status | Colored dot (green/yellow/red) | Not displayed |
| Enable/disable toggle | `ToggleSwitch` per server | Not available |
| Restart/refresh | Per-server refresh button | Not available |
| Refresh all | "Refresh All MCP Servers" button | Not available |
| Edit Global MCP config | Button to open file | Not available |
| Edit Project MCP config | Button to open file | Not available |
| Network timeout | Per-server dropdown (15s–60min) | Not available |
| Server source badge | Shows "global" / "project" | Not displayed |
| Empty state | N/A | "No MCP servers configured. Edit the opencode config file..." |
### Expandable Server Detail (Legacy Only)
When a server was connected and enabled, expanding it showed 5 tabs:
| Tab | Content |
| ------------ | --------------------------------------------------------------------- |
| Tools | List of MCP tools with tool count |
| Resources | List of resources + resource templates with count |
| Instructions | Server-provided instruction text (if present) |
| Logs | Error/log entries sorted by timestamp with count |
| Auth | OAuth debug info: auth status, token expiration, scopes, refresh info |
The new extension has none of this expandable detail.
## Remaining Work
### Important (P2)
- **Add MCP Server dialog**: Form to define a new server with name, transport type (stdio/SSE), command+args or URL, environment variables. Write to CLI config via `updateConfig()`
- **Edit MCP Server**: Allow modifying existing server configurations
- **Connection status indicators**: Display per-server connection state. Requires CLI to expose MCP connection status via API or SSE events
- **Enable/disable toggle**: Per-server toggle that writes to CLI config without removing the server
- **Restart/refresh button**: Per-server and "refresh all" buttons. Requires CLI endpoint to restart MCP servers
- **Edit config file buttons**: Quick links to open global/project MCP config in the editor
### Nice to Have (P3)
- **Expandable server detail**: Show tools, resources, instructions, and logs per server when expanded. Requires CLI to expose per-server tool/resource/log data
- **Network timeout per server**: If CLI supports per-server timeout config, expose it
- **Server source badge**: Display whether server comes from global or project config
- **OAuth/Auth tab**: Display auth status for OAuth-based MCP servers (depends on CLI support)
## Notes
The mcp-and-mcp-hub.md doc in `non-agent-features/` tracks MCP configuration as a broader feature. This doc focuses specifically on the MCP Servers sub-tab within the Agent Behaviour settings tab. The Marketplace covers MCP server discovery and installation from a registry.
@@ -0,0 +1,93 @@
# Modes / Agents Sub-Tab Parity
**Priority:** P2
The legacy "Modes" sub-tab was a 1794-line component with comprehensive mode management. The new "Agents" sub-tab now covers core CRUD (create, edit, delete) after PR #7225 but is still missing several legacy features.
## Side-by-Side Comparison
### Mode/Agent CRUD
| Feature | Legacy (Modes) | New (Agents) |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| View modes/agents list | List with name + slug | Interactive clickable list with name + description + "custom" badges |
| Select mode to configure | Popover/Command searchable combobox | Click-to-edit in agent list |
| Create new mode | Full dialog: name, slug, save location, role definition, description, when-to-use, tool groups, custom instructions | "Create New Mode" button with form: name (validated), description, system prompt |
| Delete custom mode | Confirmation dialog | Delete button with confirmation dialog |
| Rename custom mode | Inline text field with save/cancel | Not available — name is immutable after creation |
| Export mode | Export to file button | Not available |
| Import mode | Import dialog (project/global) | Not available (marketplace handles this) |
| Edit modes config (JSON) | Dropdown: "Edit Global Modes" / "Edit Project Modes" | Not available |
| Marketplace button | Opens mode marketplace | Not available |
### Per-Mode/Agent Settings
| Setting | Legacy | New |
| ------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| API Configuration (provider profile) | `<Select>` dropdown of all API config profiles | Not applicable (CLI uses per-agent `model` instead of profiles) |
| Role Definition / System Prompt | `<TextArea>` (5 rows) + reset button for built-ins | Multiline auto-resizing textarea (edit view for custom; prompt override for built-in) |
| Description | `<TextField>` + reset button | Editable text field in edit view |
| When to Use | `<TextArea>` (4 rows) + reset button | Not available |
| Tools (tool groups) | Checkbox grid per group, file regex restrictions | Not applicable (CLI uses permission-based tool control) |
| Custom Instructions (per-mode) | `<TextArea>` (10 rows) + link to `.kilocode/rules-{slug}/rules.md` | Not available as separate field (merged into system prompt) |
| Temperature | Not in legacy Modes sub-tab (was in provider profile) | `<TextField>` per agent |
| Top P | Not in legacy Modes sub-tab | `<TextField>` per agent |
| Max Steps | Not in legacy Modes sub-tab | `<TextField>` per agent |
| Model Override | Not in legacy Modes sub-tab (was via API config) | `<TextField>` per agent |
### System Prompt Features
| Feature | Legacy | New |
| ---------------------- | ----------------------------------------------------------------- | ------------- |
| Preview System Prompt | Button to view full rendered prompt in slide-out panel | Not available |
| Copy System Prompt | Clipboard button | Not available |
| Override System Prompt | Advanced disclosure with link to `.kilocode/system-prompt-{slug}` | Not available |
### Global Custom Instructions
| Feature | Legacy | New |
| -------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------- |
| Global Custom Instructions field | `<TextArea>` (4 rows) inside Modes sub-tab | Not available in Agents sub-tab (Rules sub-tab has instruction file paths instead) |
| Link to global rules file | Link to `.kilocode/rules/rules.md` | Not available |
### Organization Features
| Feature | Legacy | New |
| ------------------------- | ------------------------------------------------- | ------------- |
| Share modes banner | `KiloShareModesBanner` component | Not available |
| Organization mode warning | Warning when mode has `source === "organization"` | Not available |
### CLI Agent Fields Not Exposed
These are CLI capabilities beyond what the legacy had:
| CLI Field | Type | Description |
| ------------------------ | ------------------------------ | ---------------------------------------------------- |
| `agent[name].variant` | string | Default thinking/reasoning variant (low/medium/high) |
| `agent[name].permission` | Permission | Per-agent tool permission overrides |
| `agent[name].hidden` | boolean | Hide agent from mode switcher |
| `agent[name].disable` | boolean | Fully disable agent |
| `agent[name].mode` | "subagent" / "primary" / "all" | Agent visibility context |
| `agent[name].color` | string | Agent identification color |
| `agent[name].options` | Record | Arbitrary key-value config |
## Remaining Work
### Important (P2)
- **When to Use field**: Add a text area for orchestrator routing hints (if CLI supports it)
- **System prompt preview**: Add a button to view the full rendered system prompt — requires a CLI endpoint to return the assembled prompt for an agent
- **Import/Export**: Add import (from file) and export (to file) for agent/mode definitions
- **Default variant per agent**: Expose `agent[name].variant` for persistent thinking effort defaults
- **Hidden/disable toggles**: Add visibility controls for agents
### Nice to Have (P3)
- **Rename agent**: Allow renaming custom agents (currently name is immutable after creation)
- **Edit config JSON buttons**: Quick links to open the raw config file for global/project agent config
- **Marketplace integration**: Button to open mode marketplace (covered by marketplace.md)
- **Organization mode features**: Share modes banner and org mode warning (depends on org feature implementation)
- **Agent mode selector**: Dropdown for subagent/primary/all
- **Color picker**: Visual agent identification
- **Per-agent permissions**: Collapsible permission override section per agent
- **Copy system prompt**: Clipboard button for the assembled prompt
@@ -0,0 +1,53 @@
# Rules & Workflows Sub-Tabs Parity
**Priority:** P2 (Rules), P3 (Workflows)
The legacy extension had feature-complete Rules and Workflows sub-tabs with toggle lists, file creation, and global/workspace separation. The new extension has a simpler Rules sub-tab and a stub Workflows sub-tab.
## Rules Sub-Tab Comparison
| Feature | Legacy | New |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Description text + docs link | Yes | No |
| Global rules section | Toggle list of global rule files | Not separated — single flat list of instruction file paths |
| Workspace rules section | Toggle list of workspace rule files | Same flat list |
| Per-rule enable/disable toggle | `ToggleSwitch` per rule file | Not available — paths are either in the list or removed |
| Create new rule file | Input field with validation (.md, .txt, or no extension) per section | Not available |
| Add instruction file path | Not available (auto-discovered from filesystem) | `TextField` + "Add" button |
| Rule source discovery | Auto-discovers from `.kilocode/rules/`, `.kilocoderules`, `.roorules`, `.clinerules`, mode-specific dirs, `AGENTS.md` | Only shows explicitly configured paths in `config.instructions` |
### Key Architectural Difference
The legacy extension auto-discovered rule files from well-known filesystem locations and displayed them with on/off toggles. The new extension treats instruction files as explicit config entries — you add paths to `config.instructions` and they're always active. There's no discovery or toggling.
## Workflows Sub-Tab Comparison
| Feature | Legacy | New |
| ---------------------------------- | --------------------------------------- | ----------------------------------------- |
| Description text + docs link | Yes | Placeholder: "Not yet implemented." |
| Global workflows section | Toggle list of global workflow files | Not available |
| Workspace workflows section | Toggle list of workspace workflow files | Not available |
| Per-workflow enable/disable toggle | `ToggleSwitch` per workflow file | Not available |
| Create new workflow file | Input field per section | Not available |
| Invoke workflow | Type `/workflow-name` in chat | CLI has custom commands (similar concept) |
## Remaining Work
### Rules (P2)
- **Description text**: Add explanatory description with link to docs, matching the legacy pattern
- **Global vs workspace separation**: If the CLI distinguishes between global and workspace instruction files, separate them into two sections
- **Enable/disable toggles**: If rules can be toggled without removing them from config, add toggle switches per rule
- **New rule file creation**: Add a button/input to create a new rule file (not just add an existing path)
- **Auto-discovery**: Consider adding a section showing auto-discovered rule sources (`.kilocode/rules/`, `AGENTS.md`) that the CLI loads regardless of explicit `config.instructions` entries
### Workflows (P3)
- **Implement the sub-tab**: Replace the placeholder with actual workflow management
- **Determine CLI mapping**: The CLI has custom commands — determine whether "workflows" maps to CLI custom commands or is a distinct concept
- **Workflow file management**: Add toggle list for global/workspace workflow files with create/enable/disable
- **Description text**: Add explanatory description matching the legacy pattern
## Notes
The existing `non-agent-features/rules-and-workflows.md` doc covers workflows broadly. This doc adds the specific UI comparison for what the sub-tabs look like in the Agent Behaviour tab. The CLI's `config.instructions` array is a simpler model than the legacy's auto-discovered toggle list — the extension may need to augment it with discovery logic to match parity.
@@ -12,3 +12,19 @@
- Write approved settings to CLI config via `/global/config` endpoint or directly to `opencode.json`
- Show what was migrated and what was not
- Mark migration as complete in `globalState` so it doesn't run again
## Agent Behaviour Tab Settings to Migrate
Settings from the legacy "Agent Behaviour" tab (Modes, MCP Servers, Rules, Workflows, Skills sub-tabs):
| Legacy Setting | CLI Equivalent | Notes |
| ----------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| `customModes` (custom mode definitions) | CLI agent config in `opencode.json` | Map `roleDefinition` → `prompt`, `groups` → CLI tool permissions |
| `customModePrompts` (per-mode overrides) | Per-agent `prompt`, `temperature`, etc. | Map each mode slug to CLI agent name |
| `customInstructions` (global, shown in Modes sub-tab) | Rule files in `.kilocode/rules/` or `config.instructions` | May need a global instructions config key in CLI |
| `modeApiConfigs` (per-mode model) | Per-agent `model` | Map mode slug → agent name → model ID |
| MCP server configs | `config.mcp` | CLI owns MCP config |
| `localRulesToggles` / `globalRulesToggles` | `config.instructions` | Toggle state doesn't map directly — CLI has path list |
| `localWorkflowToggles` / `globalWorkflowToggles` | CLI custom commands (TBD) | Workflow concept mapping needs clarification |
See [Agent Behaviour Tab Parity](../agent-behaviour/) docs for detailed sub-tab comparisons.
@@ -10,3 +10,8 @@
- **Prompts tab**: Shows "Not implemented" message
- **Workflows subtab** (inside AgentBehaviour): Renders a `<Placeholder>` with no functionality
- Import/export settings functionality
## Related Docs
- **Agent Behaviour tab** (all sub-tabs) → [Agent Behaviour Tab Parity](../agent-behaviour/)
- **Workflows subtab** → [Rules & Workflows Sub-Tabs Parity](../agent-behaviour/rules-workflows-subtab-parity.md)
@@ -42,6 +42,19 @@ This document tracks remaining work needed for feature parity with the old exten
---
## Agent Behaviour Tab Parity
The "Agent Behaviour" settings tab contains 5 sub-tabs in both the legacy and new extensions. The legacy tab was a combined 2800+ lines of UI; the new tab is ~820 lines. Each sub-tab has its own parity doc.
| Sub-Tab | Remaining Work | Priority |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| [Modes / Agents](agent-behaviour/modes-subtab-parity.md) | Core CRUD done (PR #7225). Remaining: when-to-use, system prompt preview, import/export, default variant, hidden/disable, org features | P2 |
| [MCP Servers](agent-behaviour/mcp-server-creation.md) | Add/edit servers, connection status, enable/disable toggle, restart, per-server timeout, expandable detail (tools/resources/logs/auth) | P2 |
| [Rules & Workflows](agent-behaviour/rules-workflows-subtab-parity.md) | Rules: description text, global/workspace separation, per-rule toggles, new file creation, auto-discovery. Workflows: entire sub-tab is a stub | P2/P3 |
| Skills | Minor gaps: project/global separation, mode badge per skill. Covered by [Skills System](non-agent-features/skills-system.md) | P2 |
---
## Project Board Issues
Open issues from the [GitHub project board](https://github.com/orgs/Kilo-Org/projects/25/views/1) not covered by the feature docs above. Each item has its own detailed doc.
+8 -17
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.1.2",
"version": "7.1.8",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
@@ -17,6 +17,7 @@
"engines": {
"vscode": "^1.105.1"
},
"license": "MIT",
"author": {
"name": "Kilo Code"
},
@@ -89,11 +90,6 @@
"title": "History",
"icon": "$(history)"
},
{
"command": "kilo-code.new.cloudHistoryButtonClicked",
"title": "Cloud History",
"icon": "$(cloud)"
},
{
"command": "kilo-code.new.profileButtonClicked",
"title": "Profile",
@@ -333,33 +329,28 @@
"when": "view == kilo-code.SidebarProvider"
},
{
"command": "kilo-code.new.agentManagerOpen",
"command": "kilo-code.new.historyButtonClicked",
"group": "navigation@1",
"when": "view == kilo-code.SidebarProvider"
},
{
"command": "kilo-code.new.marketplaceButtonClicked",
"command": "kilo-code.new.agentManagerOpen",
"group": "navigation@2",
"when": "view == kilo-code.SidebarProvider"
},
{
"command": "kilo-code.new.cloudHistoryButtonClicked",
"command": "kilo-code.new.marketplaceButtonClicked",
"group": "navigation@3",
"when": "view == kilo-code.SidebarProvider"
},
{
"command": "kilo-code.new.historyButtonClicked",
"command": "kilo-code.new.profileButtonClicked",
"group": "navigation@4",
"when": "view == kilo-code.SidebarProvider"
},
{
"command": "kilo-code.new.profileButtonClicked",
"group": "navigation@5",
"when": "view == kilo-code.SidebarProvider"
},
{
"command": "kilo-code.new.settingsButtonClicked",
"group": "navigation@6",
"group": "navigation@5",
"when": "view == kilo-code.SidebarProvider"
}
],
@@ -753,7 +744,7 @@
},
"scripts": {
"prepare:cli-binary": "bun script/local-bin.ts",
"compile": "bun run prepare:cli-binary && bun run rebuild-sdk && bun run typecheck && bun run lint && node esbuild.js",
"compile": "bun run prepare:cli-binary -- --force && bun run rebuild-sdk && bun run typecheck && bun run lint && node esbuild.js",
"watch": "bun run rebuild-sdk && bun run --parallel watch:esbuild watch:tsc",
"watch:esbuild": "bun run prepare:cli-binary && node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
+10 -1
View File
@@ -36,9 +36,10 @@ const targets = [
const binDir = join(import.meta.dir, "..", "bin")
const distDir = join(import.meta.dir, "..", "dist")
const outDir = join(import.meta.dir, "..", "out")
const outDirProd = join(import.meta.dir, "..", "out", "prod")
console.log("\n🧹 Cleaning up directories...")
for (const dir of [binDir, distDir, outDir]) {
for (const dir of [binDir, distDir, outDir, outDirProd]) {
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
console.log(` ✓ Cleaned ${dir}`)
@@ -47,6 +48,7 @@ for (const dir of [binDir, distDir, outDir]) {
mkdirSync(outDir, { recursive: true })
mkdirSync(distDir, { recursive: true })
mkdirSync(outDirProd, { recursive: true })
console.log("\n🔄 Rebuilding SDK types (ensures dist/ is in sync with server API)...")
await $`bun run --cwd ${join(import.meta.dir, "..", "..", "sdk", "js")} build`
@@ -87,6 +89,13 @@ for (const config of targets) {
npm_config_ignore_scripts: "true",
})
console.log(` ✅ Created ${vsixPath}`)
const prodVsixPath = join(outDirProd, `kilo-vscode-${config.target}.vsix`)
await $`vsce package --no-dependencies --skip-license --target ${config.target} -o ${prodVsixPath}`.env({
...process.env,
npm_config_ignore_scripts: "true",
})
console.log(` ✅ Created ${prodVsixPath}`)
}
console.log("\n✨ All VSIX packages built successfully!")
+42 -3
View File
@@ -24,11 +24,42 @@ const opencodeDir = join(packagesDir, "opencode")
const targetBinDir = join(kiloVscodeDir, "bin")
const binName = process.platform === "win32" ? "kilo.exe" : "kilo"
const targetBinPath = join(targetBinDir, binName)
const versionFile = join(targetBinDir, ".cli-version")
function log(msg: string) {
console.log(`[local-bin] ${msg}`)
}
async function cliSourceHash(): Promise<string | null> {
try {
const result = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet()
return result.text().trim() || null
} catch {
return null
}
}
async function isDirty(): Promise<boolean> {
try {
const result = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet()
return result.text().trim().length > 0
} catch {
return false
}
}
async function isStale(): Promise<boolean> {
if (await isDirty()) return true
const hash = await cliSourceHash()
if (!hash) return false // can't determine — assume fresh
try {
const stored = (await Bun.file(versionFile).text()).trim()
return stored !== hash
} catch {
return true // no version file — treat as stale
}
}
function platformTag(): string {
const os = process.platform === "win32" ? "windows" : process.platform
return `cli-${os}-${process.arch}`
@@ -114,8 +145,12 @@ async function ensureBuiltBinary(): Promise<string> {
async function main() {
const targetFile = Bun.file(targetBinPath)
const exists = await targetFile.exists()
if ((await targetFile.exists()) && !forceRebuild) {
const stale = exists && !forceRebuild && (await isStale())
const rebuild = forceRebuild || stale
if (exists && !rebuild) {
const st = statSync(targetBinPath)
log(
`CLI binary already present at ${relative(kiloVscodeDir, targetBinPath)} (${Math.round(st.size / 1024 / 1024)}MB). Use --force to rebuild.`,
@@ -123,8 +158,8 @@ async function main() {
return
}
if ((await targetFile.exists()) && forceRebuild) {
log(`Removing existing binary (--force).`)
if (exists && rebuild) {
log(stale ? `CLI source has changed — rebuilding.` : `Removing existing binary (--force).`)
rmSync(targetBinPath)
// Also remove the prebuilt dist so ensureBuiltBinary() triggers a fresh build
const distDir = join(opencodeDir, "dist")
@@ -144,6 +179,10 @@ async function main() {
await $`cp ${sourceBinPath} ${targetBinPath}`
chmodSync(targetBinPath, 0o755)
// Record the CLI source version so future runs detect when a rebuild is needed
const hash = await cliSourceHash()
if (hash) await Bun.write(versionFile, hash + "\n")
log(`Copied CLI binary from ${relative(packagesDir, sourceBinPath)} -> ${relative(kiloVscodeDir, targetBinPath)}`)
}
+6 -5
View File
@@ -7,6 +7,8 @@ import { Script } from "@opencode-ai/script"
console.log(`Publishing VSCode extension for release: v${Script.version}`)
const outDir = process.env.VSIX_DIR || join(import.meta.dir, "..", "out")
const outDirProd = join(outDir, "prod")
console.log(`Using VSIX directory: ${outDir}`)
if (!existsSync(outDir)) {
@@ -37,15 +39,14 @@ console.log(`\nFound ${vsixFiles.length} VSIX files`)
for (const target of targets) {
const vsixPath = join(outDir, `kilo-vscode-${target}.vsix`)
console.log(`\n🚀 Publishing ${target} to VS Code Marketplace...`)
await $`vsce publish --pre-release --packagePath ${vsixPath}`
console.log(` ✅ Published ${target} to VS Code Marketplace`)
// Note: Open VSX publishing is commented out as it doesn't support prereleases
// console.log(`\n📤 Publishing ${target} to Open VSX...`)
// await $`npx ovsx publish ${vsixPath} --target ${target} -p ${process.env.OPENVSX_TOKEN}`
// console.log(` ✅ Published ${target} to Open VSX`)
const prodVsixPath = join(outDirProd, `kilo-vscode-${target}.vsix`)
console.log(`\n📤 Publishing ${target} to Open VSX...`)
await $`npx ovsx publish --pat ${process.env.OPENVSX_TOKEN} --packagePath ${prodVsixPath}`
console.log(` ✅ Published Prod ${target} to Open VSX`)
}
if (Script.release) {
+155 -10
View File
@@ -28,8 +28,13 @@ import {
isEventFromForeignProject,
loadSessions as loadSessionsUtil,
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
resolveContextDirectory,
resolveWorkspaceDirectory,
type SessionRefreshContext,
} from "./kilo-provider-utils"
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService } from "./services/marketplace"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
@@ -75,6 +80,7 @@ import {
disconnectProvider as disconnectProviderAction,
saveCustomProvider as saveCustomProviderAction,
} from "./provider-actions"
import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models"
type KiloProviderOptions = {
projectDirectory?: string | null
@@ -86,6 +92,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private webview: vscode.Webview | null = null
private currentSession: Session | null = null
/** Remembers the last selected session so /new can stay in the same worktree after clearSession. */
private contextSessionID: string | undefined
private connectionState: "connecting" | "connected" | "disconnected" | "error" = "connecting"
private loginAttempt = 0
private isWebviewReady = false
@@ -143,11 +151,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private chatAutocomplete: ChatTextAreaAutocomplete | null = null
private projectDirectory: string | null | undefined
private slimEditMetadata = true
/** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */
private statsPoller: GitStatsPoller | null = null
private cachedStats: unknown = null
/** Optional interceptor called before the standard message handler.
* Return null to consume the message, or return a (possibly transformed) message. */
private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null
/** Handler for "Continue in Worktree" — set by extension.ts to delegate to AgentManagerProvider. */
private continueInWorktreeHandler:
| ((sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise<void>)
| null = null
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
@@ -257,6 +273,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
data: profileData,
})
// Re-send cached worktree stats so the badge renders immediately after webview reload.
if (this.cachedStats) this.postMessage(this.cachedStats)
// Seed session status map so the Settings panel knows about already-running sessions.
// Must run after webview is ready (postMessage is a no-op before that).
void this.seedSessionStatusMap()
@@ -294,6 +313,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(webviewView.webview)
// Pause stats polling when sidebar is hidden, resume when visible
webviewView.onDidChangeVisibility(() => {
this.statsPoller?.setEnabled(webviewView.visible)
})
// Initialize connection to CLI backend
this.initializeConnection()
}
@@ -325,6 +349,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
public registerSession(session: Session): void {
this.currentSession = session
this.contextSessionID = session.id
this.trackedSessionIds.add(session.id)
this.postMessage({
type: "sessionCreated",
@@ -374,6 +399,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "openCloudSession", sessionId })
}
/** Register the handler for "Continue in Worktree" messages from the sidebar. */
public setContinueInWorktreeHandler(
handler: (sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise<void>,
): void {
this.continueInWorktreeHandler = handler
}
/**
* Attach to a webview that already has its own HTML set.
* Sets up message handling and connection without overriding HTML content.
@@ -494,6 +526,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.handleCreateSession()
break
case "clearSession":
this.contextSessionID = this.currentSession?.id ?? this.contextSessionID
this.currentSession = null
break
case "loadMessages":
@@ -540,6 +573,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "openChanges":
vscode.commands.executeCommand("kilo-code.new.showChanges")
break
case "continueInWorktree":
if (message.sessionId && this.continueInWorktreeHandler) {
this.continueInWorktreeHandler(message.sessionId, (status: string, detail?: string, error?: string) => {
this.postMessage({ type: "continueInWorktreeProgress", status, detail, error })
}).catch((err: unknown) => {
console.error("[Kilo New] continueInWorktree failed:", err)
this.postMessage({
type: "continueInWorktreeProgress",
status: "error",
error: err instanceof Error ? err.message : String(err),
})
})
}
break
case "retryConnection":
console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...")
this.initializeConnection().catch((e) =>
@@ -567,6 +614,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "saveCustomProvider":
await this.handleProviderAction(message)
break
case "fetchCustomProviderModels":
this.handleFetchCustomProviderModels(message).catch((e) =>
console.error("[Kilo New] fetchCustomProviderModels failed:", e),
)
break
case "compact":
await this.handleCompact(message.sessionID, message.providerID, message.modelID)
break
@@ -590,6 +642,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "removeMcp":
this.handleRemoveMcp(message.name).catch((e) => console.error("[Kilo New] handleRemoveMcp failed:", e))
break
case "questionReply":
await handleQuestionReply(this.questionCtx, message.requestID, message.answers)
break
@@ -599,6 +652,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestConfig":
this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e))
break
case "requestGlobalConfig":
this.fetchAndSendGlobalConfig().catch((e) => console.error("[Kilo New] fetchAndSendGlobalConfig failed:", e))
break
case "updateConfig":
await this.handleUpdateConfig(message.config)
break
@@ -971,6 +1027,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
])
this.sendNotificationSettings()
// Start polling worktree diff stats for the sidebar badge
this.startStatsPolling()
console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error)
@@ -1004,9 +1063,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
try {
const workspaceDir = this.getWorkspaceDirectory()
const workspaceDir = this.getContextDirectory()
const { data: session } = await this.client.session.create({ directory: workspaceDir }, { throwOnError: true })
this.currentSession = session
this.contextSessionID = session.id
this.trackDirectory(session.id, workspaceDir)
this.trackedSessionIds.add(session.id)
// Notify webview of the new session
@@ -1029,6 +1090,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleLoadMessages(sessionID: string): Promise<void> {
// Track the session so we receive its SSE events
this.trackedSessionIds.add(sessionID)
this.contextSessionID = sessionID
if (!this.client) {
this.postMessage({
@@ -1067,6 +1129,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
.then((result) => {
if (result.data && !abort.signal.aborted) {
this.currentSession = result.data
this.contextSessionID = result.data.id
}
})
.catch((err: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", err))
@@ -1385,6 +1448,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return saveCustomProviderAction(ctx, rid, pid, config, key, this.cachedConfigMessage, set)
}
private async handleFetchCustomProviderModels(msg: Record<string, unknown>): Promise<void> {
const rid = typeof msg.requestId === "string" ? msg.requestId : ""
const url = typeof msg.baseURL === "string" ? msg.baseURL : ""
if (!rid || !url) return
const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined
const headers = msg.headers && typeof msg.headers === "object" ? (msg.headers as Record<string, string>) : undefined
try {
const models = await fetchOpenAIModels({ baseURL: url, apiKey: key, headers })
this.postMessage({ type: "customProviderModelsFetched", requestId: rid, models })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to fetch models"
const auth = err instanceof FetchModelsError && err.auth
this.postMessage({ type: "customProviderModelsFetched", requestId: rid, error: message, auth })
}
}
/**
* Fetch agents (modes) from the backend and send to webview.
*/
@@ -1406,6 +1485,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
type: "agentsLoaded",
agents: visible.map((a) => ({
name: a.name,
displayName: a.displayName,
description: a.description,
mode: a.mode,
native: a.native,
@@ -1662,6 +1742,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
/** Fetch global-only config (no project/managed layers) for settings export. */
private async fetchAndSendGlobalConfig(): Promise<void> {
if (!this.client || this.connectionState !== "connected") return
try {
const { data: config } = await this.client.global.config.get({ throwOnError: true })
this.postMessage({ type: "globalConfigLoaded", config })
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch global config:", error)
}
}
/**
* Seed sessionStatusMap with current session statuses on connect.
* Without this, the Settings panel (which has no tracked sessions) would see
@@ -1853,11 +1944,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async resolveSession(sessionID?: string): Promise<{ sid: string; dir: string } | undefined> {
if (!this.client) return undefined
const dir = this.getWorkspaceDirectory(sessionID || this.currentSession?.id)
const dir = sessionID ? this.getWorkspaceDirectory(sessionID) : this.getContextDirectory()
if (!sessionID && !this.currentSession) {
const { data: session } = await this.client.session.create({ directory: dir }, { throwOnError: true })
this.currentSession = session
this.contextSessionID = session.id
this.trackDirectory(session.id, dir)
this.trackedSessionIds.add(session.id)
this.postMessage({
type: "sessionCreated",
@@ -2117,6 +2210,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
},
set currentSession(session) {
self.currentSession = session
if (session) self.contextSessionID = session.id
},
trackedSessionIds: this.trackedSessionIds,
connectionService: this.connectionService,
@@ -2135,6 +2229,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
getWorkspaceDirectory: () => this.getWorkspaceDirectory(),
disposeGlobal: () => this.disposeGlobal(),
fetchAndSendProviders: () => this.fetchAndSendProviders(),
fetchAndSendAgents: () => this.fetchAndSendAgents(),
}
}
@@ -2256,7 +2351,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (!key.startsWith(prefix)) continue
const parts = key.split(".")
const section = parts.slice(0, -1).join(".")
const leaf = parts[parts.length - 1]
const leaf = parts[parts.length - 1]!
const config = vscode.workspace.getConfiguration(section)
await config.update(leaf, undefined, vscode.ConfigurationTarget.Global)
}
@@ -2355,10 +2450,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Side effects that must happen before the webview message is sent
if (event.type === "session.created" && !this.currentSession) {
this.currentSession = event.properties.info
this.contextSessionID = event.properties.info.id
this.trackedSessionIds.add(event.properties.info.id)
}
if (event.type === "session.updated" && this.currentSession?.id === event.properties.info.id) {
this.currentSession = event.properties.info
this.contextSessionID = event.properties.info.id
}
const msg = mapSSEEventToWebviewMessage(event, sessionID)
@@ -2469,7 +2566,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const uri = tab.input.uri
if (uri.scheme === "file") {
const rel = path.relative(dir, uri.fsPath)
if (!rel.startsWith("..") && controller.validateAccess(uri.fsPath)) {
if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) {
result.add(rel.replaceAll("\\", "/"))
}
}
@@ -2503,7 +2600,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return undefined
}
const relative = path.relative(workspaceDir, fsPath)
if (relative.startsWith("..")) {
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return undefined
}
return relative
@@ -2542,17 +2639,38 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Checks session directory overrides first (e.g., worktree paths), then falls back to workspace root.
*/
private getWorkspaceDirectory(sessionId?: string): string {
if (sessionId) {
const dir = this.sessionDirectories.get(sessionId)
if (dir) return dir
}
return resolveWorkspaceDirectory({
sessionID: sessionId,
sessionDirectories: this.sessionDirectories,
workspaceDirectory: this.getRootDirectory(),
})
}
private getContextDirectory(): string {
return resolveContextDirectory({
currentSessionID: this.currentSession?.id,
contextSessionID: this.contextSessionID,
sessionDirectories: this.sessionDirectories,
workspaceDirectory: this.getRootDirectory(),
})
}
private getRootDirectory(): string {
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders && workspaceFolders.length > 0) {
return workspaceFolders[0].uri.fsPath
return workspaceFolders[0]!.uri.fsPath
}
return process.cwd()
}
private trackDirectory(sessionId: string, dir: string) {
if (path.resolve(dir) === path.resolve(this.getRootDirectory())) {
this.sessionDirectories.delete(sessionId)
return
}
this.sessionDirectories.set(sessionId, dir)
}
private getProjectDirectory(sessionId?: string): string | undefined {
return resolveProjectDirectory(this.projectDirectory, () => this.getWorkspaceDirectory(sessionId))
}
@@ -2601,11 +2719,38 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return this.marketplace
}
// ── Worktree stats polling (sidebar diff badge) ──────────────────
private startStatsPolling(): void {
this.statsPoller?.stop()
const git = new GitOps({ log: () => {} })
this.statsPoller = new GitStatsPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => getWorkspaceRoot(),
getClient: () => this.connectionService.getClient(),
git,
onStats: () => {},
onLocalStats: (stats: LocalStats) => {
const msg = {
type: "worktreeStatsLoaded" as const,
files: stats.files,
additions: stats.additions,
deletions: stats.deletions,
}
this.cachedStats = msg
this.postMessage(msg)
},
log: () => {},
})
this.statsPoller.setEnabled(true)
}
/**
* Dispose of the provider and clean up subscriptions.
* Does NOT kill the server — that's the connection service's job.
*/
dispose(): void {
this.statsPoller?.stop()
this.unsubscribeEvent?.()
this.unsubscribeState?.()
this.unsubscribeNotificationDismiss?.()
@@ -13,10 +13,12 @@ import { versionedName } from "./branch-name"
import { normalizePath, classifyWorktreeError } from "./git-import"
import { SetupScriptService } from "./SetupScriptService"
import { SetupScriptRunner } from "./SetupScriptRunner"
import { copyEnvFiles } from "./env-copy"
import { SessionTerminalManager } from "./SessionTerminalManager"
import { createTerminalHost } from "./terminal-host"
import { executeVscodeTask } from "./task-runner"
import { forkSession } from "./fork-session"
import { continueInWorktree } from "./continue-in-worktree"
import { shouldStopDiffPolling } from "./delete-worktree"
import { buildKeybindingMap } from "./format-keybinding"
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
@@ -102,6 +104,7 @@ export class AgentManagerProvider implements Disposable {
if (this.panel) {
this.log("Panel already open, revealing")
this.panel.reveal()
this.postToWebview({ type: "action", action: "focusInput" })
return
}
this.log("Opening Agent Manager panel")
@@ -117,6 +120,11 @@ export class AgentManagerProvider implements Disposable {
/** Restore the Agent Manager panel from a previously serialized state.
* The caller (extension.ts / vscode-host.ts) wraps the raw panel before passing it. */
public deserializePanel(ctx: PanelContext): void {
if (this.panel) {
this.log("Panel already exists during deserialization, disposing duplicate")
ctx.dispose()
return
}
this.log("Deserializing Agent Manager panel")
this.attachPanel(ctx)
}
@@ -128,6 +136,11 @@ export class AgentManagerProvider implements Disposable {
/** Wire up a panel context (shared by openPanel and deserializePanel). */
private attachPanel(ctx: PanelContext): void {
if (this.panel) {
this.log("Disposing previous panel before attaching new one")
this.panel.dispose()
this.panel = undefined
}
this.panel = ctx
this.stateReady = this.initializeState()
@@ -135,11 +148,15 @@ export class AgentManagerProvider implements Disposable {
this.sendKeybindings()
ctx.onDidDispose(() => {
this.log("Panel disposed")
this.statsPoller.stop()
this.stopDiffPolling()
// Only clear if this is still the active panel — a newer panel may
// have already replaced us via attachPanel.
if (this.panel === ctx) {
this.log("Panel disposed")
this.statsPoller.stop()
this.stopDiffPolling()
this.panel = undefined
}
ctx.sessions.dispose()
this.panel = undefined
})
}
@@ -191,6 +208,17 @@ export class AgentManagerProvider implements Disposable {
if (m.type === "agentManager.deleteWorktree") return this.onDeleteWorktree(m.worktreeId)
if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId)
if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId)
if (m.type === "agentManager.openLocally") {
if (!this.panel) return null
this.panel.sessions.clearSessionDirectory(m.sessionId)
return null
}
if (m.type === "continueInWorktree") {
void this.continueFromSidebar(m.sessionId, (status, detail, error) => {
this.panel?.postMessage({ type: "continueInWorktreeProgress", status, detail, error })
})
return null
}
if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId)
if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId)
if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId)
@@ -1306,10 +1334,14 @@ export class AgentManagerProvider implements Disposable {
}
}
/** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */
/** Copy .env files and run the worktree setup script. Blocks until complete. Shows progress in overlay. */
private async runSetupScriptForWorktree(worktreePath: string, branch?: string, worktreeId?: string): Promise<void> {
const root = this.getRoot()
if (!root) return
// Always copy .env files from the main repo (before the setup script so it can override)
await copyEnvFiles(root, worktreePath, (msg) => this.outputChannel.appendLine(`[EnvCopy] ${msg}`))
try {
const service = this.getSetupScriptService()
if (!service || !service.hasScript()) return
@@ -1790,6 +1822,7 @@ export class AgentManagerProvider implements Disposable {
public focusPanel(): void {
if (!this.panel) return
this.panel.reveal(false)
this.postToWebview({ type: "action", action: "focusInput" })
}
public isActive(): boolean {
@@ -1801,6 +1834,42 @@ export class AgentManagerProvider implements Disposable {
return this.panel?.sessions.getSessionDirectories() ?? new Map()
}
/**
* Continue a sidebar session in a new worktree.
* Captures git state, creates worktree, applies state, forks session.
* Called from KiloProvider when the sidebar sends "continueInWorktree".
*/
public async continueFromSidebar(
sessionId: string,
progress: (status: string, detail?: string, error?: string) => void,
): Promise<void> {
const root = this.getRoot()
if (!root) {
progress("error", undefined, "No workspace folder open")
return
}
this.openPanel()
await this.waitForStateReady("continueFromSidebar")
await continueInWorktree(
{
root,
getClient: () => this.connectionService.getClient(),
createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts),
runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id),
getStateManager: () => this.getStateManager(),
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
registerSession: (session) => this.panel?.sessions.registerSession(session),
notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid),
capture: (event, props) => this.host.capture(event, props),
log: (...args) => this.log(...args),
},
sessionId,
progress,
)
}
public postMessage(message: unknown): void {
this.panel?.postMessage(message)
}
@@ -1,7 +1,7 @@
import * as nodePath from "path"
import * as os from "os"
import * as cp from "child_process"
import * as fs from "fs/promises"
import { spawn } from "../util/process"
import simpleGit from "simple-git"
import { parseWorktreeList, normalizePath } from "./git-import"
@@ -349,26 +349,11 @@ export class GitOps {
private exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const child = cp.execFile(
"git",
args,
{
cwd,
env: options?.env,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
},
(error, stdout, stderr) => {
if (!error) {
resolve({ code: 0, stdout, stderr })
return
}
const exec = error as cp.ExecException
const code = typeof exec.code === "number" ? exec.code : 1
const fallback = exec.message || "Git command failed"
resolve({ code, stdout: stdout ?? "", stderr: stderr || fallback })
},
)
const child = spawn("git", args, {
cwd,
env: options?.env,
stdio: ["pipe", "pipe", "pipe"],
})
if (options?.stdin !== undefined) {
if (!child.stdin) {
@@ -377,6 +362,22 @@ export class GitOps {
}
child.stdin.end(options.stdin)
}
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
child.on("error", (error) => {
resolve({ code: 1, stdout: "", stderr: error.message })
})
child.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(out).toString("utf8"),
stderr: Buffer.concat(err).toString("utf8"),
})
})
})
}
}
@@ -0,0 +1,139 @@
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { CreateWorktreeResult } from "./WorktreeManager"
import type { WorktreeStateManager } from "./WorktreeStateManager"
import { capture as captureGitState, apply as applyGitState, type GitSnapshot } from "./git-transfer"
import { getErrorMessage } from "../kilo-provider-utils"
import { PLATFORM } from "./constants"
export interface ContinueContext {
root: string
getClient: () => KiloClient
createWorktreeOnDisk: (opts: { baseBranch: string }) => Promise<{
worktree: { id: string }
result: CreateWorktreeResult
} | null>
runSetupScript: (path: string, branch: string, worktreeId: string) => Promise<void>
getStateManager: () => WorktreeStateManager | undefined
registerWorktreeSession: (sessionId: string, directory: string) => void
registerSession: (session: Session) => void
notifyReady: (sessionId: string, result: CreateWorktreeResult, worktreeId: string) => void
capture: (event: string, props: Record<string, unknown>) => void
log: (...args: unknown[]) => void
}
/** Result type for each step — either success with a value or an error string. */
export type StepResult<T> = { ok: true; value: T } | { ok: false; error: string }
/** Abort a running session. Best-effort — failures are logged but not fatal. */
export async function abortSession(ctx: ContinueContext, sessionId: string): Promise<void> {
try {
const client = ctx.getClient()
await client.session.abort({ sessionID: sessionId }).catch((err) => {
ctx.log("Session abort failed (may already be idle):", getErrorMessage(err))
})
} catch (err) {
ctx.log("Client not available for abort, continuing:", getErrorMessage(err))
}
}
/** Capture git state from the workspace root. */
export async function captureState(ctx: ContinueContext): Promise<StepResult<GitSnapshot>> {
try {
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args))
return { ok: true, value: snapshot }
} catch (err) {
return { ok: false, error: `Failed to capture git state: ${getErrorMessage(err)}` }
}
}
/** Create a worktree and run the setup script. */
export async function prepareWorktree(
ctx: ContinueContext,
branch: string,
): Promise<StepResult<{ worktreeId: string; result: CreateWorktreeResult }>> {
const created = await ctx.createWorktreeOnDisk({ baseBranch: branch })
if (!created) return { ok: false, error: "Failed to create worktree" }
await ctx.runSetupScript(created.result.path, created.result.branch, created.worktree.id)
return { ok: true, value: { worktreeId: created.worktree.id, result: created.result } }
}
/** Apply a git snapshot to a worktree directory. */
export async function transferState(
ctx: ContinueContext,
snapshot: GitSnapshot,
target: string,
): Promise<StepResult<void>> {
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args))
if (!applied.ok) {
ctx.log("Git state transfer failed:", applied.error)
return { ok: false, error: applied.error ?? "Failed to apply changes to worktree" }
}
return { ok: true, value: undefined }
}
/** Fork the session into the worktree directory. */
export async function forkSession(ctx: ContinueContext, sessionId: string, dir: string): Promise<StepResult<Session>> {
let client: KiloClient
try {
client = ctx.getClient()
} catch (err) {
ctx.log("Client not available for session fork:", getErrorMessage(err))
return { ok: false, error: "Not connected to CLI backend" }
}
try {
const { data } = await client.session.fork({ sessionID: sessionId, directory: dir }, { throwOnError: true })
return { ok: true, value: data }
} catch (err) {
return { ok: false, error: `Failed to fork session: ${getErrorMessage(err)}` }
}
}
/** Register the forked session in state and emit telemetry. */
export function registerSession(
ctx: ContinueContext,
session: Session,
result: CreateWorktreeResult,
worktreeId: string,
sourceId: string,
): void {
const state = ctx.getStateManager()
if (state) state.addSession(session.id, worktreeId)
ctx.registerWorktreeSession(session.id, result.path)
ctx.registerSession(session)
ctx.notifyReady(session.id, result, worktreeId)
ctx.capture("Continue in Worktree", { source: PLATFORM, sessionId: session.id, worktreeId })
ctx.log(`Continued sidebar session ${sourceId} → worktree ${worktreeId} (session ${session.id})`)
}
/**
* Continue a sidebar session in a new worktree.
* Orchestrates the atomic steps: abort → capture → prepare → transfer → fork → register.
*
* Pure orchestration — no vscode imports.
*/
export async function continueInWorktree(
ctx: ContinueContext,
sessionId: string,
progress: (status: string, detail?: string, error?: string) => void,
): Promise<void> {
await abortSession(ctx, sessionId)
progress("capturing", "Capturing git changes...")
const captured = await captureState(ctx)
if (!captured.ok) return progress("error", undefined, captured.error)
progress("creating", "Creating worktree...")
const prepared = await prepareWorktree(ctx, captured.value.branch)
if (!prepared.ok) return progress("error", undefined, prepared.error)
progress("transferring", "Transferring changes...")
const transferred = await transferState(ctx, captured.value, prepared.value.result.path)
if (!transferred.ok) return progress("error", undefined, transferred.error)
progress("forking", "Starting session...")
const forked = await forkSession(ctx, sessionId, prepared.value.result.path)
if (!forked.ok) return progress("error", undefined, forked.error)
registerSession(ctx, forked.value, prepared.value.result, prepared.value.worktreeId, sessionId)
progress("done")
}
@@ -0,0 +1,85 @@
/**
* Copies .env files from the main repository into a worktree.
*
* Runs automatically before the user setup script so worktrees inherit
* environment configuration without manual copying. Only plain files
* named exactly `.env` or matching `.env.<qualifier>` (e.g. `.env.local`,
* `.env.development`) at the repo root are considered. Files like `.envrc`
* or `.environment` are excluded — they follow different conventions.
* Nested directories are not traversed and existing files in the worktree
* are never overwritten.
*/
import * as fs from "node:fs"
import { constants } from "node:fs"
import * as path from "node:path"
/** Result returned after a copy attempt. */
export interface EnvCopyResult {
/** Files that were copied. */
copied: string[]
/** Files that were skipped because they already exist in the worktree. */
skipped: string[]
}
type Log = (msg: string) => void
/** Match `.env` exactly, or `.env.` followed by a qualifier. */
function isEnvFile(name: string): boolean {
return name === ".env" || name.startsWith(".env.")
}
/**
* List `.env` / `.env.*` filenames at the root of `dir`.
* Returns basenames only (e.g. `[".env", ".env.local"]`).
*/
export function listEnvFiles(dir: string): string[] {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.filter((e) => e.isFile() && isEnvFile(e.name)).map((e) => e.name)
} catch {
return []
}
}
/**
* Copy `.env` / `.env.*` files from `repoPath` into `worktreePath`.
*
* - Only root-level files named `.env` or `.env.<qualifier>` are copied.
* - Files that already exist in the worktree are skipped (no overwrite).
* - Cross-platform: uses only Node `fs` APIs.
*/
export async function copyEnvFiles(
repoPath: string,
worktreePath: string,
log: Log = () => {},
): Promise<EnvCopyResult> {
const names = listEnvFiles(repoPath)
if (names.length === 0) {
log("No .env files found in main repo")
return { copied: [], skipped: [] }
}
const result: EnvCopyResult = { copied: [], skipped: [] }
for (const name of names) {
const src = path.join(repoPath, name)
const dst = path.join(worktreePath, name)
try {
// COPYFILE_EXCL fails atomically if dst exists — no TOCTOU race.
await fs.promises.copyFile(src, dst, constants.COPYFILE_EXCL)
log(`Copied ${name}`)
result.copied.push(name)
} catch (err: unknown) {
if (err instanceof Error && "code" in err && err.code === "EEXIST") {
log(`Skipping ${name} (already exists in worktree)`)
result.skipped.push(name)
continue
}
log(`Failed to copy ${name}: ${err}`)
}
}
return result
}
@@ -0,0 +1,161 @@
import * as nodePath from "path"
import * as fs from "fs/promises"
import * as cp from "child_process"
/**
* Portable git state snapshot — captures uncommitted changes as patches
* that can be applied to any directory on the same commit.
*
* Used by "Continue in Worktree" to copy git state from the user's
* working tree into a fresh worktree without modifying the source.
*/
export interface GitSnapshot {
branch: string
head: string
/** Binary-safe unified diff of unstaged changes, or null if clean. */
unstaged: string | null
/** Binary-safe unified diff of staged changes, or null if none staged. */
staged: string | null
/** Untracked files (new files not yet added to git). */
untracked: UntrackedFile[]
}
export interface UntrackedFile {
/** Relative path from repo root. */
path: string
/** Raw file content. */
content: Buffer
}
const MAX_FILE = 10 * 1024 * 1024 // 10 MB
function git(args: string[], cwd: string, stdin?: string): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
if (stdin !== undefined) {
// Use spawn for stdin piping — execFile doesn't reliably create a stdin pipe
const child = cp.spawn("git", args, { cwd, windowsHide: true })
let stdout = ""
let stderr = ""
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()))
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()))
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }))
child.stdin.end(stdin)
} else {
cp.execFile(
"git",
args,
{ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true },
(error, stdout, stderr) => {
if (!error) {
resolve({ code: 0, stdout, stderr })
return
}
const exec = error as cp.ExecException
resolve({ code: typeof exec.code === "number" ? exec.code : 1, stdout: stdout ?? "", stderr: stderr ?? "" })
},
)
}
})
}
async function raw(args: string[], cwd: string): Promise<string> {
const result = await git(args, cwd)
return result.stdout.trim()
}
/**
* Capture the current git state from `cwd` as a portable snapshot.
* This is a read-only operation — the source directory is never modified.
*/
export async function capture(cwd: string, log: (...args: unknown[]) => void): Promise<GitSnapshot> {
const patch = (args: string[]) =>
git(args, cwd).then((r) => {
const out = r.stdout
return out.trim() ? out : null
})
const [branch, head, unstaged, staged, untrackedRaw] = await Promise.all([
raw(["branch", "--show-current"], cwd),
raw(["rev-parse", "HEAD"], cwd),
patch(["diff", "--binary"]),
patch(["diff", "--cached", "--binary"]),
raw(["ls-files", "--others", "--exclude-standard"], cwd).then((s: string) =>
s.split("\n").filter((l: string) => l.length > 0),
),
])
const untracked: UntrackedFile[] = []
for (const rel of untrackedRaw) {
const full = nodePath.resolve(cwd, rel)
try {
const stat = await fs.stat(full)
if (stat.size > MAX_FILE) {
log(`Skipping untracked file ${rel}: ${(stat.size / 1024 / 1024).toFixed(1)} MB exceeds limit`)
continue
}
const content = await fs.readFile(full)
untracked.push({ path: rel, content })
} catch (err) {
log(`Failed to read untracked file ${rel}:`, err)
}
}
return { branch, head, unstaged, staged, untracked }
}
/**
* Apply a git snapshot to a target directory.
* Applies staged changes (and re-stages them), unstaged changes, and writes untracked files.
*/
export async function apply(
snapshot: GitSnapshot,
target: string,
log: (...args: unknown[]) => void,
): Promise<{ ok: boolean; error?: string }> {
// Apply staged patch first, then re-stage those files
if (snapshot.staged) {
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged)
if (result.code !== 0) {
const msg = result.stderr.trim() || "Patch did not apply"
log("Failed to apply staged patch:", msg)
return { ok: false, error: `Staged patch failed: ${msg}` }
}
const files = parsePatchFiles(snapshot.staged)
if (files.length > 0) {
await git(["add", "--", ...files], target)
}
}
// Apply unstaged patch (leave as unstaged working-tree changes)
if (snapshot.unstaged) {
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged)
if (result.code !== 0) {
const msg = result.stderr.trim() || "Patch did not apply"
log("Failed to apply unstaged patch:", msg)
return { ok: false, error: `Unstaged patch failed: ${msg}` }
}
}
// Write untracked files
for (const file of snapshot.untracked) {
const full = nodePath.resolve(target, file.path)
try {
await fs.mkdir(nodePath.dirname(full), { recursive: true })
await fs.writeFile(full, file.content)
} catch (err) {
log(`Failed to write untracked file ${file.path}:`, err)
}
}
return { ok: true }
}
/** Extract file paths from a unified diff's `diff --git a/... b/...` headers. */
function parsePatchFiles(patch: string): string[] {
const files: string[] = []
for (const line of patch.split("\n")) {
const match = /^diff --git a\/.+ b\/(.+)$/.exec(line)
if (match && match[1]) files.push(match[1])
}
return files
}
@@ -11,11 +11,9 @@
* process.env.PATH so all subsequent child_process calls benefit.
*/
import { type ExecFileOptionsWithStringEncoding, execFile } from "child_process"
import { type ExecFileOptionsWithStringEncoding } from "child_process"
import * as os from "os"
import { promisify } from "util"
const run = promisify(execFile)
import { exec } from "../util/process"
// Environment variable keys match: letters, digits, underscores, starting with a non-digit.
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
@@ -70,7 +68,7 @@ export async function getShellEnvironment(): Promise<Record<string, string>> {
const shell = process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash")
try {
const { stdout } = await run(shell, ["-lc", "env"], {
const { stdout } = await exec(shell, ["-lc", "env"], {
timeout: 10_000,
env: { ...process.env, HOME: os.homedir() },
})
@@ -127,7 +125,7 @@ export async function execWithShellEnv(
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Promise<{ stdout: string; stderr: string }> {
try {
return await run(cmd, args, { ...options, encoding: "utf8" })
return await exec(cmd, args, options)
} catch (error) {
if (
process.platform !== "darwin" ||
@@ -141,13 +139,13 @@ export async function execWithShellEnv(
// Already resolved and PATH was actually changed — no point retrying resolution.
// Just retry with the (already-patched) process.env.
if (fixed) {
return await run(cmd, args, { ...options, encoding: "utf8" })
return await exec(cmd, args, options)
}
// If another caller is already resolving, wait for it then retry.
if (fixing) {
await fixing
return await run(cmd, args, { ...options, encoding: "utf8" })
return await exec(cmd, args, options)
}
console.log(`[shell-env] "${cmd}" not found, resolving shell environment`)
@@ -159,7 +157,7 @@ export async function execWithShellEnv(
fixing = null
}
return await run(cmd, args, { ...options, encoding: "utf8" })
return await exec(cmd, args, options)
}
}
@@ -229,6 +229,11 @@ interface PromoteSessionIn {
sessionId: string
}
interface OpenLocallyIn {
type: "agentManager.openLocally"
sessionId: string
}
interface AddSessionToWorktreeIn {
type: "agentManager.addSessionToWorktree"
worktreeId: string
@@ -411,12 +416,18 @@ interface AbortIn {
sessionID: string
}
interface ContinueInWorktreeIn {
type: "continueInWorktree"
sessionId: string
}
/** All messages the Agent Manager expects from the webview (onMessage input). */
export type AgentManagerInMessage =
| CreateWorktreeIn
| DeleteWorktreeIn
| RemoveStaleWorktreeIn
| PromoteSessionIn
| OpenLocallyIn
| AddSessionToWorktreeIn
| CloseSessionIn
| ForkSessionIn
@@ -451,3 +462,4 @@ export type AgentManagerInMessage =
| LoadMessagesIn
| ClearSessionIn
| AbortIn
| ContinueInWorktreeIn
+31 -3
View File
@@ -56,11 +56,22 @@ export function activate(context: vscode.ExtensionContext) {
}),
)
// Ensure Agent Manager keybindings work when a VS Code terminal has focus.
// The terminal intercepts all keystrokes unless the command is listed in
// terminal.integrated.commandsToSkipShell, which only contains built-in
// commands by default.
ensureCommandsSkipShell(["kilo-code.new.agentManagerOpen", "kilo-code.new.agentManager.showTerminal"])
// Create Agent Manager provider for editor panel
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context)
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService)
context.subscriptions.push(agentManagerProvider)
// Wire "Continue in Worktree" from sidebar → Agent Manager
provider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
// Register serializer so Agent Manager restores when VS Code restarts
context.subscriptions.push(
vscode.window.registerWebviewPanelSerializer(AgentManagerProvider.viewType, {
@@ -155,9 +166,6 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("kilo-code.new.historyButtonClicked", () => {
provider.postMessage({ type: "action", action: "historyButtonClicked" })
}),
vscode.commands.registerCommand("kilo-code.new.cloudHistoryButtonClicked", () => {
provider.postMessage({ type: "action", action: "cloudHistoryButtonClicked" })
}),
vscode.commands.registerCommand("kilo-code.new.cycleAgentMode", () => {
provider.postMessage({ type: "action", action: "cycleAgentMode" })
agentManagerProvider.postMessage({ type: "action", action: "cycleAgentMode" })
@@ -354,6 +362,26 @@ async function openKiloInNewTab(context: vscode.ExtensionContext, connectionServ
)
}
/**
* Add extension commands to terminal.integrated.commandsToSkipShell so they
* work when a VS Code terminal has focus. The setting only ships with built-in
* commands; extension commands must be added explicitly.
*/
function ensureCommandsSkipShell(commands: string[]): void {
const config = vscode.workspace.getConfiguration("terminal.integrated")
const info = config.inspect<string[]>("commandsToSkipShell")
// Update whichever scope already carries an override so we don't
// shadow workspace settings or leak workspace values into global.
const [existing, target] = info?.workspaceFolderValue
? [info.workspaceFolderValue, vscode.ConfigurationTarget.WorkspaceFolder]
: info?.workspaceValue
? [info.workspaceValue, vscode.ConfigurationTarget.Workspace]
: [info?.globalValue ?? [], vscode.ConfigurationTarget.Global]
const missing = commands.filter((cmd) => !existing.includes(cmd))
if (missing.length === 0) return
config.update("commandsToSkipShell", [...existing, ...missing], target)
}
function waitForWebviewPanelToBeActive(panel: vscode.WebviewPanel): Promise<void> {
if (panel.active) {
return Promise.resolve()
@@ -167,6 +167,32 @@ export function buildSettingPath(key: string): { section: string; leaf: string }
return { section, leaf }
}
export function resolveWorkspaceDirectory(input: {
sessionID?: string
sessionDirectories: Map<string, string>
workspaceDirectory: string
}) {
if (!input.sessionID) return input.workspaceDirectory
const dir = input.sessionDirectories.get(input.sessionID)
if (dir) return dir
return input.workspaceDirectory
}
export function resolveContextDirectory(input: {
currentSessionID?: string
contextSessionID?: string
sessionDirectories: Map<string, string>
workspaceDirectory: string
}) {
return resolveWorkspaceDirectory({
sessionID: input.currentSessionID ?? input.contextSessionID,
sessionDirectories: input.sessionDirectories,
workspaceDirectory: input.workspaceDirectory,
})
}
export type WebviewMessage =
| {
type: "partUpdated"
@@ -14,6 +14,7 @@ export interface AuthContext {
getWorkspaceDirectory(): string
disposeGlobal(): Promise<void>
fetchAndSendProviders(): Promise<void>
fetchAndSendAgents(): Promise<void>
}
/**
@@ -131,6 +132,11 @@ export async function handleSetOrganization(ctx: AuthContext, organizationId: st
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to refresh providers after org switch:", error)
}
try {
await ctx.fetchAndSendAgents()
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to refresh agents after org switch:", error)
}
}
/** Handle profile refresh request. */
@@ -6,7 +6,7 @@
*/
/** Strip a filediff down to path + addition/deletion counts. */
function slimMeta(meta: unknown): Record<string, unknown> | undefined {
function slimEditMeta(meta: unknown): Record<string, unknown> | undefined {
if (!meta || typeof meta !== "object") return undefined
const obj = meta as Record<string, unknown>
@@ -30,7 +30,7 @@ function slimMeta(meta: unknown): Record<string, unknown> | undefined {
return result
}
/** Strip heavy metadata from a single edit tool part; pass-through for all other part types. */
/** Strip heavy metadata from a single edit tool part; pass-through for everything else. */
export function slimPart<T>(part: T): T {
if (!part || typeof part !== "object") return part
@@ -41,7 +41,7 @@ export function slimPart<T>(part: T): T {
if (!state || typeof state !== "object") return part
const next = { ...(state as Record<string, unknown>) }
const meta = slimMeta(next.metadata)
const meta = slimEditMeta(next.metadata)
if (meta) next.metadata = meta
else delete next.metadata
@@ -65,6 +65,10 @@ export class AutocompleteModel {
let inputTokens = 0
let outputTokens = 0
// Capture SSE-level errors so they propagate to the caller. The SDK's SSE
// client catches HTTP errors (402, 401, 429, 5xx) internally and silently
// ends the stream. Without this, errors never reach ErrorBackoff.
let sseError: Error | undefined
const { stream } = await client.kilo.fim(
{
prefix,
@@ -73,7 +77,13 @@ export class AutocompleteModel {
maxTokens: 256,
temperature: 0.2,
},
{ signal },
{
signal,
sseMaxRetryAttempts: 1,
onSseError: (error) => {
sseError = error instanceof Error ? error : new Error(String(error))
},
},
)
for await (const chunk of stream) {
@@ -86,6 +96,8 @@ export class AutocompleteModel {
if (chunk.cost !== undefined) cost = chunk.cost
}
if (sseError) throw sseError
return {
cost,
inputTokens,
@@ -127,4 +139,20 @@ export class AutocompleteModel {
}
return this.connectionService.getConnectionState() === "connected"
}
/**
* Check the user's credit balance via the profile endpoint.
* Returns true if the user has a positive balance, false otherwise.
* Returns false on any error (not connected, fetch failed, etc.).
*/
public async hasBalance(): Promise<boolean> {
if (!this.connectionService || this.connectionService.getConnectionState() !== "connected") {
return false
}
const result = await this.connectionService
.getClient()
.kilo.profile()
.catch(() => null)
return (result?.data?.balance?.balance ?? 0) > 0
}
}
@@ -59,6 +59,7 @@ export class AutocompleteServiceManager {
public readonly inlineCompletionProvider: AutocompleteInlineCompletionProvider
private inlineCompletionProviderDisposable: vscode.Disposable | null = null
private unsubscribeState: (() => void) | null = null
private unsubscribeEvent: (() => void) | null = null
constructor(context: vscode.ExtensionContext, connectionService: KiloConnectionService) {
if (AutocompleteServiceManager._instance) {
@@ -84,14 +85,26 @@ export class AutocompleteServiceManager {
() => this.settings,
workspacePath,
new AutocompleteTelemetry(),
(status) => this.handleFatalAutocompleteError(status),
)
// Reload when CLI backend connection state changes so autocomplete
// picks up the connected state even if it wasn't ready at startup.
// Also reset error backoff — a reconnect may mean the user re-authenticated
// or added credits, so we should give autocomplete a fresh chance.
this.unsubscribeState = connectionService.onStateChange(() => {
this.inlineCompletionProvider.resetBackoff()
void this.load()
})
// Reset error backoff when auth state changes (login, logout, org switch).
// The CLI emits global.disposed after these actions, which is the most
// reliable signal that credentials may have changed.
this.unsubscribeEvent = connectionService.onEventFiltered(
(event) => event.type === "global.disposed",
() => this.inlineCompletionProvider.resetBackoff(),
)
void this.load()
}
@@ -312,6 +325,27 @@ export class AutocompleteServiceManager {
return !this.model.hasValidCredentials()
}
/**
* Handle a fatal (non-retriable) autocomplete error such as 402 Payment Required.
* Shows a one-time notification to the user so they know autocomplete is paused.
*/
private handleFatalAutocompleteError(status: number | null): void {
const msg =
status === 402
? t("kilocode:autocomplete.creditsExhausted.message")
: t("kilocode:autocomplete.authError.message")
if (status === 402) {
vscode.window.showWarningMessage(msg, t("kilocode:autocomplete.creditsExhausted.addCredits")).then((choice) => {
if (choice === t("kilocode:autocomplete.creditsExhausted.addCredits")) {
vscode.env.openExternal(vscode.Uri.parse("https://app.kilo.ai/credits"))
}
})
} else {
vscode.window.showWarningMessage(msg)
}
}
private updateCostTracking(cost: number, _inputTokens: number, _outputTokens: number): void {
this.completionCount++
this.sessionCost += cost
@@ -360,9 +394,11 @@ export class AutocompleteServiceManager {
this.snoozeTimer = null
}
// Unsubscribe from connection state changes
// Unsubscribe from connection state changes and SSE events
this.unsubscribeState?.()
this.unsubscribeState = null
this.unsubscribeEvent?.()
this.unsubscribeEvent = null
// Dispose inline completion provider registration
if (this.inlineCompletionProviderDisposable) {
@@ -22,7 +22,6 @@ import {
calcDebounceDelay,
MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn,
} from "./inline-utils"
import { HoleFiller } from "./HoleFiller"
import { FimPromptBuilder } from "./FillInTheMiddle"
import { AutocompleteModel } from "../AutocompleteModel"
import { ContextRetrievalService } from "../continuedev/core/autocomplete/context/ContextRetrievalService"
@@ -34,6 +33,7 @@ import { postprocessAutocompleteSuggestion } from "./uselessSuggestionFilter"
import { shouldSkipAutocomplete } from "./contextualSkip"
import { FileIgnoreController } from "../shims/FileIgnoreController"
import { AutocompleteTelemetry } from "./AutocompleteTelemetry"
import { ErrorBackoff } from "./ErrorBackoff"
const MAX_SUGGESTIONS_HISTORY = 20
@@ -119,7 +119,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
public suggestionsHistory: FillInAtCursorSuggestion[] = []
/** Tracks all pending/in-flight requests */
private pendingRequests: PendingRequest[] = []
private holeFiller: HoleFiller
private fimPromptBuilder: FimPromptBuilder
private model: AutocompleteModel
private costTrackingCallback: CostTrackingCallback
@@ -137,6 +136,12 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
private telemetry: AutocompleteTelemetry | null
/** Information about the last suggestion shown to the user */
private lastSuggestion: LastSuggestionInfo | null = null
/** Circuit breaker / exponential backoff for API errors */
public readonly backoff = new ErrorBackoff()
/** Optional callback fired once when a fatal (non-retriable) error is first detected */
private onFatalError: ((status: number | null) => void) | null = null
/** Whether the fatal error notification has already been fired (avoid repeating) */
private fatalNotified = false
constructor(
context: vscode.ExtensionContext,
@@ -145,11 +150,13 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
getSettings: () => AutocompleteServiceSettings | null,
workspacePath: string,
telemetry: AutocompleteTelemetry | null = null,
onFatalError?: (status: number | null) => void,
) {
this.telemetry = telemetry
this.model = model
this.costTrackingCallback = costTrackingCallback
this.getSettings = getSettings
this.onFatalError = onFatalError ?? null
this.ignoreController = (async () => {
const ignoreController = new FileIgnoreController(workspacePath)
@@ -165,7 +172,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
model,
ignoreController: this.ignoreController,
}
this.holeFiller = new HoleFiller(contextProvider)
this.fimPromptBuilder = new FimPromptBuilder(contextProvider)
this.recentlyVisitedRangesService = new RecentlyVisitedRangesService(ide)
@@ -218,10 +224,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
const { prefix, suffix } = extractPrefixSuffix(document, position)
const languageId = document.languageId
// Determine strategy based on model capabilities and call only the appropriate prompt builder
const prompt = this.model.supportsFim()
? await this.fimPromptBuilder.getFimPrompts(autocompleteInput, this.model.getModelName() ?? "codestral")
: await this.holeFiller.getPrompts(autocompleteInput, languageId)
const prompt = await this.fimPromptBuilder.getFimPrompts(
autocompleteInput,
this.model.getModelName() ?? "codestral",
)
return { prompt, prefix, suffix }
}
@@ -277,6 +283,15 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
}
}
/**
* Reset error backoff and allow fatal notifications to fire again.
* Call this when auth state changes (login, reconnect, org switch).
*/
public resetBackoff(): void {
this.backoff.reset()
this.fatalNotified = false
}
public dispose(): void {
if (this.debounceTimer !== null) {
clearTimeout(this.debounceTimer)
@@ -333,6 +348,24 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
return []
}
// Circuit breaker / backoff: skip requests when the API is returning errors.
// This prevents flooding the API with thousands of failed requests when
// credits are depleted (402), auth is invalid (401/403), or the server
// is rate-limiting (429) / having issues (5xx).
if (this.backoff.blocked()) {
// For 402 (credits depleted), periodically check the balance endpoint
// instead of sending a probe FIM request. If the user has added credits,
// reset the backoff so autocomplete resumes.
if (this.backoff.getFatalStatus() === 402 && this.backoff.shouldProbe()) {
const funded = await this.model.hasBalance()
if (funded) {
this.backoff.reset()
this.fatalNotified = false
}
}
if (this.backoff.blocked()) return []
}
if (!document?.uri?.fsPath) {
return []
}
@@ -389,9 +422,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
const { prompt, prefix: promptPrefix, suffix: promptSuffix } = await this.getPrompt(document, position)
// Update context with strategy now that we know it
telemetryContext.strategy = prompt.strategy
await this.debouncedFetchAndCacheSuggestion(prompt, promptPrefix, promptSuffix, document.languageId)
const cachedResult = applyFirstLineOnly(findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), prefix)
@@ -525,7 +555,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
languageId,
modelId: this.model?.getModelName(),
provider: this.model?.getProviderDisplayName(),
strategy: prompt.strategy,
}
// Defense-in-depth: credentials may become invalid between the provider gate and the actual
@@ -540,10 +569,12 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
const curriedProcessSuggestion = (text: string) =>
this.processSuggestion(text, prefix, suffix, this.model, telemetryContext, languageId)
const result =
prompt.strategy === "fim"
? await this.fimPromptBuilder.getFromFIM(this.model, prompt, curriedProcessSuggestion, controller.signal)
: await this.holeFiller.getFromChat(this.model, prompt, curriedProcessSuggestion)
const result = await this.fimPromptBuilder.getFromFIM(
this.model,
prompt,
curriedProcessSuggestion,
controller.signal,
)
const latencyMs = performance.now() - startTime
@@ -562,6 +593,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
this.costTrackingCallback(result.cost, result.inputTokens, result.outputTokens)
// Successful response — reset any backoff / circuit breaker state
this.backoff.success()
this.fatalNotified = false
// Always update suggestions, even if text is empty (for caching)
this.updateSuggestions(result.suggestion)
} catch (error) {
@@ -576,6 +611,15 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
},
telemetryContext,
)
// Update circuit breaker / backoff state based on the error kind
const kind = this.backoff.failure(error)
// Notify once when a fatal error (402/401/403) is first detected
if (kind === "fatal" && !this.fatalNotified) {
this.fatalNotified = true
this.onFatalError?.(this.backoff.getFatalStatus())
}
}
}
}
@@ -0,0 +1,170 @@
/**
* Circuit breaker and exponential backoff for autocomplete request errors.
*
* Classifies errors into:
* - **fatal**: 401, 402, 403 — stops all requests until explicitly reset
* - **retriable**: 429, 5xx, network errors — exponential backoff with cap
* - **transient**: abort, unknown — no special handling
*
* When a fatal error (like 402 Payment Required) is detected, autocomplete
* requests are blocked to prevent thousands of wasted API calls. The caller
* can periodically check `shouldProbe()` to run a lightweight balance check
* and call `reset()` if the user has added credits.
*/
/** Base backoff delay in ms for retriable errors */
const BASE_DELAY_MS = 2_000
/** Maximum backoff delay in ms (2 minutes) */
const MAX_DELAY_MS = 120_000
/** Number of consecutive retriable failures before circuit opens */
const CIRCUIT_THRESHOLD = 5
/** Duration in ms the circuit stays open before allowing a probe (5 minutes) */
const CIRCUIT_COOLDOWN_MS = 300_000
/** Interval between balance/auth probe checks for fatal errors (5 minutes) */
const FATAL_PROBE_INTERVAL_MS = 300_000
export type ErrorKind = "fatal" | "retriable" | "transient"
/**
* Extract an HTTP status code from an error message like "SSE failed: 402 Payment Required"
* or "FIM request failed: 429 Too Many Requests".
* Only matches 4xx/5xx after a colon — the two error sources (SDK SSE client and
* gateway FIM route) both use this format.
*/
export function extractStatus(error: unknown): number | null {
const msg = error instanceof Error ? error.message : String(error)
const match = msg.match(/:\s*([45]\d{2})\b/)
return match ? Number(match[1]) : null
}
/**
* Classify an error into one of three categories based on its HTTP status code.
*/
export function classify(error: unknown): ErrorKind {
const status = extractStatus(error)
if (status === null) return "transient"
if (status === 401 || status === 402 || status === 403) return "fatal"
if (status === 429 || status >= 500) return "retriable"
return "transient"
}
export class ErrorBackoff {
/** The kind of the most recent fatal error, if any */
private fatal: ErrorKind | null = null
/** HTTP status of the fatal error (for notification messages) */
private fatalStatus: number | null = null
/** Timestamp when the fatal error was recorded (for probe interval) */
private fatalAt = 0
/** Timestamp when the circuit was opened (for retriable circuit breaker) */
private opened = 0
/** Consecutive retriable failure count */
private failures = 0
/** Timestamp until which requests should be delayed (retriable backoff) */
private blockedUntil = 0
/**
* Record a successful request. Resets all backoff/circuit state.
*/
success(): void {
this.fatal = null
this.fatalStatus = null
this.fatalAt = 0
this.failures = 0
this.blockedUntil = 0
this.opened = 0
}
/**
* Record an error and update backoff state.
* Returns the error kind for the caller to act on.
*/
failure(error: unknown): ErrorKind {
const kind = classify(error)
if (kind === "fatal") {
this.fatal = kind
this.fatalStatus = extractStatus(error)
this.fatalAt = Date.now()
return kind
}
if (kind === "retriable") {
this.failures++
// Exponential backoff: 2s, 4s, 8s, 16s, … capped at MAX_DELAY_MS
const delay = Math.min(BASE_DELAY_MS * 2 ** (this.failures - 1), MAX_DELAY_MS)
this.blockedUntil = Date.now() + delay
// Open circuit after threshold consecutive failures
if (this.failures >= CIRCUIT_THRESHOLD && this.opened === 0) {
this.opened = Date.now()
}
return kind
}
// transient — no state change
return kind
}
/**
* Whether autocomplete requests should be blocked right now.
*/
blocked(): boolean {
// Fatal errors block until explicitly reset
if (this.fatal) return true
// Circuit breaker is open
if (this.opened > 0) {
const elapsed = Date.now() - this.opened
if (elapsed < CIRCUIT_COOLDOWN_MS) return true
// Cooldown expired — allow one probe, reset circuit
this.opened = 0
this.failures = 0
this.blockedUntil = 0
return false
}
// Exponential backoff still active
if (this.blockedUntil > 0 && Date.now() < this.blockedUntil) return true
return false
}
/**
* Whether a fatal (non-retriable) error is active — credits depleted, auth invalid, etc.
*/
isFatal(): boolean {
return this.fatal !== null
}
/**
* The HTTP status code of the fatal error, or null.
*/
getFatalStatus(): number | null {
return this.fatalStatus
}
/**
* Whether it's time for a lightweight probe (e.g. balance check) to see if
* the fatal condition has been resolved. Returns true at most once per
* FATAL_PROBE_INTERVAL_MS. The caller should check balance/auth and call
* reset() if the condition is cleared.
*/
shouldProbe(): boolean {
if (!this.fatal) return false
const elapsed = Date.now() - this.fatalAt
if (elapsed < FATAL_PROBE_INTERVAL_MS) return false
this.fatalAt = Date.now()
return true
}
/**
* Manually reset all state (e.g. when user adds credits or re-authenticates).
*/
reset(): void {
this.success()
}
}
@@ -47,7 +47,6 @@ export class FimPromptBuilder {
}
return {
strategy: "fim",
formattedPrefix,
prunedSuffix,
autocompleteInput,
@@ -1,200 +0,0 @@
import {
AutocompleteInput,
AutocompleteContextProvider,
HoleFillerAutocompletePrompt,
FillInAtCursorSuggestion,
ChatCompletionResult,
} from "../types"
import { parseAutocompleteResponse as _parseAutocompleteResponse } from "./hole-filler-utils"
import { getProcessedSnippets } from "./getProcessedSnippets"
import { formatSnippets } from "../continuedev/core/autocomplete/templating/formatting"
import { AutocompleteModel, ApiStreamChunk } from "../AutocompleteModel"
export type { HoleFillerAutocompletePrompt, FillInAtCursorSuggestion, ChatCompletionResult }
/**
* Parse the response - only handles responses with <COMPLETION> tags
* Returns a FillInAtCursorSuggestion with the extracted text, or an empty string if nothing found
*/
export function parseAutocompleteResponse(
fullResponse: string,
prefix: string,
suffix: string,
): FillInAtCursorSuggestion {
return _parseAutocompleteResponse(fullResponse, prefix, suffix)
}
export class HoleFiller {
constructor(private contextProvider: AutocompleteContextProvider) {}
async getPrompts(autocompleteInput: AutocompleteInput, languageId: string): Promise<HoleFillerAutocompletePrompt> {
return {
strategy: "hole_filler",
systemPrompt: this.getSystemInstructions(),
userPrompt: await this.getUserPrompt(autocompleteInput, languageId),
autocompleteInput,
}
}
getSystemInstructions(): string {
return `You are a HOLE FILLER. You are provided with a file containing holes, formatted as '{{FILL_HERE}}'. Your TASK is to complete with a string to replace this hole with, inside a <COMPLETION/> XML tag, including context-aware indentation, if needed. All completions MUST be truthful, accurate, well-written and correct.
## CRITICAL RULES
- NEVER repeat or duplicate content that appears immediately before {{FILL_HERE}}
- If {{FILL_HERE}} is at the end of a comment line, start your completion with a newline and new code
- Maintain proper indentation matching the surrounding code
## Context Format
<LANGUAGE>: file language
<QUERY>: contains commented reference code (// Path: file.ts) followed by code with {{FILL_HERE}}
Comments provide context from related files, recent edits, imports, etc.
## EXAMPLE QUERY:
<QUERY>
function sum_evens(lim) {
var sum = 0;
for (var i = 0; i < lim; ++i) {
{{FILL_HERE}}
}
return sum;
}
</QUERY>
TASK: Fill the {{FILL_HERE}} hole.
## CORRECT COMPLETION
<COMPLETION>if (i % 2 === 0) {
sum += i;
}</COMPLETION>
## EXAMPLE QUERY:
<QUERY>
def sum_list(lst):
total = 0
for x in lst:
{{FILL_HERE}}
return total
print sum_list([1, 2, 3])
</QUERY>
## CORRECT COMPLETION:
<COMPLETION> total += x</COMPLETION>
## EXAMPLE QUERY:
<QUERY>
// data Tree a = Node (Tree a) (Tree a) | Leaf a
// sum :: Tree Int -> Int
// sum (Node lft rgt) = sum lft + sum rgt
// sum (Leaf val) = val
// convert to TypeScript:
{{FILL_HERE}}
</QUERY>
## CORRECT COMPLETION:
<COMPLETION>type Tree<T>
= {$:"Node", lft: Tree<T>, rgt: Tree<T>}
| {$:"Leaf", val: T};
function sum(tree: Tree<number>): number {
switch (tree.$) {
case "Node":
return sum(tree.lft) + sum(tree.rgt);
case "Leaf":
return tree.val;
}
}</COMPLETION>
## EXAMPLE QUERY:
The 5th {{FILL_HERE}} is Jupiter.
## CORRECT COMPLETION:
<COMPLETION>planet from the Sun</COMPLETION>
## EXAMPLE QUERY:
function hypothenuse(a, b) {
return Math.sqrt({{FILL_HERE}}b ** 2);
}
## CORRECT COMPLETION:
<COMPLETION>a ** 2 + </COMPLETION>
Task: Auto-Completion
Provide a subtle, non-intrusive completion after a typing pause.
`
}
/**
* Build minimal prompt for auto-trigger with optional context
*/
async getUserPrompt(autocompleteInput: AutocompleteInput, languageId: string): Promise<string> {
const { helper, snippetsWithUris, workspaceDirs } = await getProcessedSnippets(
autocompleteInput,
autocompleteInput.filepath,
this.contextProvider.contextService,
this.contextProvider.model,
this.contextProvider.ide,
this.contextProvider.ignoreController,
)
const formattedContext = formatSnippets(helper, snippetsWithUris, workspaceDirs)
// Use pruned prefix/suffix from HelperVars (token-limited based on DEFAULT_AUTOCOMPLETE_OPTS)
return (
`<LANGUAGE>${languageId}</LANGUAGE>\n\n` +
`<QUERY>
${formattedContext}${formattedContext ? "\n" : ""}${helper.prunedPrefix}{{FILL_HERE}}${helper.prunedSuffix}
</QUERY>
TASK: Fill the {{FILL_HERE}} hole. Answer only with the CORRECT completion, and NOTHING ELSE. Do it now.
Return the COMPLETION tags`
)
}
/**
* Execute chat-based completion using the model
*/
async getFromChat(
model: AutocompleteModel,
prompt: HoleFillerAutocompletePrompt,
processSuggestion: (text: string) => FillInAtCursorSuggestion,
): Promise<ChatCompletionResult> {
const { systemPrompt, userPrompt } = prompt
let response = ""
let chunkCount = 0
const onChunk = (chunk: ApiStreamChunk) => {
if (chunk.type === "text") {
response += chunk.text
chunkCount++
}
}
const usageInfo = await model.generateResponse(systemPrompt, userPrompt, onChunk)
// Extract just the text from the response - prefix/suffix are handled by the caller
const { text: suggestionText } = _parseAutocompleteResponse(response, "", "")
const fillInAtCursorSuggestion = processSuggestion(suggestionText)
return {
suggestion: fillInAtCursorSuggestion,
cost: usageInfo.cost,
inputTokens: usageInfo.inputTokens,
outputTokens: usageInfo.outputTokens,
cacheWriteTokens: usageInfo.cacheWriteTokens,
cacheReadTokens: usageInfo.cacheReadTokens,
}
}
}
@@ -842,7 +842,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Create mock dependencies
mockModel = {
generateResponse: vi.fn().mockResolvedValue({
generateFimResponse: vi.fn().mockResolvedValue({
cost: 0,
inputTokens: 0,
outputTokens: 0,
@@ -851,7 +851,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
}),
getModelName: vi.fn().mockReturnValue("test-model"),
getProviderDisplayName: vi.fn().mockReturnValue("test-provider"),
supportsFim: vi.fn().mockReturnValue(false), // Default to false for non-FIM tests
supportsFim: vi.fn().mockReturnValue(true),
hasValidCredentials: vi.fn().mockReturnValue(true), // Default to true for tests
} as unknown as AutocompleteModel
mockCostTrackingCallback = vi.fn() as CostTrackingCallback
@@ -1421,7 +1421,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Should return empty array because auto-trigger is disabled
expect(result).toEqual([])
// Model should not be called
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
})
it("should block manual trigger when auto-trigger is disabled (defense in depth)", async () => {
@@ -1439,7 +1439,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Should return empty array as defense in depth, even for manual triggers
// The provider should be deregistered at the manager level when disabled
expect(result).toEqual([])
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
})
it("should read settings dynamically on each call", async () => {
@@ -1453,7 +1453,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
// First call with auto-trigger enabled
await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken)
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
// Change settings to disable auto-trigger
mockSettings = { enableAutoTrigger: false }
@@ -1462,7 +1462,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
const result = await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken)
// Should not call model again because auto-trigger is now disabled
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
expect(result).toEqual([])
})
@@ -1479,7 +1479,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Should default to false (disabled) when settings are null
expect(result).toEqual([])
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
})
it("should allow auto-trigger when explicitly enabled", async () => {
@@ -1494,14 +1494,14 @@ describe("AutocompleteInlineCompletionProvider", () => {
await provideWithDebounce(mockDocument, mockPosition, autoContext, mockToken)
// Model should be called because auto-trigger is enabled
expect(mockModel.generateResponse).toHaveBeenCalled()
expect(mockModel.generateFimResponse).toHaveBeenCalled()
})
})
describe("failed lookups cache", () => {
it("should cache failed LLM lookups and not call LLM again for same prefix/suffix", async () => {
// Mock the model to return empty suggestions
vi.mocked(mockModel.generateResponse).mockResolvedValue({
vi.mocked(mockModel.generateFimResponse).mockResolvedValue({
cost: 0.01,
inputTokens: 100,
outputTokens: 50,
@@ -1518,11 +1518,11 @@ describe("AutocompleteInlineCompletionProvider", () => {
)) as vscode.InlineCompletionItem[]
expect(result1).toHaveLength(0)
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
expect(mockCostTrackingCallback).toHaveBeenCalledWith(0.01, 100, 50)
// Second call with same prefix/suffix - should NOT invoke LLM
vi.mocked(mockModel.generateResponse).mockClear()
vi.mocked(mockModel.generateFimResponse).mockClear()
vi.mocked(mockCostTrackingCallback).mockClear()
const result2 = (await provideWithDebounce(
@@ -1533,20 +1533,18 @@ describe("AutocompleteInlineCompletionProvider", () => {
)) as vscode.InlineCompletionItem[]
expect(result2).toHaveLength(0)
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
expect(mockCostTrackingCallback).not.toHaveBeenCalled()
})
it("should not cache successful LLM lookups in failed cache", async () => {
// Mock the model to return a successful suggestion using proper COMPLETION format
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
callCount++
// Simulate streaming response with proper COMPLETION format expected by parser
// Simulate streaming FIM response
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "console.log('success');" })
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("console.log('success');")
}
return {
cost: 0.01,
@@ -1584,7 +1582,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should cache different prefix/suffix combinations separately", async () => {
// Mock the model to return empty suggestions
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async () => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => {
callCount++
return {
cost: 0.01,
@@ -1618,7 +1616,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should maintain only the last 50 failed lookups (FIFO)", async () => {
// Mock the model to return empty suggestions
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async () => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => {
callCount++
return {
cost: 0,
@@ -1664,7 +1662,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should not add duplicate failed lookups", async () => {
// Mock the model to return empty suggestions
vi.mocked(mockModel.generateResponse).mockResolvedValue({
vi.mocked(mockModel.generateFimResponse).mockResolvedValue({
cost: 0,
inputTokens: 0,
outputTokens: 0,
@@ -1674,22 +1672,22 @@ describe("AutocompleteInlineCompletionProvider", () => {
// First call - adds to failed cache
await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken)
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
// Second call - should use cache, not add duplicate
vi.mocked(mockModel.generateResponse).mockClear()
vi.mocked(mockModel.generateFimResponse).mockClear()
await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken)
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
// Third call - should still use cache
vi.mocked(mockModel.generateResponse).mockClear()
vi.mocked(mockModel.generateFimResponse).mockClear()
await provideWithDebounce(mockDocument, mockPosition, mockContext, mockToken)
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
})
it("should return empty result with zero cost when using failed cache", async () => {
// Mock the model to return empty suggestions
vi.mocked(mockModel.generateResponse).mockResolvedValue({
vi.mocked(mockModel.generateFimResponse).mockResolvedValue({
cost: 0.01,
inputTokens: 100,
outputTokens: 50,
@@ -1711,11 +1709,9 @@ describe("AutocompleteInlineCompletionProvider", () => {
describe("useless suggestion filtering", () => {
it("should refuse suggestions that match the end of prefix", async () => {
// Mock the model to return a suggestion that matches the end of prefix
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "= 1" }) // This matches the end of "const x = 1"
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("= 1") // This matches the end of "const x = 1"
}
return {
cost: 0.01,
@@ -1735,16 +1731,14 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Should return empty array because the suggestion is useless
expect(result).toHaveLength(0)
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
})
it("should refuse suggestions that match the start of suffix", async () => {
// Mock the model to return a suggestion that matches the start of suffix
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "\nconst" }) // This matches the start of "\nconst y = 2"
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("\nconst") // This matches the start of "\nconst y = 2"
}
return {
cost: 0.01,
@@ -1764,16 +1758,14 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Should return empty array because the suggestion is useless
expect(result).toHaveLength(0)
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
})
it("should accept useful suggestions that don't match prefix end or suffix start", async () => {
// Mock the model to return a useful suggestion
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "\nconsole.log('useful');" }) // Useful suggestion
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("\nconsole.log('useful');") // Useful suggestion
}
return {
cost: 0.01,
@@ -1794,16 +1786,14 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Should return the suggestion because it's useful
expect(result).toHaveLength(1)
expect(result[0].insertText).toBe("\nconsole.log('useful');")
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
})
it("should cache refused suggestions as empty to avoid repeated LLM calls", async () => {
// Mock the model to return a useless suggestion
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "= 1" }) // Matches end of prefix
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("= 1") // Matches end of prefix
}
return {
cost: 0.01,
@@ -1823,10 +1813,10 @@ describe("AutocompleteInlineCompletionProvider", () => {
)) as vscode.InlineCompletionItem[]
expect(result1).toHaveLength(0)
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
// Second call with same prefix/suffix - should use cache, not call LLM
vi.mocked(mockModel.generateResponse).mockClear()
vi.mocked(mockModel.generateFimResponse).mockClear()
const result2 = (await provideWithDebounce(
mockDocument,
mockPosition,
@@ -1835,7 +1825,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
)) as vscode.InlineCompletionItem[]
expect(result2).toHaveLength(0)
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
})
})
@@ -1861,13 +1851,13 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Should return empty array because credentials are not valid
expect(result).toHaveLength(0)
// Model should not be called
expect(mockModel.generateResponse).not.toHaveBeenCalled()
expect(mockModel.generateFimResponse).not.toHaveBeenCalled()
})
it("should not attempt an LLM call if credentials become invalid before the debounced fetch executes", async () => {
// First call executes immediately (leading edge) - just to move provider into debounced mode
await provider.provideInlineCompletionItems(mockDocument, mockPosition, mockContext, mockToken)
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
// Second call will be debounced. Simulate a model reload happening before the timer fires.
vi.mocked(mockModel.hasValidCredentials).mockReturnValue(false)
@@ -1878,8 +1868,8 @@ describe("AutocompleteInlineCompletionProvider", () => {
await vi.advanceTimersByTimeAsync(300)
await promise
// If fetch-time validation is working, we do not call generateResponse again.
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
// If fetch-time validation is working, we do not call generateFimResponse again.
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
})
it("should return suggestions when model has valid credentials", async () => {
@@ -1940,12 +1930,10 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should reuse pending request when user types forward (prefix extends, suffix unchanged)", async () => {
// Mock the model to track call count
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
callCount++
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "console.log('test');" })
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("console.log('test');")
}
return {
cost: 0.01,
@@ -1988,12 +1976,10 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should NOT reuse pending request when suffix changes", async () => {
// Mock the model to track call count
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
callCount++
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "console.log('test');" })
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("console.log('test');")
}
return {
cost: 0.01,
@@ -2038,12 +2024,10 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should NOT reuse pending request when user backspaces (prefix shrinks)", async () => {
// Mock the model to track call count
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
callCount++
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "console.log('test');" })
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("console.log('test');")
}
return {
cost: 0.01,
@@ -2087,7 +2071,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
describe("debounce with leading edge behavior", () => {
it("should execute immediately on first call (leading edge)", async () => {
vi.mocked(mockModel.generateResponse).mockResolvedValue({
vi.mocked(mockModel.generateFimResponse).mockResolvedValue({
cost: 0.01,
inputTokens: 100,
outputTokens: 50,
@@ -2100,12 +2084,12 @@ describe("AutocompleteInlineCompletionProvider", () => {
// Model should be called immediately (no timer needed)
await promise
expect(mockModel.generateResponse).toHaveBeenCalledTimes(1)
expect(mockModel.generateFimResponse).toHaveBeenCalledTimes(1)
})
it("should debounce subsequent calls (wait for 300ms of inactivity)", async () => {
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async () => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => {
callCount++
return {
cost: 0.01,
@@ -2138,7 +2122,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should reset debounce timer on each call (only execute after 300ms of inactivity)", async () => {
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async () => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => {
callCount++
return {
cost: 0.01,
@@ -2184,7 +2168,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should allow immediate execution after debounce completes (new leading edge)", async () => {
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async () => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => {
callCount++
return {
cost: 0.01,
@@ -2222,7 +2206,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
describe("adaptive debounce delay", () => {
it("should start with initial debounce delay of 300ms", async () => {
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async () => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => {
callCount++
return {
cost: 0.01,
@@ -2321,7 +2305,7 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should use adaptive debounce delay after collecting enough samples", async () => {
let callCount = 0
vi.mocked(mockModel.generateResponse).mockImplementation(async () => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async () => {
callCount++
return {
cost: 0.01,
@@ -2365,12 +2349,10 @@ describe("AutocompleteInlineCompletionProvider", () => {
it("should record latency from LLM requests", async () => {
// Mock the model to simulate a delay
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
vi.mocked(mockModel.generateFimResponse).mockImplementation(async (_prefix, _suffix, onChunk) => {
// Simulate some processing time
if (onChunk) {
onChunk({ type: "text", text: "<COMPLETION>" })
onChunk({ type: "text", text: "console.log('test');" })
onChunk({ type: "text", text: "</COMPLETION>" })
onChunk("console.log('test');")
}
return {
cost: 0.01,
@@ -1,301 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { HoleFiller, parseAutocompleteResponse } from "../HoleFiller"
import { AutocompleteInput, AutocompleteContextProvider } from "../../types"
import crypto from "crypto"
import { AutocompleteSnippetType } from "../../continuedev/core/autocomplete/types"
// Mock the getProcessedSnippets module
vi.mock("../getProcessedSnippets", () => ({
getProcessedSnippets: vi.fn().mockResolvedValue({
filepathUri: "file:///test.ts",
helper: {
filepath: "file:///test.ts",
lang: { name: "typescript", singleLineComment: "//" },
prunedPrefix: "const x = 1;\n",
prunedSuffix: "",
},
snippetsWithUris: [],
workspaceDirs: [],
}),
}))
function createAutocompleteInput(
filepath: string = "/test.ts",
line: number = 0,
character: number = 0,
): AutocompleteInput {
return {
isUntitledFile: false,
completionId: crypto.randomUUID(),
filepath,
pos: { line, character },
recentlyVisitedRanges: [],
recentlyEditedRanges: [],
}
}
describe("HoleFiller", () => {
let holeFiller: HoleFiller
beforeEach(() => {
vi.clearAllMocks()
// Create a minimal mock context provider for basic tests
const mockContextProvider: AutocompleteContextProvider = {
contextService: {
initializeForFile: vi.fn().mockResolvedValue(undefined),
} as any,
model: {
getModelName: () => "codestral",
} as any,
ide: {
getWorkspaceDirs: vi.fn().mockResolvedValue([]),
} as any,
ignoreController: undefined,
}
holeFiller = new HoleFiller(mockContextProvider)
})
describe("getPrompts", () => {
it("should generate prompts with QUERY/FILL_HERE format", async () => {
const { systemPrompt, userPrompt } = await holeFiller.getPrompts(
createAutocompleteInput("/test.ts", 0, 13),
"typescript",
)
// Verify system prompt contains auto-trigger keywords
expect(systemPrompt).toContain("Auto-Completion")
expect(systemPrompt).toContain("Context Format")
const expected = `<LANGUAGE>typescript</LANGUAGE>
<QUERY>
// test.ts
const x = 1;
{{FILL_HERE}}
</QUERY>
TASK: Fill the {{FILL_HERE}} hole. Answer only with the CORRECT completion, and NOTHING ELSE. Do it now.
Return the COMPLETION tags`
expect(userPrompt).toBe(expected)
})
it("should include comment-wrapped context when provider is set", async () => {
// Update the mock for this specific test
const { getProcessedSnippets } = await import("../getProcessedSnippets")
;(getProcessedSnippets as any).mockResolvedValueOnce({
filepathUri: "file:///app.ts",
helper: {
filepath: "file:///app.ts",
lang: { name: "typescript", singleLineComment: "//" },
prunedPrefix: "function calculate() {\n ",
prunedSuffix: "\n}",
},
snippetsWithUris: [
{
filepath: "file:///utils.ts",
content: "export function sum(a: number, b: number) {\n return a + b\n}",
type: AutocompleteSnippetType.Code,
},
],
workspaceDirs: ["file:///workspace"],
})
const mockContextProvider: AutocompleteContextProvider = {
contextService: {
initializeForFile: vi.fn().mockResolvedValue(undefined),
} as any,
model: {
getModelName: () => "codestral",
} as any,
ide: {
getWorkspaceDirs: vi.fn().mockResolvedValue(["file:///workspace"]),
} as any,
ignoreController: undefined,
}
const holeFillerWithContext = new HoleFiller(mockContextProvider)
const { userPrompt } = await holeFillerWithContext.getPrompts(
createAutocompleteInput("/app.ts", 5, 0),
"typescript",
)
// Use toContain for key parts instead of exact match to avoid whitespace issues
expect(userPrompt).toContain("<LANGUAGE>typescript</LANGUAGE>")
expect(userPrompt).toContain("// Path: utils.ts")
expect(userPrompt).toContain("// export function sum(a: number, b: number)")
expect(userPrompt).toContain("function calculate() {")
expect(userPrompt).toContain("{{FILL_HERE}}")
expect(userPrompt).toContain("TASK: Fill the {{FILL_HERE}} hole")
})
})
describe("parseAutocompleteResponse", () => {
const prefix = "function test() {\n "
const suffix = "\n}"
describe("Response parsing with COMPLETION tags", () => {
it("should extract content between COMPLETION tags", () => {
const response = "<COMPLETION>return 42</COMPLETION>"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("return 42")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should handle multiline content in COMPLETION tags", () => {
const response = "<COMPLETION>const x = 1;\nconst y = 2;\nreturn x + y;</COMPLETION>"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("const x = 1;\nconst y = 2;\nreturn x + y;")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should handle incomplete COMPLETION tag (streaming)", () => {
const response = "<COMPLETION>return 42"
const result = parseAutocompleteResponse(response, prefix, suffix)
// Incomplete tags should return empty string
expect(result.text).toBe("")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should remove any accidental tag remnants", () => {
const response = "<COMPLETION>return 42<COMPLETION></COMPLETION>"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("return 42")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should handle case-insensitive tags", () => {
const response = "<completion>return 42</completion>"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("return 42")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
})
describe("Response parsing without COMPLETION tags (no suggestions)", () => {
it("should return empty string when no tags present", () => {
const response = "return 42"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should return empty string for multiline response without tags", () => {
const response = "const x = 1;\nconst y = 2;\nreturn x + y;"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should return empty string for markdown code blocks without tags", () => {
const response = "```typescript\nreturn 42\n```"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
})
describe("Edge cases", () => {
it("should handle empty response", () => {
const response = ""
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should return empty string for whitespace-only response without tags", () => {
const response = " \n\t "
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should handle custom prefix/suffix with COMPLETION tags", () => {
const customPrefix = "const greeting = "
const customSuffix = ";"
const response = '<COMPLETION>"Hello, World!"</COMPLETION>'
const result = parseAutocompleteResponse(response, customPrefix, customSuffix)
expect(result.text).toBe('"Hello, World!"')
expect(result.prefix).toBe(customPrefix)
expect(result.suffix).toBe(customSuffix)
})
it("should handle empty COMPLETION tags", () => {
const response = "<COMPLETION></COMPLETION>"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should handle whitespace-only content in COMPLETION tags", () => {
const response = "<COMPLETION> </COMPLETION>"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe(" ")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should handle response with extra text before COMPLETION tag", () => {
const response = "Here is the code:\n<COMPLETION>return 42</COMPLETION>"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("return 42")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
it("should handle response with extra text after COMPLETION tag", () => {
const response = "<COMPLETION>return 42</COMPLETION>\nThat's the code!"
const result = parseAutocompleteResponse(response, prefix, suffix)
expect(result.text).toBe("return 42")
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
})
describe("performance", () => {
it("should handle large responses efficiently", () => {
const largeContent = "x".repeat(10000)
const response = `<COMPLETION>${largeContent}</COMPLETION>`
const startTime = performance.now()
const result = parseAutocompleteResponse(response, prefix, suffix)
const endTime = performance.now()
expect(endTime - startTime).toBeLessThan(100)
expect(result.text).toBe(largeContent)
expect(result.prefix).toBe(prefix)
expect(result.suffix).toBe(suffix)
})
})
})
})
@@ -1,19 +0,0 @@
import type { FillInAtCursorSuggestion } from "../types"
/**
* Parse a chat completion response and extract the text between <COMPLETION> tags.
* Returns a FillInAtCursorSuggestion with the extracted text, or empty string if not found.
*/
export function parseAutocompleteResponse(
fullResponse: string,
prefix: string,
suffix: string,
): FillInAtCursorSuggestion {
let fimText = ""
const completionMatch = fullResponse.match(/<COMPLETION>([\s\S]*?)<\/COMPLETION>/i)
if (completionMatch) {
fimText = completionMatch[1] || ""
}
fimText = fimText.replace(/<\/?COMPLETION>/gi, "")
return { text: fimText, prefix, suffix }
}

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