Merge branch 'main' into feat/minimax-m3-thinking-toggle

This commit is contained in:
Christiaan Arnoldus
2026-06-15 12:03:11 +02:00
committed by GitHub
354 changed files with 9306 additions and 6890 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Allow reasoning to be removed from custom provider models after it has been enabled.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Clarify potential loop permission prompts and auto-approval rules with localized tool names.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Restore reverted sessions on the first Redo click.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Restore Kilo branding, fork-specific CLI commands, and CLI lifecycle initialization after upstream merges.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Prevent skill removal from recursively deleting working directories.
+1 -1
View File
@@ -1 +1 @@
v1.14.51
v1.15.4
+35 -15
View File
@@ -578,9 +578,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.10",
"@opentui/keymap": ">=0.2.10",
"@opentui/solid": ">=0.2.10",
"@opentui/core": ">=0.2.11",
"@opentui/keymap": ">=0.2.11",
"@opentui/solid": ">=0.2.11",
},
"optionalPeers": [
"@opentui/core",
@@ -746,9 +746,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.11",
"@pierre/diffs": "1.1.22",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -1591,23 +1591,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
"@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="],
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
"@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5065,6 +5065,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
"ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
@@ -5589,6 +5593,20 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -5819,6 +5837,8 @@
"mocha/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Q/F/FbHmJ2To96E8Y/iLL+nILLOk+oB+qpGC3P0T43Q=",
"aarch64-linux": "sha256-wvXhCOmdLwQv8/mshDHPBrqL1slOs+Q1oOCzF1J6ZVs=",
"aarch64-darwin": "sha256-4E77I50PSaI6S+euQOz6SDvasAtJadicXqxlPVBj5nA=",
"x86_64-darwin": "sha256-Ebz3fGdA85Akrbau0PNIr+m+Yu/C0nz7AXNMOOHFhXQ="
"x86_64-linux": "sha256-eDZ7bR8/xjtQj8Y+X02h/D7afn2TGC3xOTnDmwFKkwo=",
"aarch64-linux": "sha256-CvsqQ8uSoZmc/9ClO4I/Wwb2NIQqynt57n/G0P9mFPY=",
"aarch64-darwin": "sha256-mIC3G1zON6WmKyZT5YaYGc0Wh6v/tQ+aJaZcU7/nyzE=",
"x86_64-darwin": "sha256-U6A3ZPFUVUDdgwxCJXX5X0HzR6DiLzIoHViDoQDiMMY="
}
}
+3 -3
View File
@@ -32,8 +32,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.11",
"@opentui/solid": "0.2.11",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -78,7 +78,7 @@
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10",
"@opentui/keymap": "0.2.10"
"@opentui/keymap": "0.2.11"
}
},
"devDependencies": {
+17 -8
View File
@@ -5,7 +5,8 @@ import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Instance } from "./instance"
import { Location } from "./location"
import { EventV2 } from "./event"
type ProviderRecord = {
provider: ProviderV2.Info
@@ -24,6 +25,15 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
modelID: ModelV2.ID,
}) {}
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
schema: {
model: ModelV2.Info,
},
}),
}
export interface Interface {
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
@@ -57,10 +67,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Instance.Service
yield* Location.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -157,14 +168,12 @@ export const layer = Layer.effect(
)
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
if (updated.cancel) return
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
records = HashMap.set(records, providerID, {
provider: record.provider,
models: HashMap.set(
record.models,
modelID,
new ModelV2.Info({ ...updated.model, id: modelID, providerID }),
),
models: HashMap.set(record.models, modelID, next),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
@@ -257,4 +266,4 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
+157
View File
@@ -0,0 +1,157 @@
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Location } from "./location"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly data: DataSchema
}
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
}
export type Sync = (event: Payload) => Effect.Effect<void>
export const registry = new Map<string, Definition>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
version: Schema.optional(Schema.Number),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.version === undefined ? {} : { version: input.version }),
...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }),
data: Data,
})
registry.set(input.type, definition)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
export function definitions() {
return registry.values().toArray()
}
export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
}
export type Unsubscribe = Effect.Effect<void>
export interface Interface {
readonly publish: <D extends Definition>(
definition: D,
data: Data<D>,
options?: PublishOptions,
) => Effect.Effect<Payload<D>>
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const syncHandlers = new Array<Sync>()
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const event = {
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.version === undefined ? {} : { version: definition.version }),
...(location ? { location } : {}),
data,
} as Payload<D>
return yield* publishEvent(event)
})
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync })
}),
)
export const defaultLayer = layer
export * as EventV2 from "./event"
+1 -1
View File
@@ -53,7 +53,7 @@ export const Flag = {
KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"),
KILO_DISABLE_CLAUDE_CODE_SKILLS,
KILO_DISABLE_EXTERNAL_SKILLS: truthy("KILO_DISABLE_EXTERNAL_SKILLS"),
KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"),
KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), // kilocode_change
KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"],
KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"],
KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"],
-12
View File
@@ -1,12 +0,0 @@
import { Layer, LayerMap } from "effect"
import { Instance } from "./instance"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
lookup: (ref: Instance.Ref) => {
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
},
idleTimeToLive: "5 minutes",
}) {}
-10
View File
@@ -1,10 +0,0 @@
import { Context } from "effect"
export * as Instance from "./instance"
export type Ref = {
readonly directory: string
readonly workspaceID?: string
}
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}
+12
View File
@@ -0,0 +1,12 @@
import { Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Layer.succeed(Location.Service, Location.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
},
idleTimeToLive: "5 minutes",
}) {}
+11
View File
@@ -0,0 +1,11 @@
import { Context, Schema } from "effect"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
export class Service extends Context.Service<Service, Ref>()("@opencode/Location") {}
@@ -1,12 +1,13 @@
import { SessionID } from "@/session/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { EventV2 } from "./event"
import { FileAttachment, Prompt } from "@opencode-ai/core/session-prompt"
import { Schema } from "effect"
import { EventV2 } from "./event"
import { ModelV2 } from "./model"
import { NonNegativeInt } from "./schema"
import { Session } from "./session"
import { FileAttachment, Prompt } from "./session-prompt"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
export { FileAttachment }
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { ModelV2 } from "@opencode-ai/core/model"
export const Source = Schema.Struct({
start: NonNegativeInt,
@@ -15,92 +16,94 @@ export const Source = Schema.Struct({
}).annotate({
identifier: "session.next.event.source",
})
export type Source = Schema.Schema.Type<typeof Source>
export type Source = typeof Source.Type
const Base = {
timestamp: V2Schema.DateTimeUtcFromMillis,
sessionID: SessionID,
sessionID: Session.ID,
}
const options = {
aggregate: "sessionID",
version: 1,
} as const
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
})
export type UnknownError = Schema.Schema.Type<typeof UnknownError>
export type UnknownError = typeof UnknownError.Type
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
agent: Schema.String,
},
})
export type AgentSwitched = Schema.Schema.Type<typeof AgentSwitched>
export type AgentSwitched = typeof AgentSwitched.Type
export const ModelSwitched = EventV2.define({
type: "session.next.model.switched",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
model: ModelV2.Ref,
},
})
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
export type ModelSwitched = typeof ModelSwitched.Type
export const Prompted = EventV2.define({
type: "session.next.prompted",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
prompt: Prompt,
},
})
export type Prompted = Schema.Schema.Type<typeof Prompted>
export type Prompted = typeof Prompted.Type
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Synthetic = Schema.Schema.Type<typeof Synthetic>
export type Synthetic = typeof Synthetic.Type
export namespace Shell {
export const Started = EventV2.define({
type: "session.next.shell.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
command: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.shell.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
output: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Step {
export const Started = EventV2.define({
type: "session.next.step.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
agent: Schema.String,
@@ -108,11 +111,11 @@ export namespace Step {
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.step.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
finish: Schema.String,
@@ -129,123 +132,123 @@ export namespace Step {
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
export const Failed = EventV2.define({
type: "session.next.step.failed",
aggregate: "sessionID",
...options,
schema: {
...Base,
error: UnknownError,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
export type Failed = typeof Failed.Type
}
export namespace Text {
export const Started = EventV2.define({
type: "session.next.text.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.text.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.text.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Reasoning {
export const Started = EventV2.define({
type: "session.next.reasoning.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.reasoning.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.reasoning.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Tool {
export namespace Input {
export const Started = EventV2.define({
type: "session.next.tool.input.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
name: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.tool.input.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.tool.input.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export const Called = EventV2.define({
type: "session.next.tool.called",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -257,11 +260,11 @@ export namespace Tool {
}),
},
})
export type Called = Schema.Schema.Type<typeof Called>
export type Called = typeof Called.Type
export const Progress = EventV2.define({
type: "session.next.tool.progress",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -269,11 +272,11 @@ export namespace Tool {
content: Schema.Array(ToolOutput.Content),
},
})
export type Progress = Schema.Schema.Type<typeof Progress>
export type Progress = typeof Progress.Type
export const Success = EventV2.define({
type: "session.next.tool.success",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -285,11 +288,11 @@ export namespace Tool {
}),
},
})
export type Success = Schema.Schema.Type<typeof Success>
export type Success = typeof Success.Type
export const Failed = EventV2.define({
type: "session.next.tool.failed",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -300,7 +303,7 @@ export namespace Tool {
}),
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
export type Failed = typeof Failed.Type
}
export const RetryError = Schema.Struct({
@@ -313,49 +316,50 @@ export const RetryError = Schema.Struct({
}).annotate({
identifier: "session.next.retry_error",
})
export type RetryError = Schema.Schema.Type<typeof RetryError>
export type RetryError = typeof RetryError.Type
export const Retried = EventV2.define({
type: "session.next.retried",
aggregate: "sessionID",
...options,
schema: {
...Base,
attempt: Schema.Finite,
error: RetryError,
},
})
export type Retried = Schema.Schema.Type<typeof Retried>
export type Retried = typeof Retried.Type
export namespace Compaction {
export const Started = EventV2.define({
type: "session.next.compaction.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.compaction.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
include: Schema.String.pipe(Schema.optional),
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export const All = Schema.Union(
@@ -392,16 +396,7 @@ export const All = Schema.Union(
},
).pipe(Schema.toTaggedUnion("type"))
// user
// assistant
// assistant
// assistant
// user
// compaction marker
// -> text
// assistant
export type Event = Schema.Schema.Type<typeof All>
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./session-event"
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Prompt } from "@opencode-ai/core/session-prompt"
import { Prompt } from "./session-prompt"
import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { ModelV2 } from "@opencode-ai/core/model"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
import { ModelV2 } from "./model"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -20,7 +20,7 @@ const Base = {
export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.AgentSwitched")({
...Base,
type: Schema.Literal("agent-switched"),
agent: SessionEvent.AgentSwitched.fields.data.fields.agent,
agent: SessionEvent.AgentSwitched.data.fields.agent,
}) {}
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
@@ -43,16 +43,16 @@ export class User extends Schema.Class<User>("Session.Message.User")({
export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Synthetic")({
...Base,
sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID,
text: SessionEvent.Synthetic.fields.data.fields.text,
sessionID: SessionEvent.Synthetic.data.fields.sessionID,
text: SessionEvent.Synthetic.data.fields.text,
type: Schema.Literal("synthetic"),
}) {}
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
...Base,
type: Schema.Literal("shell"),
callID: SessionEvent.Shell.Started.fields.data.fields.callID,
command: SessionEvent.Shell.Started.fields.data.fields.command,
callID: SessionEvent.Shell.Started.data.fields.callID,
command: SessionEvent.Shell.Started.data.fields.command,
output: Schema.String,
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
@@ -130,7 +130,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
...Base,
type: Schema.Literal("assistant"),
agent: Schema.String,
model: SessionEvent.Step.Started.fields.data.fields.model,
model: SessionEvent.Step.Started.data.fields.model,
content: AssistantContent.pipe(Schema.Array),
snapshot: Schema.Struct({
start: Schema.String.pipe(Schema.optional),
@@ -147,7 +147,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
write: Schema.Finite,
}),
}).pipe(Schema.optional),
error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional),
error: SessionEvent.Step.Failed.data.fields.error.pipe(Schema.optional),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
@@ -156,7 +156,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
export class Compaction extends Schema.Class<Compaction>("Session.Message.Compaction")({
type: Schema.Literal("compaction"),
reason: SessionEvent.Compaction.Started.fields.data.fields.reason,
reason: SessionEvent.Compaction.Started.data.fields.reason,
summary: Schema.String,
include: Schema.String.pipe(Schema.optional),
...Base,
+13
View File
@@ -0,0 +1,13 @@
export * as Session from "./session"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
)
export type ID = typeof ID.Type
+36 -4
View File
@@ -1,14 +1,21 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>
@@ -69,6 +76,31 @@ describe("CatalogV2", () => {
}),
)
it.effect("publishes model updated events", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const fiber = yield* events
.subscribe(Catalog.Event.ModelUpdated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, modelID, (model) => {
model.name = "Updated Model"
})
const event = Array.from(yield* Fiber.join(fiber))[0]
expect(event?.type).toBe("catalog.model.updated")
expect(event?.data.model.providerID).toBe(providerID)
expect(event?.data.model.id).toBe(modelID)
expect(event?.data.model.name).toBe("Updated Model")
expect(event?.location).toEqual({ directory: "test" })
}),
)
it.effect("resolves unknown model endpoint from provider endpoint", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
)
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(EventV2.layer)
const Message = EventV2.define({
type: "test.message",
schema: {
text: Schema.String,
},
})
const GlobalMessage = EventV2.define({
type: "test.global",
schema: {
text: Schema.String,
},
})
const VersionedMessage = EventV2.define({
type: "test.versioned",
version: 2,
schema: {
text: Schema.String,
},
})
describe("EventV2", () => {
it.effect("publishes events with the current location", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const fiber = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* events.publish(Message, { text: "hello" })
const received = Array.from(yield* Fiber.join(fiber))
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
}),
)
itWithoutLocation.effect("omits location when no location is available", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const event = yield* events.publish(GlobalMessage, { text: "hello" })
expect(event).not.toHaveProperty("location")
expect(event.type).toBe("test.global")
}),
)
it.effect("publishes definition version", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const event = yield* events.publish(VersionedMessage, { text: "hello" })
expect(event.type).toBe("test.versioned")
expect(event.version).toBe(2)
}),
)
it.effect("stores definitions in the exported registry", () =>
Effect.sync(() => {
expect(EventV2.registry.get(Message.type)).toBe(Message)
}),
)
it.effect("publishes to typed and wildcard subscriptions", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* events.publish(Message, { text: "hello" })
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
}),
)
it.effect("runs sync handlers inline", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const unsubscribe = yield* events.sync((event) =>
Effect.sync(() => {
received.push(event)
}),
)
const event = yield* events.publish(Message, { text: "hello" })
yield* unsubscribe
yield* events.publish(Message, { text: "after unsubscribe" })
expect(received).toEqual([event])
}),
)
it.effect("runs sync handlers before publishing to streams", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
const fiber = yield* events.all().pipe(
Stream.take(1),
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
Effect.forkScoped,
)
yield* events.sync((event) =>
Effect.sync(() => {
received.push(event.type)
}),
)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "hello" })
yield* Fiber.join(fiber)
expect(received).toEqual([Message.type, "stream"])
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () =>
@@ -192,6 +192,6 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
)
})
@@ -13,12 +13,14 @@
| `kilo upgrade [target]` | upgrade kilo to the latest or a specific version |
| `kilo uninstall` | uninstall kilo and remove all related files |
| `kilo serve` | starts a headless kilo server |
| `kilo web` | start kilo server and open web interface |
| `kilo models [provider]` | list all available models |
| `kilo roll-call <filter>` | batch-test text models matching a filter for connectivity and latency |
| `kilo profile` | show Kilo account profile |
| `kilo stats` | show token usage and cost statistics |
| `kilo export [sessionID]` | export session data as JSON |
| `kilo import <file>` | import session data from JSON file or URL |
| `kilo github` | manage GitHub agent |
| `kilo pr <number>` | fetch and checkout a GitHub PR branch, then run kilo |
| `kilo session` | manage sessions |
| `kilo remote` | enable remote connection for real-time session relay |
@@ -682,6 +682,21 @@ Options:
--cors additional domains to allow for CORS [array] [default: []]
```
## kilo web
```
start kilo server and open web interface
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
```
## kilo models
```
@@ -768,6 +783,42 @@ Options:
--version Show version number [boolean]
```
## kilo github
```
manage GitHub agent
Commands:
kilo github install install the GitHub agent
kilo github run run the GitHub agent
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo github install
```
install the GitHub agent
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo github run
```
run the GitHub agent
Options:
--help Show help [boolean]
--version Show version number [boolean]
--event GitHub mock event to run the agent for [string]
--token GitHub personal access token (github_pat_********) [string]
```
## kilo pr
```
@@ -904,8 +955,6 @@ Options:
## kilo console
Open Kilo Console to manage CLI configuration, including **Settings > CLI > Notifications**. See [CLI Notifications and Sounds](/docs/code-with-ai/platforms/cli#cli-notifications-and-sounds) for the equivalent `tui.json` settings and custom sound overrides.
```
open the local Kilo Console
+44 -14
View File
@@ -53,7 +53,7 @@ import { getWorkspaceRoot } from "./review-utils"
import { createMarketplaceRemover, removeAgent, removeMcp } from "./kilo-provider/remove-config-item"
import type { RemoteStatusService } from "./services/RemoteStatusService"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { seedSessionStatuses } from "./session-status"
import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error"
import { retry } from "./services/cli-backend/retry"
import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata"
@@ -139,7 +139,9 @@ import {
completeProviderOAuth as completeOAuthAction,
disconnectProvider as disconnectProviderAction,
saveCustomProvider as saveCustomProviderAction,
resolveStoredKey,
} from "./provider-actions"
import type { StoredProviderKey } from "./provider-actions"
import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models"
import type { Agent } from "@kilocode/sdk/v2/client"
import { configFeatures } from "./features"
@@ -196,6 +198,7 @@ type LegacySyncEvent =
properties: Extract<SyncPayload, { name: "session.created.1" }>["data"]
}
| {
source: "sync"
id: string
type: "session.updated"
properties: Extract<SyncPayload, { name: "session.updated.1" }>["data"]
@@ -206,20 +209,30 @@ type LegacySyncEvent =
properties: Extract<SyncPayload, { name: "session.deleted.1" }>["data"]
}
type ProviderEvent = Event | LegacySyncEvent
type FullSessionUpdatedEvent = {
id: string
type: "session.updated"
properties: { sessionID: string; info: Session }
}
type ProviderEvent = Event | LegacySyncEvent | FullSessionUpdatedEvent
function isLegacySyncEvent(event: ProviderEvent): event is LegacySyncEvent {
if (event.type === "session.updated") return "source" in event && event.source === "sync"
return (
event.type === "message.updated" ||
event.type === "message.removed" ||
event.type === "message.part.updated" ||
event.type === "message.part.removed" ||
event.type === "session.created" ||
event.type === "session.updated" ||
event.type === "session.deleted"
)
}
function isFullSessionUpdatedEvent(event: ProviderEvent): event is FullSessionUpdatedEvent {
return event.type === "session.updated" && !isLegacySyncEvent(event)
}
function unwrapSyncEvent(event: GlobalEvent["payload"]): ProviderEvent | undefined {
if (event.type !== "sync") return event
@@ -235,7 +248,7 @@ function unwrapSyncEvent(event: GlobalEvent["payload"]): ProviderEvent | undefin
case "session.created.1":
return { id: event.id, type: "session.created", properties: event.data }
case "session.updated.1":
return { id: event.id, type: "session.updated", properties: event.data }
return { source: "sync", id: event.id, type: "session.updated", properties: event.data }
case "session.deleted.1":
return { id: event.id, type: "session.deleted", properties: event.data }
default:
@@ -257,6 +270,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private readonly extensionVersion =
vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown"
private cachedProvidersMessage: unknown = null
/**
* Provider API keys retained extension-side for authenticated model
* fetches (#10139). Keys are stripped before provider data reaches the
* webview, so fetch requests for an existing provider carry a providerID
* and the key is resolved here. Refreshed on every provider fetch.
*/
private storedProviderKeys: Record<string, StoredProviderKey> = {}
/** Coalesce provider refreshes — at most one follow-up rerun when a request lands mid-flight. */
private providersRefresh: Promise<void> | null = null
private providersQueued = false
@@ -1808,12 +1828,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return
}
try {
const { response, authMethods, authStates } = await fetchProviderData(client, this.getWorkspaceDirectory())
const { response, authMethods, authStates, storedKeys } = await fetchProviderData(
client,
this.getWorkspaceDirectory(),
)
if (generation !== this.providersGeneration || client !== this.client) {
if (!this.providersQueued) return
generation = this.providersGeneration
continue
}
this.storedProviderKeys = storedKeys
const settings = vscode.workspace.getConfiguration("kilo-code.new.model")
const message = {
type: "providersLoaded",
@@ -1900,7 +1924,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
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 key =
typeof msg.apiKey === "string" ? msg.apiKey : resolveStoredKey(this.storedProviderKeys, msg.providerID, url)
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 })
@@ -2358,11 +2383,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage(getWorkStylePayload())
}
/** Returns the number of sessions currently in "busy" state. */
private getBusySessionCount(): number {
return getBusySessionCount(this.sessionStatusMap)
}
private async handleUpdateConfig(
partial: Partial<Config>,
project: Partial<Config> = {},
@@ -3102,7 +3122,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return
if (
!isLegacySyncEvent(event) &&
!isFullSessionUpdatedEvent(event) &&
isEventFromForeignProject(event, this.projectID)
)
return
if (
this.projectID &&
(event.type === "session.created" || event.type === "session.updated") &&
@@ -3183,7 +3208,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.trackedSessionIds.add(event.properties.info.id)
}
if (event.type === "session.updated" && this.currentSession?.id === event.properties.sessionID) {
this.setCurrentSession(applySessionPatch(this.currentSession, event.properties.info))
const session = isLegacySyncEvent(event)
? applySessionPatch(this.currentSession, event.properties.info)
: event.properties.info
this.setCurrentSession(session)
this.contextSessionID = event.properties.sessionID
}
@@ -3226,7 +3254,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const msg = isLegacySyncEvent(event)
? this.mapSyncEventToWebviewMessage(event)
: mapSSEEventToWebviewMessage(event, sessionID)
: isFullSessionUpdatedEvent(event)
? { type: "sessionUpdated" as const, session: this.sessionToWebview(event.properties.info) }
: mapSSEEventToWebviewMessage(event, sessionID)
if (!msg) return
if (msg.type === "partUpdated") {
this.streams.push({ ...msg, part: this.slimPart(msg.part) })
@@ -399,7 +399,7 @@ export class KiloClawProvider implements vscode.Disposable {
return false
}
this.attachEventHandlers(events, chat)
this.attachEventHandlers(events)
this.subscribeSandboxContext()
return true
}
@@ -511,7 +511,7 @@ export class KiloClawProvider implements vscode.Disposable {
this.subscribedConversationContext = null
}
private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void {
private attachEventHandlers(events: EventServiceClient): void {
// Reset on reconnect — the event stream may have missed events while
// disconnected, so refetch authoritative state.
const offReconnect = events.onReconnect(() => {
@@ -89,10 +89,6 @@ export class KiloChatClient {
})
}
getConversation(conversationId: string): Promise<ConversationDetail> {
return this.request(`/v1/conversations/${conversationId}`)
}
createConversation(req: {
sandboxId: string
title?: string
@@ -26,11 +26,6 @@ export class TokenManager {
constructor(private readonly getClient: () => KiloClient | null) {}
/** Latest resolved token info (may be stale). Used for URL extraction. */
peek(): ChatToken | null {
return this.cached
}
/** Drop the cached token; next `get` will refetch. */
clear(): void {
this.cached = null
+33 -1
View File
@@ -18,6 +18,12 @@ import { configFeatures } from "./features"
*/
type AuthState = "api" | "oauth" | "wellknown"
/** API key retained extension-side for authenticated model fetches (#10139). */
export interface StoredProviderKey {
key: string
baseURL: string
}
function disabledWithout(list: string[] | undefined, id: string) {
return (list ?? []).filter((item) => item !== id)
}
@@ -59,17 +65,43 @@ export async function fetchProviderData(client: KiloClient, dir: string) {
authRequest,
])
const authStates: Record<string, AuthState> = {}
const storedKeys: Record<string, StoredProviderKey> = {}
const all = response.all.map((item) => {
const raw = item as Record<string, unknown>
if (typeof raw.id === "string" && typeof raw.key === "string" && raw.key) {
authStates[raw.id] = "api"
// Retain the key on the extension side so model fetches for an existing
// provider can authenticate without the webview ever seeing the secret
// (#10139). Only providers with a configured baseURL are retained — the
// fetch handler requires a URL match before applying a stored key.
const options = record(raw.options) ? raw.options : undefined
const baseURL = options && typeof options.baseURL === "string" ? options.baseURL : undefined
if (baseURL) storedKeys[raw.id] = { key: raw.key, baseURL }
}
if (!("key" in raw)) return item
const next = { ...raw }
delete next.key
return next as (typeof response.all)[number]
})
return { response: { ...response, all }, authMethods, authStates }
return { response: { ...response, all }, authMethods, authStates, storedKeys }
}
/**
* Resolve the stored API key for a model fetch on an existing provider.
* The key is only applied when the requested URL matches the provider's
* configured baseURL, so a stored secret can never be redirected to a
* different host (e.g. after the user edits the URL field).
*/
export function resolveStoredKey(
storedKeys: Record<string, StoredProviderKey>,
providerID: unknown,
url: string,
): string | undefined {
if (typeof providerID !== "string" || !providerID) return undefined
const stored = storedKeys[providerID]
if (!stored) return undefined
const normalize = (value: string) => value.trim().replace(/\/+$/, "")
return normalize(stored.baseURL) === normalize(url) ? stored.key : undefined
}
export function buildActionContext(
@@ -257,37 +257,6 @@ export class AutocompleteServiceManager {
return Date.now() < snoozeUntil
}
/**
* Get remaining snooze time in seconds
*/
public getSnoozeRemainingSeconds(): number {
const snoozeUntil = this.settings?.snoozeUntil
if (!snoozeUntil) {
return 0
}
const remaining = Math.max(0, Math.ceil((snoozeUntil - Date.now()) / 1000))
return remaining
}
/**
* Snooze autocomplete for a specified number of seconds
*/
public async snooze(seconds: number): Promise<void> {
if (this.snoozeTimer) {
clearTimeout(this.snoozeTimer)
this.snoozeTimer = null
}
const snoozeUntil = Date.now() + seconds * 1000
await writeSettings({ snoozeUntil })
this.snoozeTimer = setTimeout(() => {
void this.unsnooze()
}, seconds * 1000)
await this.load()
}
/**
* Cancel snooze and re-enable autocomplete
*/
@@ -84,10 +84,6 @@ vi.mock("../classic-auto-complete/AutocompleteInlineCompletionProvider", () => {
public setModel(id: string) {
this.modelId = id
}
public getModelId(): string {
return this.modelId
}
constructor(..._args: any[]) {}
}
return { AutocompleteInlineCompletionProvider }
@@ -317,21 +313,5 @@ describe("AutocompleteServiceManager (less mocked logic)", () => {
expect(manager.isSnoozed()).toBe(true)
})
it("getSnoozeRemainingSeconds() returns 0 when not snoozed", async () => {
const manager = await createManager()
;(manager as any).settings = {}
expect(manager.getSnoozeRemainingSeconds()).toBe(0)
})
it("getSnoozeRemainingSeconds() returns a positive number when snoozed", async () => {
const manager = await createManager()
;(manager as any).settings = { snoozeUntil: Date.now() + 30_000 }
const remaining = manager.getSnoozeRemainingSeconds()
expect(remaining).toBeGreaterThan(0)
expect(remaining).toBeLessThanOrEqual(30)
})
})
})
@@ -233,10 +233,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
this.contextProvider.modelId = modelId
}
public getModelId(): string {
return this.contextProvider.modelId
}
private processSuggestion(
suggestionText: string,
prefix: string,
@@ -133,13 +133,6 @@ export class ErrorBackoff {
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.
*/
@@ -55,9 +55,7 @@ vi.mock("../../continuedev/core/autocomplete/snippets/getAllSnippets", () => ({
rootPathSnippets: [],
recentlyEditedRangeSnippets: [],
recentlyVisitedRangesSnippets: [],
diffSnippets: [],
clipboardSnippets: [],
ideSnippets: [],
staticSnippet: [],
}),
}))
@@ -133,9 +131,7 @@ describe("AutocompleteContextProvider", () => {
rootPathSnippets: [],
recentlyEditedRangeSnippets: [],
recentlyVisitedRangesSnippets: [],
diffSnippets: [],
clipboardSnippets: [],
ideSnippets: [],
staticSnippet: [],
})
@@ -178,9 +174,7 @@ describe("AutocompleteContextProvider", () => {
rootPathSnippets: [],
recentlyEditedRangeSnippets: [],
recentlyVisitedRangesSnippets: [],
diffSnippets: [],
clipboardSnippets: [],
ideSnippets: [],
staticSnippet: [],
})
@@ -275,9 +269,7 @@ describe("AutocompleteContextProvider", () => {
rootPathSnippets: [],
recentlyEditedRangeSnippets: [],
recentlyVisitedRangesSnippets: [],
diffSnippets: [],
clipboardSnippets: [],
ideSnippets: [],
staticSnippet: [],
})
@@ -318,12 +310,6 @@ describe("AutocompleteContextProvider", () => {
rootPathSnippets: [],
recentlyEditedRangeSnippets: [],
recentlyVisitedRangesSnippets: [],
diffSnippets: [
{
content: "diff content",
type: AutocompleteSnippetType.Diff,
},
],
clipboardSnippets: [
{
content: "clipboard content",
@@ -331,14 +317,12 @@ describe("AutocompleteContextProvider", () => {
copiedAt: "2024-01-01",
},
],
ideSnippets: [],
staticSnippet: [],
})
const { getSnippets } = await import("../../continuedev/core/autocomplete/templating/filtering")
;(getSnippets as any).mockImplementation((_helper: any, payload: any) => [
...payload.recentlyOpenedFileSnippets,
...payload.diffSnippets,
...payload.clipboardSnippets,
])
@@ -357,11 +341,9 @@ describe("AutocompleteContextProvider", () => {
result.snippetsWithUris.some((s) => "filepath" in s && s.filepath && s.filepath.includes("blocked.ts")),
).toBe(false)
// But should contain snippets without file paths
expect(result.snippetsWithUris).toHaveLength(2)
expect(result.snippetsWithUris[0].content).toBe("diff content")
expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Diff)
expect(result.snippetsWithUris[1].content).toBe("clipboard content")
expect(result.snippetsWithUris[1].type).toBe(AutocompleteSnippetType.Clipboard)
expect(result.snippetsWithUris).toHaveLength(1)
expect(result.snippetsWithUris[0].content).toBe("clipboard content")
expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Clipboard)
})
it("should allow all files when no ignore controller is provided", async () => {
@@ -389,9 +371,7 @@ describe("AutocompleteContextProvider", () => {
rootPathSnippets: [],
recentlyEditedRangeSnippets: [],
recentlyVisitedRangesSnippets: [],
diffSnippets: [],
clipboardSnippets: [],
ideSnippets: [],
staticSnippet: [],
})
@@ -4,7 +4,6 @@ import { VsCodeIde } from "../continuedev/core/vscode-test-harness/src/VSCodeIde
import { AutocompleteInput } from "../types"
import { HelperVars } from "../continuedev/core/autocomplete/util/HelperVars"
import { getAllSnippetsWithoutRace } from "../continuedev/core/autocomplete/snippets/getAllSnippets"
import { getDefinitionsFromLsp } from "../continuedev/core/vscode-test-harness/src/autocomplete/lsp"
import { DEFAULT_AUTOCOMPLETE_OPTS } from "../continuedev/core/util/parameters"
import { getSnippets } from "../continuedev/core/autocomplete/templating/filtering"
import { FileIgnoreController } from "../shims/FileIgnoreController"
@@ -97,7 +96,6 @@ export async function getProcessedSnippets(
const snippetPayload = await getAllSnippetsWithoutRace({
helper,
ide,
getDefinitionsFromLsp,
contextRetrievalService: contextService,
})
@@ -24,8 +24,6 @@ import { extractDiffInfo as _extractDiffInfo } from "./visible-code-utils"
const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"]
export class VisibleCodeTracker {
private lastContext: VisibleCodeContext | null = null
constructor(
private workspacePath: string,
private ignoreController: FileIgnoreController | null = null,
@@ -101,19 +99,10 @@ export class VisibleCodeTracker {
})
}
this.lastContext = {
return {
timestamp: Date.now(),
editors: editorInfos,
}
return this.lastContext
}
/**
* Returns the last captured context, or null if never captured.
*/
public getLastContext(): VisibleCodeContext | null {
return this.lastContext
}
/**
@@ -1,6 +1,15 @@
import { getUriFileExtension } from "../../util/uri"
import { BracketMatchingService } from "../filtering/BracketMatchingService"
import { CharacterFilter, LineFilter } from "../filtering/streamTransforms/lineStream"
type LineStream = AsyncGenerator<string>
type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream
type CharacterFilter = (args: {
chars: AsyncGenerator<string>
prefix: string
suffix: string
filepath: string
multiline: boolean
}) => AsyncGenerator<string>
export interface AutocompleteLanguageInfo {
/**
@@ -1,125 +0,0 @@
import { describe, expect, it } from "vitest"
import { rankAndOrderSnippets, fillPromptWithSnippets } from "./index"
import { RankedSnippet } from "../../types"
// vibecoded
describe("rankAndOrderSnippets", () => {
it("should rank and order snippets by similarity to cursor context", () => {
// Create a simple mock HelperVars with only the required properties
const mockHelper = {
fullPrefix: "function calculateTotal(items) {\n const total = items.reduce(",
fullSuffix: ", 0);\n return total;\n}",
options: {
slidingWindowSize: 50,
slidingWindowPrefixPercentage: 0.5,
},
} as any
// Create test snippets with different levels of similarity to the cursor context
const snippets: RankedSnippet[] = [
{
filepath: "utils.ts",
range: {
start: { line: 10, character: 0 },
end: { line: 12, character: 0 },
},
contents: "// Helper function for database queries\nfunction queryDb() {}",
},
{
filepath: "math.ts",
range: {
start: { line: 5, character: 0 },
end: { line: 7, character: 0 },
},
contents: "// Array reduce function\nconst sum = items.reduce((acc, item) => acc + item, 0);",
},
{
filepath: "helpers.ts",
range: {
start: { line: 20, character: 0 },
end: { line: 22, character: 0 },
},
contents:
"// Calculate total with reduce\nfunction calculateSum(items) {\n return items.reduce((a, b) => a + b);\n}",
},
]
const result = rankAndOrderSnippets(snippets, mockHelper)
// Verify the result has the expected structure
expect(result).toHaveLength(3)
// All snippets should have scores assigned
expect(result.every((s) => typeof s.score === "number")).toBe(true)
// All snippets should have required properties
result.forEach((snippet) => {
expect(snippet.filepath).toBeDefined()
expect(snippet.range).toBeDefined()
expect(snippet.contents).toBeDefined()
expect(snippet.score).toBeGreaterThanOrEqual(0)
})
// Scores should be in ascending order (lower scores = better matches)
for (let i = 0; i < result.length - 1; i++) {
expect(result[i].score).toBeLessThanOrEqual(result[i + 1].score)
}
})
})
// vibecoded
describe("fillPromptWithSnippets", () => {
it("should fill token budget with snippets until limit is reached", () => {
// Create snippets with required properties (including score)
const snippets: Required<RankedSnippet>[] = [
{
filepath: "math.ts",
range: {
start: { line: 1, character: 0 },
end: { line: 3, character: 0 },
},
contents: "function add(a, b) { return a + b; }",
score: 0.1,
},
{
filepath: "utils.ts",
range: {
start: { line: 5, character: 0 },
end: { line: 7, character: 0 },
},
contents: "function multiply(x, y) { return x * y; }",
score: 0.2,
},
{
filepath: "helpers.ts",
range: {
start: { line: 10, character: 0 },
end: { line: 15, character: 0 },
},
contents: "function calculateSum(items) {\n return items.reduce((acc, item) => acc + item, 0);\n}",
score: 0.3,
},
]
// Set token limit to include first 2 snippets but not the third
// Using a model name and a reasonable token limit
const maxSnippetTokens = 25
const modelName = "gpt-3.5-turbo"
const result = fillPromptWithSnippets(snippets, maxSnippetTokens, modelName)
// Verify that we got fewer snippets than we started with
expect(result.length).toBeLessThan(snippets.length)
expect(result.length).toBeGreaterThan(0)
// Verify all returned snippets are from the original list
result.forEach((snippet) => {
expect(snippets).toContainEqual(snippet)
})
// Verify snippets maintain their order (first snippets are kept)
for (let i = 0; i < result.length; i++) {
expect(result[i]).toBe(snippets[i])
}
})
})
@@ -1,133 +1,9 @@
import { RangeInFileWithContents } from "../../../"
import { countTokens } from "../../../llm/countTokens"
import { RankedSnippet } from "../../types"
import { HelperVars } from "../../util/HelperVars"
const rx = /[\s.,/#!$%^&*;:{}=\-_`~()[\]]/g
export function getSymbolsForSnippet(snippet: string): Set<string> {
const symbols = snippet
.split(rx)
.map((s) => s.trim())
.filter((s) => s !== "")
.map((symbol) => symbol.trim())
.filter((symbol) => symbol !== "")
return new Set(symbols)
}
/**
* Calculate similarity as number of shared symbols divided by total number of unique symbols between both.
*/
function jaccardSimilarity(a: string, b: string): number {
const aSet = getSymbolsForSnippet(a)
const bSet = getSymbolsForSnippet(b)
const union = new Set([...aSet, ...bSet]).size
// Avoid division by zero
if (union === 0) {
return 0
}
let intersection = 0
for (const symbol of aSet) {
if (bSet.has(symbol)) {
intersection++
}
}
return intersection / union
}
/**
* Rank code snippets to be used in tab-autocomplete prompt. Returns a sorted version of the snippet array.
*/
export function rankAndOrderSnippets(ranges: RankedSnippet[], helper: HelperVars): Required<RankedSnippet>[] {
//MINIMAL_REPO - this isn't actually used in continue
const windowAroundCursor =
helper.fullPrefix.slice(-helper.options.slidingWindowSize * helper.options.slidingWindowPrefixPercentage) +
helper.fullSuffix.slice(helper.options.slidingWindowSize * (1 - helper.options.slidingWindowPrefixPercentage))
const snippets: Required<RankedSnippet>[] = ranges.map((snippet) => ({
score: snippet.score ?? jaccardSimilarity(snippet.contents, windowAroundCursor),
...snippet,
}))
const uniqueSnippets = deduplicateSnippets(snippets)
return uniqueSnippets.sort((a, b) => a.score - b.score)
}
/**
* Deduplicate code snippets by merging overlapping ranges into a single range.
*/
function deduplicateSnippets(snippets: Required<RankedSnippet>[]): Required<RankedSnippet>[] {
// Group by file
const fileGroups: {
[key: string]: Required<RankedSnippet>[]
} = {}
for (const snippet of snippets) {
if (!fileGroups[snippet.filepath]) {
fileGroups[snippet.filepath] = []
}
fileGroups[snippet.filepath].push(snippet)
}
// Merge overlapping ranges
const allRanges = []
for (const file of Object.keys(fileGroups)) {
allRanges.push(...mergeSnippetsByRange(fileGroups[file]))
}
return allRanges
}
function mergeSnippetsByRange(snippets: Required<RankedSnippet>[]): Required<RankedSnippet>[] {
if (snippets.length <= 1) {
return snippets
}
const sorted = snippets.sort((a, b) => a.range.start.line - b.range.start.line)
const merged: Required<RankedSnippet>[] = []
while (sorted.length > 0) {
const next = sorted.shift()!
const last = merged[merged.length - 1]
if (merged.length > 0 && last.range.end.line >= next.range.start.line) {
// Merge with previous snippet
last.score = Math.max(last.score, next.score)
try {
last.range.end = next.range.end
} catch (e) {
console.log("Error merging ranges", e)
}
last.contents = mergeOverlappingRangeContents(last, next)
} else {
merged.push(next)
}
}
return merged
}
function mergeOverlappingRangeContents(first: RangeInFileWithContents, second: RangeInFileWithContents): string {
const firstLines = first.contents.split("\n")
const numOverlapping = first.range.end.line - second.range.start.line
return `${firstLines.slice(-numOverlapping).join("\n")}\n${second.contents}`
}
/**
* Fill the allowed space with snippets.
* It is assumed that the snippets are sorted by score.
*/
export function fillPromptWithSnippets( //MINIMAL_REPO - this isn't actually used in continue
snippets: Required<RankedSnippet>[],
maxSnippetTokens: number,
modelName: string,
): Required<RankedSnippet>[] {
let tokensRemaining = maxSnippetTokens
const keptSnippets: Required<RankedSnippet>[] = []
for (let i = 0; i < snippets.length; i++) {
const snippet = snippets[i]
const tokenCount = countTokens(snippet.contents, modelName)
if (tokensRemaining - tokenCount >= 0) {
tokensRemaining -= tokenCount
keptSnippets.push(snippet)
}
}
return keptSnippets
}
@@ -48,31 +48,6 @@ export class StaticContextService {
})
}
public static formatAutocompleteStaticSnippet(ctx: StaticContext): string {
let output = `AutocompleteStaticSnippet:\n`
output += ` holeType: ${ctx.holeType}\n`
output += ` relevantTypes:\n`
if (ctx.relevantTypes.size === 0) {
output += ` (none)\n`
} else {
ctx.relevantTypes.forEach((types, filepath) => {
output += ` ${filepath}: [${types.join(", ")}]\n`
})
}
output += ` relevantHeaders:\n`
if (ctx.relevantHeaders.size === 0) {
output += ` (none)\n`
} else {
ctx.relevantHeaders.forEach((headers, filepath) => {
output += ` ${filepath}: [${headers.join(", ")}]\n`
})
}
return output
}
public async getContext(helper: HelperVars): Promise<AutocompleteStaticSnippet[]> {
const tsFiles = await this.getTypeScriptFilesFromWorkspaces(helper.workspaceUris)
// Get the three contexts holeContext, relevantTypes, relevantHeaders.
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest"
import { BracketMatchingService, BRACKETS, BRACKETS_REVERSE } from "./BracketMatchingService"
import { beforeEach, describe, expect, it } from "vitest"
import { BRACKETS, BRACKETS_REVERSE, BracketMatchingService } from "./BracketMatchingService"
describe("BracketMatchingService", () => {
let service: BracketMatchingService
@@ -8,300 +8,93 @@ describe("BracketMatchingService", () => {
service = new BracketMatchingService()
})
describe("BRACKETS constants", () => {
it("should have correct opening-to-closing bracket mappings", () => {
expect(BRACKETS["("]).toBe(")")
expect(BRACKETS["{"]).toBe("}")
expect(BRACKETS["["]).toBe("]")
})
it("should have correct closing-to-opening bracket mappings", () => {
expect(BRACKETS_REVERSE[")"]).toBe("(")
expect(BRACKETS_REVERSE["}"]).toBe("{")
expect(BRACKETS_REVERSE["]"]).toBe("[")
})
it("defines matching bracket pairs", () => {
expect(BRACKETS).toEqual({ "(": ")", "{": "}", "[": "]" })
expect(BRACKETS_REVERSE).toEqual({ ")": "(", "}": "{", "]": "[" })
})
describe("handleAcceptedCompletion", () => {
it("should track unmatched opening brackets from completion", () => {
service.handleAcceptedCompletion("function test() {", "test.ts")
// Internal state should track the unmatched opening brackets
// We can verify this by checking behavior in subsequent calls
})
async function* stream(chunks: string[]): AsyncGenerator<string> {
for (const chunk of chunks) yield chunk
}
it("should handle matched bracket pairs correctly", () => {
service.handleAcceptedCompletion("function test() { return 1; }", "test.ts")
// All brackets are matched, so stack should be empty
})
async function collect(gen: AsyncGenerator<string>): Promise<string> {
const chunks: string[] = []
for await (const chunk of gen) chunks.push(chunk)
return chunks.join("")
}
it("should handle multiple unmatched opening brackets", () => {
service.handleAcceptedCompletion("if (condition) { while (true) {", "test.ts")
// Should track both unmatched { brackets
})
it("should handle nested bracket structures", () => {
service.handleAcceptedCompletion("arr[0] = { key: [1, 2]", "test.ts")
// Should track unmatched { and [
})
it("should stop tracking when encountering unmatched closing bracket", () => {
service.handleAcceptedCompletion("function test() { } }", "test.ts")
// Should stop when encountering the extra closing brace
})
it("should handle different bracket types", () => {
service.handleAcceptedCompletion("const obj = { arr: [1, (2", "test.ts")
// Should track {, [, and (
})
it("should reset state for each new completion", () => {
service.handleAcceptedCompletion("function test() {", "test.ts")
service.handleAcceptedCompletion("class MyClass {", "test.ts")
// Second call should reset state from first call
})
it("should update filepath tracking", () => {
service.handleAcceptedCompletion("function a() {", "file1.ts")
service.handleAcceptedCompletion("function b() {", "file2.ts")
// Should track that we're now in file2.ts
})
it("should handle empty completion string", () => {
service.handleAcceptedCompletion("", "test.ts")
// Should not throw and should have empty stack
})
it("should handle completion with only text and no brackets", () => {
service.handleAcceptedCompletion("const x = 5;", "test.ts")
// Should complete successfully with empty bracket stack
})
it("should handle complex nested structure", () => {
service.handleAcceptedCompletion("obj = { a: [1, { b: (x", "test.ts")
// Should track {, [, {, (
})
it("allows a matching single-line closing bracket", async () => {
const result = service.stopOnUnmatchedClosingBracket(
stream(["x + 1)"]),
"const result = calculate(",
");",
"test.ts",
false,
)
expect(await collect(result)).toBe("x + 1)")
})
describe("stopOnUnmatchedClosingBracket", () => {
// Helper function to create async generator from array
async function* arrayToAsyncGen(arr: string[]): AsyncGenerator<string> {
for (const item of arr) {
yield item
}
}
it("stops at an unmatched single-line closing bracket", async () => {
const result = service.stopOnUnmatchedClosingBracket(
stream(["x + 1))"]),
"const result = calculate(",
"",
"test.ts",
false,
)
expect(await collect(result)).toBe("x + 1)")
})
// Helper to collect all values from async generator
async function collectAll(gen: AsyncGenerator<string>): Promise<string[]> {
const results: string[] = []
for await (const item of gen) {
results.push(item)
}
return results
}
it("tracks brackets opened by the current stream", async () => {
const result = service.stopOnUnmatchedClosingBracket(
stream(["function test() {", "\n return 1;", "\n}"]),
"",
"",
"test.ts",
true,
)
expect(await collect(result)).toBe("function test() {\n return 1;\n}")
})
describe("multiline mode", () => {
it("should allow closing brackets that match previous completion", async () => {
service.handleAcceptedCompletion("function test() {", "test.ts")
const stream = arrayToAsyncGen(["\n return 1;\n}"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() ", "", "test.ts", true)
const result = await collectAll(filtered)
expect(result.join("")).toBe("\n return 1;\n}")
})
it("stops at an unmatched multiline closing bracket", async () => {
const result = service.stopOnUnmatchedClosingBracket(
stream(["function test() {\n return 1;\n}\n}"]),
"",
"",
"test.ts",
true,
)
expect(await collect(result)).toBe("function test() {\n return 1;\n}\n")
})
it("should not use previous completion state from different file", async () => {
service.handleAcceptedCompletion("function test() {", "file1.ts")
const stream = arrayToAsyncGen(["}"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true)
const result = await collectAll(filtered)
// Different file so stack is empty, but '}' is in whitespace section
// Whitespace section (closing brackets before non-whitespace) yields without checking
// Since '}' doesn't match /[^\s\)\}\]]/, it's all whitespace/closing brackets
// So entire chunk is yielded and loop continues to end
expect(result.join("")).toBe("}")
})
it("handles nested bracket types", async () => {
const result = service.stopOnUnmatchedClosingBracket(stream(["arr[i][j]"]), "const val = ", ";", "test.ts", false)
expect(await collect(result)).toBe("arr[i][j]")
})
it("should stop on unmatched closing bracket in multiline", async () => {
const stream = arrayToAsyncGen(["function test() {\n return 1;\n}\n}"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
const result = await collectAll(filtered)
// The chunk contains one complete function and one extra '}'
// Processing char by char: '{' at position 16 opens, '}' at position 32 closes (stack empty)
// '\n' at position 33, then '}' at position 34 is unmatched (stack empty)
// Yields chunk.slice(0, 34) which includes the newline after the first }
expect(result.join("")).toBe("function test() {\n return 1;\n}\n")
})
it("uses closing brackets from a whitespace-prefixed suffix", async () => {
const result = service.stopOnUnmatchedClosingBracket(stream(["1, 2, 3)"]), "func(", " )", "test.ts", false)
expect(await collect(result)).toBe("1, 2, 3)")
})
it("should handle multiple chunks in stream", async () => {
const stream = arrayToAsyncGen(["function", " test()", " {", "\n return", " 1;", "\n}"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
const result = await collectAll(filtered)
expect(result.join("")).toBe("function test() {\n return 1;\n}")
})
})
it("stops suffix bracket parsing at other content", async () => {
const result = service.stopOnUnmatchedClosingBracket(stream(["x)"]), "func(", ") {", "test.ts", false)
expect(await collect(result)).toBe("x")
})
describe("single-line mode", () => {
it("should allow completing brackets from current line", async () => {
const stream = arrayToAsyncGen(["x + 1)"])
const filtered = service.stopOnUnmatchedClosingBracket(
stream,
"const result = calculate(",
");",
"test.ts",
false,
)
const result = await collectAll(filtered)
expect(result.join("")).toBe("x + 1)")
})
it("handles a closing bracket at a chunk boundary", async () => {
const result = service.stopOnUnmatchedClosingBracket(
stream(["return 1", ";", "\n", "}", "extra"]),
"function test() {",
"",
"test.ts",
true,
)
expect(await collect(result)).toBe("return 1;\n")
})
it("should handle bracket in suffix that gets overwritten", async () => {
const stream = arrayToAsyncGen(["1, 2, 3)"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "array.push(", ");", "test.ts", false)
const result = await collectAll(filtered)
// Should allow the closing paren because suffix has one
expect(result.join("")).toBe("1, 2, 3)")
})
it("should stop on unmatched closing bracket in single-line", async () => {
const stream = arrayToAsyncGen(["x + 1))"])
const filtered = service.stopOnUnmatchedClosingBracket(
stream,
"const result = calculate(",
"",
"test.ts",
false,
)
const result = await collectAll(filtered)
expect(result.join("")).toBe("x + 1)")
})
it("should handle multiple bracket pairs on current line", async () => {
const stream = arrayToAsyncGen(['" + y + ")])'])
const filtered = service.stopOnUnmatchedClosingBracket(
stream,
'array.push({ key: getValue("x',
"",
"test.ts",
false,
)
const result = await collectAll(filtered)
// Current line has: { ( (
// Stream closes: ) ] )
// First ) matches third (, second ] doesn't match second ( (expects }), stops before ]
expect(result.join("")).toBe('" + y + ")')
})
})
describe("edge cases", () => {
it("should handle empty stream", async () => {
const stream = arrayToAsyncGen([])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
const result = await collectAll(filtered)
expect(result).toEqual([])
})
it("should handle stream with only whitespace before brackets", async () => {
const stream = arrayToAsyncGen([" \n }"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true)
const result = await collectAll(filtered)
expect(result.join("")).toBe(" \n }")
})
it("should allow closing brackets before non-whitespace content", async () => {
const stream = arrayToAsyncGen([")\n const x = 1;"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test(", "", "test.ts", true)
const result = await collectAll(filtered)
// In multiline mode with no previous completion, stack starts empty but prefix has '('
// Actually, prefix is NOT processed in multiline mode (only previous completion state)
// Whitespace section: searches for /[^\s\)\}\]]/, finds 'c' at index 7
// Yields ')\n ' (everything before 'c'), then processes 'const x = 1;'
// 'const x = 1;' has no brackets, yields entire remaining chunk
expect(result.join("")).toBe(")\n const x = 1;")
})
it("should handle mixed bracket types correctly", async () => {
const stream = arrayToAsyncGen(["]})"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "obj = { arr: [{ key: val", "", "test.ts", true)
const result = await collectAll(filtered)
expect(result.join("")).toBe("]})")
})
it("should handle suffix with spaces before closing bracket", async () => {
const stream = arrayToAsyncGen(["1, 2, 3)"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", " )", "test.ts", false)
const result = await collectAll(filtered)
// Spaces in suffix should be ignored, bracket should be added to stack
expect(result.join("")).toBe("1, 2, 3)")
})
it("should stop when suffix parsing ends at non-bracket", async () => {
const stream = arrayToAsyncGen(["x)"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", ") {", "test.ts", false)
const result = await collectAll(filtered)
// In single-line mode, current line is 'func() {'
// Stack from current line: ( opens, ) closes (matches), { opens -> stack = ['{']
// Suffix ') {': unshift adds '(' to FRONT of stack -> stack = ['(', '{']
// Stream 'x)': ')' is closing bracket
// stack.pop() removes from END, returns '{', BRACKETS['{'] = '}', char = ')'
// '}' !== ')' so condition is true, stops and yields 'x'
expect(result.join("")).toBe("x")
})
it("should handle chunk boundary on closing bracket", async () => {
const stream = arrayToAsyncGen(["return 1", ";", "\n", "}", "extra"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true)
const result = await collectAll(filtered)
// In multiline mode without previous completion state, stack starts empty
// Prefix doesn't add to stack in multiline mode
// First chunk 'return 1' has no brackets, yielded
// Second chunk ';' has no brackets, yielded
// Third chunk '\n' is whitespace with no brackets, still in whitespace section
// Fourth chunk '}' is closing bracket in whitespace section, but stack is empty so stops immediately
expect(result.join("")).toBe("return 1;\n")
})
it("should handle nested brackets in stream", async () => {
const stream = arrayToAsyncGen(["arr[i][j]"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "const val = ", ";", "test.ts", false)
const result = await collectAll(filtered)
expect(result.join("")).toBe("arr[i][j]")
})
it("should handle unmatched opening brackets in stream", async () => {
const stream = arrayToAsyncGen(["arr[index"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
const result = await collectAll(filtered)
expect(result.join("")).toBe("arr[index")
})
})
describe("state persistence across completions", () => {
it("should use state from previous completion in same file", async () => {
service.handleAcceptedCompletion("if (cond) {\n while (true) {", "test.ts")
const stream = arrayToAsyncGen(["\n doWork();\n }\n}"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
const result = await collectAll(filtered)
expect(result.join("")).toBe("\n doWork();\n }\n}")
})
it("should not use state from previous file", async () => {
service.handleAcceptedCompletion("if (cond) {", "file1.ts")
const stream = arrayToAsyncGen(["}"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true)
const result = await collectAll(filtered)
// Different file so stack is empty, but '}' is in whitespace section
// Since '}' doesn't match /[^\s\)\}\]]/, entire chunk yielded without bracket checking
expect(result.join("")).toBe("}")
})
it("should clear state when switching files", async () => {
service.handleAcceptedCompletion("function a() {", "file1.ts")
service.handleAcceptedCompletion("function b() {", "file2.ts")
const stream = arrayToAsyncGen(["\n return;\n}"])
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true)
const result = await collectAll(filtered)
// Should only allow one closing brace from file2's state
expect(result.join("")).toBe("\n return;\n}")
})
})
it("handles an empty stream", async () => {
const result = service.stopOnUnmatchedClosingBracket(stream([]), "", "", "test.ts", true)
expect(await collect(result)).toBe("")
})
})
@@ -13,61 +13,27 @@ export const BRACKETS_REVERSE: { [key: string]: string } = {
* But sometimes we started the pair in a previous autocomplete suggestion
*/
export class BracketMatchingService {
private openingBracketsFromLastCompletion: string[] = []
private lastCompletionFile: string | undefined = undefined
handleAcceptedCompletion(completion: string, filepath: string) {
this.openingBracketsFromLastCompletion = []
const stack: string[] = []
for (let i = 0; i < completion.length; i++) {
const char = completion[i]
if (Object.keys(BRACKETS).includes(char)) {
// It's an opening bracket
stack.push(char)
} else if (Object.values(BRACKETS).includes(char)) {
// It's a closing bracket
if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) {
break
}
}
}
// Any remaining opening brackets in the stack are uncompleted
this.openingBracketsFromLastCompletion = stack
this.lastCompletionFile = filepath
}
async *stopOnUnmatchedClosingBracket(
stream: AsyncGenerator<string>,
prefix: string,
suffix: string,
filepath: string,
_filepath: string,
multiline: boolean, // Whether this is a multiline completion or not
): AsyncGenerator<string> {
let stack: string[] = []
if (multiline) {
// Add opening brackets from the previous response
if (this.lastCompletionFile === filepath) {
stack = [...this.openingBracketsFromLastCompletion]
} else {
this.lastCompletionFile = undefined
}
} else {
const stack: string[] = []
if (!multiline) {
// If single line completion, then allow completing bracket pairs that are
// started on the current line but not finished on the current line
if (!multiline) {
const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "")
for (let i = 0; i < currentLine.length; i++) {
const char = currentLine[i]
if (Object.keys(BRACKETS).includes(char)) {
// It's an opening bracket
stack.push(char)
} else if (Object.values(BRACKETS).includes(char)) {
// It's a closing bracket
if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) {
break
}
const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "")
for (let i = 0; i < currentLine.length; i++) {
const char = currentLine[i]
if (Object.keys(BRACKETS).includes(char)) {
// It's an opening bracket
stack.push(char)
} else if (Object.values(BRACKETS).includes(char)) {
// It's a closing bracket
if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) {
break
}
}
}
@@ -1,179 +0,0 @@
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"
import {
avoidPathLine,
avoidEmptyComments,
streamWithNewLines,
lineIsRepeated,
stopAtSimilarLine,
stopAtLines,
LINES_TO_STOP_AT,
PREFIXES_TO_SKIP,
skipPrefixes,
stopAtRepeatingLines,
} from "./lineStream"
describe("lineStream (production-used subset)", () => {
let mockFullStop: Mock
async function getLineGenerator(lines: any[]) {
return (async function* () {
for (const line of lines) {
yield line
}
})()
}
async function getFilteredLines(results: AsyncGenerator<string>) {
const output: string[] = []
for await (const line of results) {
output.push(line)
}
return output
}
beforeEach(() => {
mockFullStop = vi.fn()
})
describe("avoidPathLine", () => {
it("filters out '// Path: ...' lines", async () => {
const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"])
const result = avoidPathLine(linesGenerator, "//")
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["const x = 5;", "//", "console.log(x);"])
})
})
describe("avoidEmptyComments", () => {
it("filters out empty comment-only lines", async () => {
const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"])
const result = avoidEmptyComments(linesGenerator, "//")
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["// Path: src/index.ts", "const x = 5;", "console.log(x);"])
})
})
describe("streamWithNewLines", () => {
it("adds newline separators between lines", async () => {
const linesGenerator = await getLineGenerator(["line1", "line2", "line3"])
const result = streamWithNewLines(linesGenerator)
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["line1", "\n", "line2", "\n", "line3"])
})
})
describe("lineIsRepeated", () => {
it("returns true for similar lines", () => {
expect(lineIsRepeated("const x = 5;", "const x = 6;")).toBe(true)
})
it("returns false for different lines", () => {
expect(lineIsRepeated("const x = 5;", "let y = 10;")).toBe(false)
})
it("returns false for short lines", () => {
expect(lineIsRepeated("x=5", "x=6")).toBe(false)
})
})
describe("stopAtSimilarLine", () => {
it("stops at the exact same line", async () => {
const lineToTest = "const x = 6"
const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest])
const result = stopAtSimilarLine(linesGenerator, lineToTest, mockFullStop)
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["console.log();", "const y = () => {};"])
expect(mockFullStop).toHaveBeenCalledTimes(1)
})
it("stops at a similar line", async () => {
const lineToTest = "const x = 6;"
const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest])
const result = stopAtSimilarLine(linesGenerator, "a" + lineToTest, mockFullStop)
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["console.log();", "const y = () => {};"])
expect(mockFullStop).toHaveBeenCalledTimes(1)
})
it("continues on bracket-ending lines", async () => {
const linesGenerator = await getLineGenerator([" if (x > 0) {", " console.log(x);", " }"])
const result = stopAtSimilarLine(linesGenerator, "}", mockFullStop)
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual([" if (x > 0) {", " console.log(x);", " }"])
expect(mockFullStop).toHaveBeenCalledTimes(0)
})
})
describe("stopAtLines", () => {
it("stops at specified lines", async () => {
const linesGenerator = await getLineGenerator([
"const x = 5;",
"let y = 10;",
LINES_TO_STOP_AT[0],
"const z = 15;",
])
const result = stopAtLines(linesGenerator, mockFullStop)
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"])
expect(mockFullStop).toHaveBeenCalledTimes(1)
})
it("stops when stop phrase has leading whitespace", async () => {
const linesGenerator = await getLineGenerator([
"const x = 5;",
"let y = 10;",
` ${LINES_TO_STOP_AT[0]}`,
"const z = 15;",
])
const result = stopAtLines(linesGenerator, mockFullStop)
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"])
expect(mockFullStop).toHaveBeenCalledTimes(1)
})
})
describe("skipPrefixes", () => {
it("skips configured prefixes on the first line", async () => {
const linesGenerator = await getLineGenerator([`${PREFIXES_TO_SKIP[0]}const x = 5;`, "let y = 10;"])
const result = skipPrefixes(linesGenerator)
const filteredLines = await getFilteredLines(result)
expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"])
})
})
describe("stopAtRepeatingLines", () => {
it("yields non-repeating lines and does not stop prematurely", async () => {
const linesGenerator = await getLineGenerator(["a", "b", "c", "d", "e"])
const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop)
const filteredLines = await getFilteredLines(result as any)
expect(filteredLines).toEqual(["a", "b", "c", "d", "e"])
expect(mockFullStop).not.toHaveBeenCalled()
})
it("stops when a line repeats 3 times consecutively", async () => {
const linesGenerator = await getLineGenerator(["x", "x", "x", "x", "after"])
const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop)
const filteredLines = await getFilteredLines(result as any)
// Only the first of the repeating lines is yielded
expect(filteredLines).toEqual(["x"])
expect(mockFullStop).toHaveBeenCalledTimes(1)
})
})
})
@@ -1,219 +0,0 @@
import type { LineStream } from "../../../diff/util"
import { lineIsRepeated } from "../../util/textSimilarity"
export { lineIsRepeated }
export type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream
export type CharacterFilter = (args: {
chars: AsyncGenerator<string>
prefix: string
suffix: string
filepath: string
multiline: boolean
}) => AsyncGenerator<string>
const BRACKET_ENDING_CHARS = [")", "]", "}", ";"]
export const PREFIXES_TO_SKIP = ["<COMPLETION>"]
export const LINES_TO_STOP_AT = ["# End of file.", "<STOP EDITING HERE", "<|/updated_code|>", "```"]
function isBracketEnding(line: string): boolean {
return line
.trim()
.split("")
.some((char) => BRACKET_ENDING_CHARS.includes(char))
}
/**
* Validate whether a stop pattern in a line is in a valid context (not inside quotes or identifiers)
* and capture the text before the pattern.
* Internal helper for stopAtLines.
*/
function validatePatternInLine(
line: string,
pattern: string,
): {
isValid: boolean
patternIndex: number
beforePattern: string
} {
const patternIndex = line.indexOf(pattern)
if (patternIndex === -1) {
return { isValid: false, patternIndex: -1, beforePattern: "" }
}
// If preceded by a non-whitespace, treat as part of an identifier
if (patternIndex > 0) {
const charBefore = line[patternIndex - 1]
if (charBefore && !charBefore.match(/\s/)) {
return { isValid: false, patternIndex, beforePattern: "" }
}
}
const beforePattern = line.substring(0, patternIndex)
const singleQuotes = (beforePattern.match(/'/g) || []).length
const doubleQuotes = (beforePattern.match(/"/g) || []).length
// Odd number of quotes before the pattern - likely inside quotes
if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) {
return { isValid: false, patternIndex, beforePattern }
}
return { isValid: true, patternIndex, beforePattern }
}
/**
* Filter out lines starting with "// Path: <PATH>" which models sometimes echo.
*/
export async function* avoidPathLine(stream: LineStream, comment?: string): LineStream {
for await (const line of stream) {
if (comment && line.startsWith(`${comment} Path: `)) {
continue
}
yield line
}
}
/**
* Filter out empty comment-only lines.
*/
export async function* avoidEmptyComments(stream: LineStream, comment?: string): LineStream {
for await (const line of stream) {
if (!comment || line.trim() !== comment) {
yield line
}
}
}
/**
* Insert "\n" separators between streamed lines.
*/
export async function* streamWithNewLines(stream: LineStream): LineStream {
let firstLine = true
for await (const nextLine of stream) {
if (!firstLine) {
yield "\n"
}
firstLine = false
yield nextLine
}
}
/**
* Yield until a line equals or is very similar to the provided line, then call fullStop.
* If the provided line ends with a bracket/semicolon, allow exact trimmed matches to pass through.
*/
export async function* stopAtSimilarLine(
stream: LineStream,
line: string,
fullStop: () => void,
): AsyncGenerator<string> {
const trimmedLine = line.trim()
const lineIsBracketEnding = isBracketEnding(trimmedLine)
for await (const nextLine of stream) {
if (trimmedLine === "") {
yield nextLine
continue
}
if (lineIsBracketEnding && trimmedLine === nextLine.trim()) {
yield nextLine
continue
}
if (nextLine === line) {
fullStop()
break
}
if (lineIsRepeated(nextLine, trimmedLine)) {
fullStop()
break
}
yield nextLine
}
}
/**
* Yield until any of the stop phrases is encountered in a valid context, then call fullStop.
*/
export async function* stopAtLines(
stream: LineStream,
fullStop: () => void,
linesToStopAt: string[] = LINES_TO_STOP_AT,
): LineStream {
for await (const line of stream) {
let shouldStop = false
for (const stopAt of linesToStopAt) {
if (line.includes(stopAt)) {
const validation = validatePatternInLine(line, stopAt)
if (!validation.isValid) {
continue
}
const trimmedLine = line.trimStart()
if (trimmedLine.startsWith(stopAt)) {
shouldStop = true
break
} else {
const contentBeforeStopPhrase = validation.beforePattern.trimEnd()
if (contentBeforeStopPhrase.length < validation.beforePattern.length) {
shouldStop = true
break
}
}
}
}
if (shouldStop) {
fullStop()
break
}
yield line
}
}
/**
* On the first line only, strip any configured prefix (e.g. "<COMPLETION>").
*/
export async function* skipPrefixes(lines: LineStream): LineStream {
let isFirstLine = true
for await (const line of lines) {
if (isFirstLine) {
const match = PREFIXES_TO_SKIP.find((prefix) => line.startsWith(prefix))
if (match) {
yield line.slice(match.length)
continue
}
isFirstLine = false
}
yield line
}
}
/**
* Yield lines until a line repeats 3 times consecutively. Only the first of the repeats is yielded.
*/
export async function* stopAtRepeatingLines(lines: LineStream, fullStop: () => void): LineStream {
let previousLine: string | undefined
let repeatCount = 0
const MAX_REPEATS = 3
for await (const line of lines) {
if (line === previousLine) {
repeatCount++
if (repeatCount === MAX_REPEATS) {
fullStop()
return
}
} else {
yield line
repeatCount = 1
}
previousLine = line
}
}
@@ -1,450 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { getAllSnippets, getAllSnippetsWithoutRace } from "./getAllSnippets"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { ContextRetrievalService } from "../context/ContextRetrievalService"
import { AutocompleteSnippetType } from "../types"
import type { HelperVars } from "../util/HelperVars"
import type { IDE } from "../../index"
import type { GetLspDefinitionsFunction } from "../types"
import type { ContextRetrievalService } from "../context/ContextRetrievalService"
import { getAllSnippetsWithoutRace } from "./getAllSnippets"
describe("getAllSnippets", () => {
let mockHelper: HelperVars
let mockIde: IDE
let mockGetDefinitionsFromLsp: GetLspDefinitionsFunction
let mockContextRetrievalService: ContextRetrievalService
describe("getAllSnippetsWithoutRace", () => {
let helper: HelperVars
let ide: IDE
let context: ContextRetrievalService
beforeEach(() => {
// Create mock helper with minimal required properties
mockHelper = {
helper = {
input: {
filepath: "/test/file.ts",
recentlyEditedRanges: [
{
filepath: "/test/recent.ts",
lines: ["const x = 1;", "const y = 2;"],
},
],
recentlyEditedRanges: [{ filepath: "/test/recent.ts", lines: ["const x = 1;"] }],
recentlyVisitedRanges: [
{
filepath: "/test/visited.ts",
content: "visited content",
type: AutocompleteSnippetType.Code,
},
{ filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code },
],
},
filepath: "/test/file.ts",
fullPrefix: "const result = ",
fullSuffix: ";",
lang: "typescript",
options: {
onlyMyCode: false,
useRecentlyEdited: true,
useRecentlyOpened: true,
experimental_enableStaticContextualization: false,
},
} as any
// Create mock IDE
mockIde = {
getWorkspaceDirs: vi.fn().mockResolvedValue(["/test"]),
getClipboardContent: vi.fn().mockResolvedValue({
text: "clipboard content",
copiedAt: "2024-01-01T00:00:00.000Z",
}),
} as HelperVars
ide = {
getClipboardContent: vi.fn().mockResolvedValue({ text: "clipboard", copiedAt: "2024-01-01" }),
readFile: vi.fn().mockResolvedValue("file content"),
} as any
// Create mock LSP function
mockGetDefinitionsFromLsp = vi.fn().mockResolvedValue([])
// Create mock context retrieval service
mockContextRetrievalService = {
} as unknown as IDE
context = {
getRootPathSnippets: vi.fn().mockResolvedValue([]),
getSnippetsFromImportDefinitions: vi.fn().mockResolvedValue([]),
getStaticContextSnippets: vi.fn().mockResolvedValue([]),
} as any
} as unknown as ContextRetrievalService
})
afterEach(() => {
vi.clearAllMocks()
})
it("collects every active snippet source", async () => {
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
describe("getAllSnippets with race conditions", () => {
it("should return all snippet types", async () => {
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result).toHaveProperty("rootPathSnippets")
expect(result).toHaveProperty("importDefinitionSnippets")
expect(result).toHaveProperty("ideSnippets")
expect(result).toHaveProperty("recentlyEditedRangeSnippets")
expect(result).toHaveProperty("diffSnippets")
expect(result).toHaveProperty("clipboardSnippets")
expect(result).toHaveProperty("recentlyVisitedRangesSnippets")
expect(result).toHaveProperty("recentlyOpenedFileSnippets")
expect(result).toHaveProperty("staticSnippet")
})
it("should collect recently edited snippets synchronously", async () => {
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.recentlyEditedRangeSnippets).toHaveLength(1)
expect(result.recentlyEditedRangeSnippets[0]).toEqual({
filepath: "/test/recent.ts",
content: "const x = 1;\nconst y = 2;",
type: AutocompleteSnippetType.Code,
})
})
it("should pass through recently visited ranges", async () => {
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.recentlyVisitedRangesSnippets).toEqual(mockHelper.input.recentlyVisitedRanges)
})
it("should timeout slow snippet sources after default 100ms", async () => {
// Mock a slow service that takes 200ms
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve([
{
filepath: "/slow.ts",
content: "slow",
type: AutocompleteSnippetType.Code,
},
]),
200,
)
}),
)
const startTime = Date.now()
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
const duration = Date.now() - startTime
// Should timeout and return empty array, not wait 200ms
expect(result.rootPathSnippets).toEqual([])
expect(duration).toBeLessThan(150) // Some buffer for timing
})
it("should return results from fast sources even if other sources are slow", async () => {
// Mock one fast and one slow source
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([
{
filepath: "/fast.ts",
content: "fast",
type: AutocompleteSnippetType.Code,
},
])
mockContextRetrievalService.getSnippetsFromImportDefinitions = vi
.fn()
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve([]), 200)))
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
// Fast source should return results
expect(result.rootPathSnippets).toHaveLength(1)
expect(result.rootPathSnippets[0].content).toBe("fast")
// Slow source should timeout and return empty
expect(result.importDefinitionSnippets).toEqual([])
})
it("should collect clipboard snippets", async () => {
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.clipboardSnippets).toHaveLength(1)
expect(result.clipboardSnippets[0]).toEqual({
content: "clipboard content",
copiedAt: "2024-01-01T00:00:00.000Z",
type: AutocompleteSnippetType.Clipboard,
})
})
it("should handle empty results from snippet sources", async () => {
// All sources return empty
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([])
mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockResolvedValue([])
mockIde.getClipboardContent = vi.fn().mockResolvedValue({
text: "",
copiedAt: "2024-01-01T00:00:00.000Z",
})
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.rootPathSnippets).toEqual([])
expect(result.importDefinitionSnippets).toEqual([])
expect(result.clipboardSnippets).toHaveLength(1) // Still returns clipboard snippet
})
it("should return empty array for IDE snippets when disabled", async () => {
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
// IDE_SNIPPETS_ENABLED is false in the implementation
expect(result.ideSnippets).toEqual([])
expect(mockGetDefinitionsFromLsp).not.toHaveBeenCalled()
})
it("should return empty array for diff snippets (temporarily disabled)", async () => {
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.diffSnippets).toEqual([])
})
it("should handle option useRecentlyEdited = false", async () => {
mockHelper.options.useRecentlyEdited = false
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.recentlyEditedRangeSnippets).toEqual([])
})
it("should handle option useRecentlyOpened = false", async () => {
mockHelper.options.useRecentlyOpened = false
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.recentlyOpenedFileSnippets).toEqual([])
})
it("should collect static context snippets when experimental flag is enabled", async () => {
mockHelper.options.experimental_enableStaticContextualization = true
mockContextRetrievalService.getStaticContextSnippets = vi.fn().mockResolvedValue([
{
filepath: "/static.ts",
content: "static",
type: AutocompleteSnippetType.Static,
},
])
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.staticSnippet).toHaveLength(1)
expect(result.staticSnippet[0].content).toBe("static")
})
it("should return empty static snippets when experimental flag is disabled", async () => {
mockHelper.options.experimental_enableStaticContextualization = false
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result.staticSnippet).toEqual([])
expect(mockContextRetrievalService.getStaticContextSnippets).not.toHaveBeenCalled()
expect(result).toEqual({
rootPathSnippets: [],
importDefinitionSnippets: [],
recentlyEditedRangeSnippets: [
{ filepath: "/test/recent.ts", content: "const x = 1;", type: AutocompleteSnippetType.Code },
],
recentlyVisitedRangesSnippets: [
{ filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code },
],
clipboardSnippets: [{ content: "clipboard", copiedAt: "2024-01-01", type: AutocompleteSnippetType.Clipboard }],
recentlyOpenedFileSnippets: [],
staticSnippet: [],
})
})
describe("error handling", () => {
it("should propagate errors from context retrieval service", async () => {
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error"))
it("honors disabled recent-context sources", async () => {
helper.options.useRecentlyEdited = false
helper.options.useRecentlyOpened = false
// Errors are not caught by racePromise - they propagate if they occur before timeout
await expect(
getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
expect(result.recentlyEditedRangeSnippets).toEqual([])
expect(result.recentlyOpenedFileSnippets).toEqual([])
})
it("collects import definitions and enabled static context", async () => {
helper.options.experimental_enableStaticContextualization = true
context.getSnippetsFromImportDefinitions = vi
.fn()
.mockResolvedValue([{ filepath: "/import.ts", content: "imported", type: AutocompleteSnippetType.Code }])
context.getStaticContextSnippets = vi
.fn()
.mockResolvedValue([{ filepath: "/static.ts", content: "static", type: AutocompleteSnippetType.Static }])
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
expect(result.importDefinitionSnippets[0]?.content).toBe("imported")
expect(result.staticSnippet[0]?.content).toBe("static")
})
it("propagates clipboard failures", async () => {
ide.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error"))
await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow(
"Clipboard error",
)
})
it("waits for active context sources without a collector timeout", async () => {
context.getRootPathSnippets = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(
() => resolve([{ filepath: "/slow.ts", content: "slow", type: AutocompleteSnippetType.Code }]),
120,
)
}),
).rejects.toThrow("Service error")
})
)
it("should propagate errors from IDE clipboard", async () => {
mockIde.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error"))
// Errors are not caught by racePromise - they propagate if they occur before timeout
await expect(
getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
}),
).rejects.toThrow("Clipboard error")
})
it("should pass through null from snippet sources", async () => {
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue(null as any)
const result = await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
// racePromise returns null if the promise resolves to null (not converted to [])
expect(result.rootPathSnippets).toBeNull()
})
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
expect(result.rootPathSnippets[0]?.content).toBe("slow")
})
describe("getAllSnippetsWithoutRace", () => {
it("should wait for all promises without timeout", async () => {
// Mock a slow service that takes 200ms
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve([
{
filepath: "/slow.ts",
content: "slow",
type: AutocompleteSnippetType.Code,
},
]),
200,
)
}),
)
it("propagates context retrieval failures", async () => {
context.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error"))
const result = await getAllSnippetsWithoutRace({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
// Should wait and get results, not timeout
expect(result.rootPathSnippets).toHaveLength(1)
expect(result.rootPathSnippets[0].content).toBe("slow")
})
it("should return all snippet types without racing", async () => {
const result = await getAllSnippetsWithoutRace({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
expect(result).toHaveProperty("rootPathSnippets")
expect(result).toHaveProperty("importDefinitionSnippets")
expect(result).toHaveProperty("ideSnippets")
expect(result).toHaveProperty("recentlyEditedRangeSnippets")
expect(result).toHaveProperty("diffSnippets")
expect(result).toHaveProperty("clipboardSnippets")
expect(result).toHaveProperty("recentlyVisitedRangesSnippets")
expect(result).toHaveProperty("recentlyOpenedFileSnippets")
expect(result).toHaveProperty("staticSnippet")
})
it("should handle errors without race timeout", async () => {
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error"))
// Should propagate error since no timeout
await expect(
getAllSnippetsWithoutRace({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
}),
).rejects.toThrow("Service error")
})
})
describe("parallel execution", () => {
it("should execute all snippet collections in parallel", async () => {
const executionOrder: string[] = []
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(async () => {
executionOrder.push("rootPath-start")
await new Promise((resolve) => setTimeout(resolve, 50))
executionOrder.push("rootPath-end")
return []
})
mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockImplementation(async () => {
executionOrder.push("import-start")
await new Promise((resolve) => setTimeout(resolve, 50))
executionOrder.push("import-end")
return []
})
mockIde.getClipboardContent = vi.fn().mockImplementation(async () => {
executionOrder.push("clipboard-start")
await new Promise((resolve) => setTimeout(resolve, 50))
executionOrder.push("clipboard-end")
return { text: "test", copiedAt: "2024-01-01T00:00:00.000Z" }
})
await getAllSnippets({
helper: mockHelper,
ide: mockIde,
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
contextRetrievalService: mockContextRetrievalService,
})
// All should start before any complete (parallel execution)
expect(executionOrder[0]).toBe("rootPath-start")
expect(executionOrder[1]).toBe("import-start")
expect(executionOrder[2]).toBe("clipboard-start")
})
await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow(
"Service error",
)
})
})
@@ -1,215 +1,89 @@
import { IDE } from "../../index"
import { findUriInDirs } from "../../util/uri"
import { ContextRetrievalService } from "../context/ContextRetrievalService"
import { GetLspDefinitionsFunction } from "../types"
import { HelperVars } from "../util/HelperVars"
import { openedFilesLruCache } from "../util/openedFilesLruCache"
import {
AutocompleteClipboardSnippet,
AutocompleteCodeSnippet,
AutocompleteDiffSnippet,
AutocompleteSnippetType,
AutocompleteStaticSnippet,
} from "../types"
const IDE_SNIPPETS_ENABLED = false // ideSnippets is not used, so it's temporarily disabled
export interface SnippetPayload {
rootPathSnippets: AutocompleteCodeSnippet[]
importDefinitionSnippets: AutocompleteCodeSnippet[]
ideSnippets: AutocompleteCodeSnippet[]
recentlyEditedRangeSnippets: AutocompleteCodeSnippet[]
recentlyVisitedRangesSnippets: AutocompleteCodeSnippet[]
diffSnippets: AutocompleteDiffSnippet[]
clipboardSnippets: AutocompleteClipboardSnippet[]
recentlyOpenedFileSnippets: AutocompleteCodeSnippet[]
staticSnippet: AutocompleteStaticSnippet[]
}
function racePromise<T>(promise: Promise<T[]>, timeout = 100): Promise<T[]> {
const timeoutPromise = new Promise<T[]>((resolve) => {
setTimeout(() => resolve([]), timeout)
})
return Promise.race([promise, timeoutPromise])
}
// Some IDEs might have special ways of finding snippets (e.g. JetBrains and VS Code have different "LSP-equivalent" systems,
// or they might separately track recently edited ranges)
async function getIdeSnippets(
helper: HelperVars,
ide: IDE,
getDefinitionsFromLsp: GetLspDefinitionsFunction,
): Promise<AutocompleteCodeSnippet[]> {
const ideSnippets = await getDefinitionsFromLsp(
helper.input.filepath,
helper.fullPrefix + helper.fullSuffix,
helper.fullPrefix.length,
ide,
helper.lang,
)
if (helper.options.onlyMyCode) {
const workspaceDirs = await ide.getWorkspaceDirs()
return ideSnippets.filter((snippet) =>
workspaceDirs.some((dir) => !!findUriInDirs(snippet.filepath, [dir]).foundInDir),
)
}
return ideSnippets
}
function getSnippetsFromRecentlyEditedRanges(helper: HelperVars): AutocompleteCodeSnippet[] {
if (helper.options.useRecentlyEdited === false) {
return []
}
if (helper.options.useRecentlyEdited === false) return []
return helper.input.recentlyEditedRanges.map((range) => {
return {
filepath: range.filepath,
content: range.lines.join("\n"),
type: AutocompleteSnippetType.Code,
}
})
return helper.input.recentlyEditedRanges.map((range) => ({
filepath: range.filepath,
content: range.lines.join("\n"),
type: AutocompleteSnippetType.Code,
}))
}
const getClipboardSnippets = async (ide: IDE): Promise<AutocompleteClipboardSnippet[]> => {
const content = await ide.getClipboardContent()
return [content].map((item) => {
return {
content: item.text,
copiedAt: item.copiedAt,
return [
{
content: content.text,
copiedAt: content.copiedAt,
type: AutocompleteSnippetType.Clipboard,
}
})
},
]
}
const getSnippetsFromRecentlyOpenedFiles = async (helper: HelperVars, ide: IDE): Promise<AutocompleteCodeSnippet[]> => {
if (helper.options.useRecentlyOpened === false) {
return []
}
if (helper.options.useRecentlyOpened === false) return []
try {
const currentFileUri = `${helper.filepath}`
// Get all file URIs excluding the current file
const fileUrisToRead = [...openedFilesLruCache.entriesDescending()]
.filter(([fileUri, _]) => fileUri !== currentFileUri)
.map(([fileUri, _]) => fileUri)
// Create an array of promises that each read a file with timeout
const fileReadPromises = fileUrisToRead.map((fileUri) => {
// Create a promise that resolves to a snippet or null
const readPromise = new Promise<AutocompleteCodeSnippet | null>((resolve) => {
const current = `${helper.filepath}`
const uris = [...openedFilesLruCache.entriesDescending()].filter(([uri]) => uri !== current).map(([uri]) => uri)
const reads = uris.map((uri) => {
const read = new Promise<AutocompleteCodeSnippet | null>((resolve) => {
ide
.readFile(fileUri)
.then((fileContent) => {
if (!fileContent || fileContent.trim() === "") {
.readFile(uri)
.then((content) => {
if (!content || content.trim() === "") {
resolve(null)
return
}
resolve({
filepath: fileUri,
content: fileContent,
type: AutocompleteSnippetType.Code,
})
resolve({ filepath: uri, content, type: AutocompleteSnippetType.Code })
})
.catch((e) => {
console.error(`Failed to read file ${fileUri}:`, e)
.catch((err) => {
console.error(`Failed to read file ${uri}:`, err)
resolve(null)
})
})
// Cut off at 80ms via racing promises
return Promise.race([readPromise, new Promise<null>((resolve) => setTimeout(() => resolve(null), 80))])
return Promise.race([read, new Promise<null>((resolve) => setTimeout(() => resolve(null), 80))])
})
// Execute all file reads in parallel
const results = await Promise.all(fileReadPromises)
// Filter out null results
const results = await Promise.all(reads)
return results.filter(Boolean) as AutocompleteCodeSnippet[]
} catch (e) {
console.error("Error processing opened files cache:", e)
} catch (err) {
console.error("Error processing opened files cache:", err)
return []
}
}
export const getAllSnippets = async ({
helper,
ide,
getDefinitionsFromLsp,
contextRetrievalService,
}: {
helper: HelperVars
ide: IDE
getDefinitionsFromLsp: GetLspDefinitionsFunction
contextRetrievalService: ContextRetrievalService
}): Promise<SnippetPayload> => {
const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper)
const [
rootPathSnippets,
importDefinitionSnippets,
ideSnippets,
diffSnippets,
clipboardSnippets,
recentlyOpenedFileSnippets,
staticSnippet,
] = await Promise.all([
racePromise(contextRetrievalService.getRootPathSnippets(helper)),
racePromise(contextRetrievalService.getSnippetsFromImportDefinitions(helper)),
IDE_SNIPPETS_ENABLED ? racePromise(getIdeSnippets(helper, ide, getDefinitionsFromLsp)) : [],
[], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882,
racePromise(getClipboardSnippets(ide)),
racePromise(getSnippetsFromRecentlyOpenedFiles(helper, ide)), // giving this one a little more time to complete
helper.options.experimental_enableStaticContextualization
? racePromise(contextRetrievalService.getStaticContextSnippets(helper))
: [],
])
return {
rootPathSnippets,
importDefinitionSnippets,
ideSnippets,
recentlyEditedRangeSnippets,
diffSnippets,
clipboardSnippets,
recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges,
recentlyOpenedFileSnippets,
staticSnippet,
}
}
export const getAllSnippetsWithoutRace = async ({
helper,
ide,
getDefinitionsFromLsp,
contextRetrievalService,
}: {
helper: HelperVars
ide: IDE
getDefinitionsFromLsp: GetLspDefinitionsFunction
contextRetrievalService: ContextRetrievalService
}): Promise<SnippetPayload> => {
const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper)
const [
rootPathSnippets,
importDefinitionSnippets,
ideSnippets,
diffSnippets,
clipboardSnippets,
recentlyOpenedFileSnippets,
staticSnippet,
] = await Promise.all([
const [root, imports, clipboard, opened, staticSnippet] = await Promise.all([
contextRetrievalService.getRootPathSnippets(helper),
contextRetrievalService.getSnippetsFromImportDefinitions(helper),
IDE_SNIPPETS_ENABLED ? getIdeSnippets(helper, ide, getDefinitionsFromLsp) : [],
[], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882,
getClipboardSnippets(ide),
getSnippetsFromRecentlyOpenedFiles(helper, ide),
helper.options.experimental_enableStaticContextualization
@@ -218,14 +92,12 @@ export const getAllSnippetsWithoutRace = async ({
])
return {
rootPathSnippets,
importDefinitionSnippets,
ideSnippets,
recentlyEditedRangeSnippets,
diffSnippets,
clipboardSnippets,
rootPathSnippets: root,
importDefinitionSnippets: imports,
recentlyEditedRangeSnippets: getSnippetsFromRecentlyEditedRanges(helper),
recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges,
recentlyOpenedFileSnippets,
clipboardSnippets: clipboard,
recentlyOpenedFileSnippets: opened,
staticSnippet,
}
}
@@ -5,7 +5,7 @@
import { CompletionOptions } from "../../index.js"
import { getLastNUriRelativePathParts, getShortestUniqueRelativeUriPaths } from "../../util/uri.js"
import { AutocompleteSnippet, AutocompleteSnippetType } from "../types.js"
import { AutocompleteSnippet } from "../types.js"
type TemplateRenderer = (
prefix: string,
@@ -49,13 +49,7 @@ const codestralMultifileFimTemplate: AutocompleteTemplate = {
)
const otherFiles = snippets
.map((snippet, i) => {
if (snippet.type === AutocompleteSnippetType.Diff) {
return snippet.content
}
return `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}`
})
.map((snippet, i) => `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}`)
.join("\n\n")
return [`${otherFiles}\n\n+++++ ${getFileName(relativePaths[relativePaths.length - 1])}\n${prefix}`, suffix]
@@ -93,13 +87,7 @@ const mercuryMultifileFimTemplate: AutocompleteTemplate = {
)
const otherFiles = snippets
.map((snippet, i) => {
if (snippet.type === AutocompleteSnippetType.Diff) {
return snippet.content
}
return `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}`
})
.map((snippet, i) => `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}`)
.join("\n\n")
return [
@@ -4,7 +4,7 @@
*/
import { describe, expect, test } from "vitest"
import { AutocompleteCodeSnippet, AutocompleteDiffSnippet, AutocompleteSnippetType } from "../../types"
import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../types"
import { HelperVars } from "../../util/HelperVars"
import { formatOpenedFilesContext } from "../formatOpenedFilesContext"
@@ -25,11 +25,6 @@ describe("formatOpenedFilesContext main function tests", () => {
content,
})
const createDiffSnippet = (content: string): AutocompleteDiffSnippet => ({
type: AutocompleteSnippetType.Diff,
content,
})
test("should return empty array when no snippets are provided", () => {
const result = formatOpenedFilesContext([], 1000, mockHelper, [], TOKEN_BUFFER)
expect(result).toEqual([])
@@ -99,7 +94,7 @@ describe("formatOpenedFilesContext main function tests", () => {
createCodeSnippet("file2.ts", "content of file 2"),
]
const alreadyAddedSnippets = [createDiffSnippet("diff content")]
const alreadyAddedSnippets = [createCodeSnippet("added.ts", "added content")]
const result = formatOpenedFilesContext(snippets, 1000, mockHelper, alreadyAddedSnippets, TOKEN_BUFFER)
@@ -44,7 +44,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco
clipboard: payload.clipboardSnippets,
recentlyVisitedRanges: payload.recentlyVisitedRangesSnippets,
recentlyEditedRanges: payload.recentlyEditedRangeSnippets,
diff: payload.diffSnippets,
recentlyOpenedFiles: payload.recentlyOpenedFileSnippets,
base: shuffleArray(
filterSnippetsAlreadyInCaretWindow(
@@ -88,13 +87,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco
defaultPriority: 4,
snippets: payload.recentlyEditedRangeSnippets,
},
{
key: "diff",
enabledOrPriority: helper.options.experimental_includeDiff,
defaultPriority: 5,
snippets: payload.diffSnippets,
// TODO: diff is commonly too large, thus anything lower in priority is not included.
},
{
key: "base",
enabledOrPriority: true,
@@ -1,9 +1,7 @@
import { IDE, RangeInFileWithContents } from "../index"
import { AutocompleteLanguageInfo } from "./constants/AutocompleteLanguageInfo"
import { RangeInFileWithContents } from "../index"
export enum AutocompleteSnippetType {
Code = "code",
Diff = "diff",
Clipboard = "clipboard",
Static = "static",
}
@@ -18,10 +16,6 @@ export interface AutocompleteCodeSnippet extends BaseAutocompleteSnippet {
type: AutocompleteSnippetType.Code
}
export interface AutocompleteDiffSnippet extends BaseAutocompleteSnippet {
type: AutocompleteSnippetType.Diff
}
export interface AutocompleteClipboardSnippet extends BaseAutocompleteSnippet {
type: AutocompleteSnippetType.Clipboard
copiedAt: string
@@ -32,20 +26,8 @@ export interface AutocompleteStaticSnippet extends BaseAutocompleteSnippet {
filepath: string
}
export type AutocompleteSnippet =
| AutocompleteCodeSnippet
| AutocompleteDiffSnippet
| AutocompleteClipboardSnippet
| AutocompleteStaticSnippet
export type AutocompleteSnippet = AutocompleteCodeSnippet | AutocompleteClipboardSnippet | AutocompleteStaticSnippet
export type RankedSnippet = RangeInFileWithContents & {
score?: number
}
export type GetLspDefinitionsFunction = (
filepath: string,
contents: string,
cursorIndex: number,
ide: IDE,
lang: AutocompleteLanguageInfo,
) => Promise<AutocompleteCodeSnippet[]>
@@ -1,85 +0,0 @@
// Generated by continue
import { describe, expect, it, vi } from "vitest"
import { ChatMessage } from "../index"
import { generateLines, streamLines } from "./util"
describe("streamLines", () => {
it("should split chunks into lines correctly", async () => {
async function* streamCompletion(): AsyncGenerator<string> {
yield "line1\nline"
yield "2\nline3\n"
yield "line4"
}
const resultLines: string[] = []
for await (const line of streamLines(streamCompletion())) {
resultLines.push(line)
}
expect(resultLines).toEqual(["line1", "line2", "line3", "line4"])
})
it("should handle ChatMessage chunks", async () => {
const messageChunk1: ChatMessage = {
role: "assistant",
content: "line1\nline",
}
const messageChunk2: ChatMessage = {
role: "assistant",
content: "2\nline3\n",
}
const messageChunk3: ChatMessage = {
role: "assistant",
content: "line4",
}
// const spy = vi.spyOn(messageContentModule, "renderChatMessage");
async function* streamCompletion(): AsyncGenerator<ChatMessage> {
yield messageChunk1
yield messageChunk2
yield messageChunk3
}
const resultLines: string[] = []
for await (const line of streamLines(streamCompletion())) {
resultLines.push(line)
}
expect(resultLines).toEqual(["line1", "line2", "line3", "line4"])
// expect(spy).toHaveBeenCalledTimes(3);
})
it("should log lines if log parameter is true", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
async function* streamCompletion(): AsyncGenerator<string> {
yield "line1\nline2\n"
yield "line3"
}
const resultLines: string[] = []
for await (const line of streamLines(streamCompletion(), true)) {
resultLines.push(line)
}
expect(resultLines).toEqual(["line1", "line2", "line3"])
expect(consoleSpy).toHaveBeenCalledWith("Streamed lines: ", "line1\nline2\nline3")
consoleSpy.mockRestore()
})
})
describe("generateLines", () => {
it("should yield the lines provided in the array", async () => {
const lines = ["line1", "line2", "line3"]
const resultLines: string[] = []
for await (const line of generateLines(lines)) {
resultLines.push(line)
}
expect(resultLines).toEqual(lines)
})
})
@@ -1,42 +0,0 @@
import type { ChatMessage } from "../index.js"
import { renderChatMessage } from "../util/messageContent.js"
export type LineStream = AsyncGenerator<string>
/**
* Convert a stream of arbitrary chunks to a stream of lines
*/
export async function* streamLines(
streamCompletion: AsyncGenerator<string | ChatMessage>,
log: boolean = false,
): LineStream {
const allLines = []
let buffer = ""
try {
for await (const update of streamCompletion) {
const chunk = typeof update === "string" ? update : renderChatMessage(update)
buffer += chunk
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
yield line
allLines.push(line)
}
}
if (buffer.length > 0) {
yield buffer
allLines.push(buffer)
}
} finally {
if (log) {
console.log("Streamed lines: ", allLines.join("\n"))
}
}
}
export async function* generateLines<T>(lines: T[]): AsyncGenerator<T> {
for (const line of lines) {
yield line
}
}
@@ -531,7 +531,6 @@ export interface TabAutocompleteOptions {
experimental_includeClipboard: boolean | number
experimental_includeRecentlyVisitedRanges: boolean | number
experimental_includeRecentlyEditedRanges: boolean | number
experimental_includeDiff: boolean | number
experimental_enableStaticContextualization: boolean
}
@@ -1,23 +0,0 @@
import { ChatMessage, MessageContent, TextMessagePart } from "../index"
function stripImages(messageContent: MessageContent): string {
if (typeof messageContent === "string") {
return messageContent
}
return messageContent
.filter((part) => part.type === "text")
.map((part) => (part as TextMessagePart).text)
.join("\n")
}
export function renderChatMessage(message: ChatMessage): string {
switch (message?.role) {
case "user":
case "assistant":
case "system":
return stripImages(message.content)
default:
return ""
}
}
@@ -25,6 +25,5 @@ export const DEFAULT_AUTOCOMPLETE_OPTS: TabAutocompleteOptions = {
experimental_includeClipboard: false,
experimental_includeRecentlyVisitedRanges: true,
experimental_includeRecentlyEditedRanges: true,
experimental_includeDiff: true,
experimental_enableStaticContextualization: false,
}
@@ -13,34 +13,3 @@ export function getRangeInString(content: string, range: Range): string {
return [firstLine, ...middleLines, lastLine].join("\n")
}
export function intersection(a: Range, b: Range): Range | null {
const startLine = Math.max(a.start.line, b.start.line)
const endLine = Math.min(a.end.line, b.end.line)
if (startLine > endLine) {
return null
}
if (startLine === endLine) {
const startCharacter = Math.max(a.start.character, b.start.character)
const endCharacter = Math.min(a.end.character, b.end.character)
if (startCharacter > endCharacter) {
return null
}
return {
start: { line: startLine, character: startCharacter },
end: { line: endLine, character: endCharacter },
}
}
const startCharacter = startLine === a.start.line ? a.start.character : b.start.character
const endCharacter = endLine === a.end.line ? a.end.character : b.end.character
return {
start: { line: startLine, character: startCharacter },
end: { line: endLine, character: endCharacter },
}
}
@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest"
import { getSymbolsForFile } from "./treeSitter"
// vibecoded
describe("getSymbolsForFile", () => {
it("should extract symbols from Python code", async () => {
const filepath = "test.py"
const contents = `def greet(name):
return f"Hello, {name}!"
class Calculator:
def add(self, a, b):
return a + b
`
const symbols = await getSymbolsForFile(filepath, contents)
// Verify we get symbols
expect(symbols).toBeDefined()
expect(symbols!.length).toBeGreaterThan(0)
// Verify function symbol
const greetSymbol = symbols?.find((s) => s.name === "greet")
expect(greetSymbol).toBeDefined()
expect(greetSymbol?.type).toBe("function_definition")
expect(greetSymbol?.filepath).toBe(filepath)
expect(greetSymbol?.range.start.line).toBe(0)
expect(greetSymbol?.content).toContain("def greet")
// Verify class symbol
const calculatorSymbol = symbols?.find((s) => s.name === "Calculator")
expect(calculatorSymbol).toBeDefined()
expect(calculatorSymbol?.type).toBe("class_definition")
expect(calculatorSymbol?.content).toContain("class Calculator")
})
})
@@ -6,7 +6,6 @@ type Language = Parser.Language
type SyntaxNode = Parser.SyntaxNode
type Query = Parser.Query
type Tree = Parser.Tree
import { SymbolWithRange } from ".."
import { getUriFileExtension } from "./uri"
export enum LanguageName {
@@ -259,87 +258,3 @@ async function loadLanguageForFileExt(fileExtension: string): Promise<Language>
return await Language.load(wasmPath)
}
// See https://tree-sitter.github.io/tree-sitter/using-parsers
const GET_SYMBOLS_FOR_NODE_TYPES: SyntaxNode["type"][] = [
"class_declaration",
"class_definition",
"function_item", // function name = first "identifier" child
"function_definition",
"method_declaration", // method name = first "identifier" child
"method_definition",
"generator_function_declaration",
// property_identifier
// field_declaration
// "arrow_function",
]
export async function getSymbolsForFile(filepath: string, contents: string): Promise<SymbolWithRange[] | undefined> {
//MINIMAL_REPO - continue doesn't use this in autocomplete
const parser = await getParserForFile(filepath)
if (!parser) {
return
}
let tree: Tree | null
try {
tree = parser.parse(contents)
} catch {
console.log(`Error parsing file: ${filepath}`)
return
}
if (!tree) {
console.log(`Failed to parse file: ${filepath}`)
return
}
// console.log(`file: ${filepath}`);
// Function to recursively find all named nodes (classes and functions)
const symbols: SymbolWithRange[] = []
function findNamedNodesRecursive(node: SyntaxNode) {
// console.log(`node: ${node.type}, ${node.text}`);
if (GET_SYMBOLS_FOR_NODE_TYPES.includes(node.type)) {
// console.log(`parent: ${node.type}, ${node.text.substring(0, 200)}`);
// node.children.forEach((child) => {
// console.log(`child: ${child.type}, ${child.text}`);
// });
// Empirically, the actual name is the last identifier in the node
// Especially with languages where return type is declared before the name
// TODO use findLast in newer version of node target
let identifier: SyntaxNode | undefined = undefined
for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i]
if (child && (child.type === "identifier" || child.type === "property_identifier")) {
identifier = child
break
}
}
if (identifier?.text) {
symbols.push({
filepath,
type: node.type,
name: identifier.text,
range: {
start: {
character: node.startPosition.column,
line: node.startPosition.row,
},
end: {
character: node.endPosition.column + 1,
line: node.endPosition.row + 1,
},
},
content: node.text,
})
}
}
node.children.forEach((child) => {
if (child) findNamedNodesRecursive(child)
})
}
findNamedNodesRecursive(tree.rootNode)
return symbols
}
@@ -1,22 +1,6 @@
import { AutocompleteLanguageInfo } from "../../../autocomplete/constants/AutocompleteLanguageInfo"
import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../../autocomplete/types"
import { GetLspDefinitionsFunction } from "../../../autocomplete/types"
import { getAst, getTreePathAtCursor } from "../../../autocomplete/util/ast"
import { intersection } from "../../../util/ranges"
import * as vscode from "vscode"
import type { DocumentSymbol, IDE, Range, RangeInFile, RangeInFileWithContents, SignatureHelp } from "../../../"
import type Parser from "web-tree-sitter"
type SyntaxNode = Parser.SyntaxNode
const FUNCTION_BLOCK_NODE_TYPES = ["block", "statement_block"]
const FUNCTION_DECLARATION_NODE_TYPEs = [
"method_definition",
"function_definition",
"function_item",
"function_declaration",
"method_declaration",
]
import type { DocumentSymbol, RangeInFile, SignatureHelp } from "../../../"
type GotoProviderName =
| "vscode.executeDefinitionProvider"
@@ -120,283 +104,6 @@ export async function executeGotoProvider(input: GotoInput): Promise<RangeInFile
}
}
function isRifWithContents(rif: RangeInFile | RangeInFileWithContents): rif is RangeInFileWithContents {
return typeof (rif as any).contents === "string"
}
function findChildren(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean, firstN?: number): SyntaxNode[] {
let matchingNodes: SyntaxNode[] = []
if (firstN && firstN <= 0) {
return []
}
// Check if the current node's type is in the list of types we're interested in
if (predicate(node)) {
matchingNodes.push(node)
}
// Recursively search for matching types in all children of the current node
for (const child of node.children) {
if (!child) continue
matchingNodes = matchingNodes.concat(
findChildren(child, predicate, firstN ? firstN - matchingNodes.length : undefined),
)
}
return matchingNodes
}
function findTypeIdentifiers(node: SyntaxNode): SyntaxNode[] {
return findChildren(
node,
(childNode) =>
childNode.type === "type_identifier" ||
(["ERROR"].includes(childNode.parent?.type ?? "") &&
childNode.type === "identifier" &&
childNode.text[0].toUpperCase() === childNode.text[0]),
)
}
async function crawlTypes(
rif: RangeInFile | RangeInFileWithContents,
ide: IDE,
depth: number = 1,
results: RangeInFileWithContents[] = [],
searchedLabels: Set<string> = new Set(),
): Promise<RangeInFileWithContents[]> {
// Get the file contents if not already attached
const contents = isRifWithContents(rif) ? rif.contents : await ide.readFile(rif.filepath)
// Parse AST
const ast = await getAst(rif.filepath, contents)
if (!ast) {
return results
}
const astLineCount = ast.rootNode.text.split("\n").length
// Find type identifiers
const identifierNodes = findTypeIdentifiers(ast.rootNode).filter((node) => !searchedLabels.has(node.text))
// Don't search for the same type definition more than once
// We deduplicate below to be sure, but this saves calls to the LSP
identifierNodes.forEach((node) => searchedLabels.add(node.text))
// Use LSP to get the definitions of those types
const definitions = []
for (const node of identifierNodes) {
const [typeDef] = await executeGotoProvider({
uri: vscode.Uri.parse(rif.filepath),
// TODO: tree-sitter is zero-indexed, but there seems to be an off-by-one
// error at least with the .ts parser sometimes
line: rif.range.start.line + Math.min(node.startPosition.row, astLineCount - 1),
character: rif.range.start.character + node.startPosition.column,
name: "vscode.executeDefinitionProvider",
})
if (!typeDef) {
definitions.push(undefined)
continue
}
const contents = await ide.readRangeInFile(typeDef.filepath, typeDef.range)
definitions.push({
...typeDef,
contents,
})
}
// TODO: Filter out if not in our code?
// Filter out duplicates
for (const definition of definitions) {
if (
!definition ||
results.some(
(result) => result.filepath === definition.filepath && intersection(result.range, definition.range) !== null,
)
) {
continue // ;)
}
results.push(definition)
}
// Recurse
if (depth > 0) {
for (const result of [...results]) {
await crawlTypes(result, ide, depth - 1, results, searchedLabels)
}
}
return results
}
async function getDefinitionsForNode(
uri: vscode.Uri,
node: SyntaxNode,
ide: IDE,
lang: AutocompleteLanguageInfo,
): Promise<RangeInFileWithContents[]> {
const ranges: (RangeInFile | RangeInFileWithContents)[] = []
switch (node.type) {
case "call_expression": {
// function call -> function definition
const [funDef] = await executeGotoProvider({
uri,
line: node.startPosition.row,
character: node.startPosition.column,
name: "vscode.executeDefinitionProvider",
})
if (!funDef) {
return []
}
// Don't display a function of more than 15 lines
// We can of course do something smarter here eventually
let funcText = await ide.readRangeInFile(funDef.filepath, funDef.range)
if (funcText.split("\n").length > 15) {
let truncated = false
const funRootAst = await getAst(funDef.filepath, funcText)
if (funRootAst) {
const [funNode] = findChildren(
funRootAst?.rootNode,
(node) => FUNCTION_DECLARATION_NODE_TYPEs.includes(node.type),
1,
)
if (funNode) {
const [statementBlockNode] = findChildren(
funNode,
(node) => FUNCTION_BLOCK_NODE_TYPES.includes(node.type),
1,
)
if (statementBlockNode) {
funcText = funRootAst.rootNode.text.slice(0, statementBlockNode.startIndex).trim()
truncated = true
}
}
}
if (!truncated) {
funcText = funcText.split("\n")[0]
}
}
ranges.push(funDef)
const typeDefs = await crawlTypes(
{
...funDef,
contents: funcText,
},
ide,
)
ranges.push(...typeDefs)
break
}
case "variable_declarator":
// variable assignment -> variable definition/type
// usages of the var that appear after the declaration
break
case "impl_item":
// impl of trait -> trait definition
break
case "new_expression": {
// In 'new MyClass(...)', "MyClass" is the classNameNode
const classNameNode = node.children.find((child) => child && child.type === "identifier")
const [classDef] = await executeGotoProvider({
uri,
line: (classNameNode ?? node).endPosition.row,
character: (classNameNode ?? node).endPosition.column,
name: "vscode.executeDefinitionProvider",
})
if (!classDef) {
break
}
const contents = await ide.readRangeInFile(classDef.filepath, classDef.range)
ranges.push({
...classDef,
contents: `${
classNameNode?.text ? `${lang.singleLineComment} ${classNameNode.text}:\n` : ""
}${contents.trim()}`,
})
const definitions = await crawlTypes({ ...classDef, contents }, ide)
ranges.push(...definitions.filter(Boolean))
break
}
case "":
// function definition -> implementations?
break
}
return await Promise.all(
ranges.map(async (rif) => {
// Convert the VS Code Range type to ours
const range: Range = {
start: {
line: rif.range.start.line,
character: rif.range.start.character,
},
end: {
line: rif.range.end.line,
character: rif.range.end.character,
},
}
rif.range = range
if (!isRifWithContents(rif)) {
return {
...rif,
contents: await ide.readRangeInFile(rif.filepath, rif.range),
}
}
return rif
}),
)
}
/**
* and other stuff not directly on the path:
* - variables defined on line above
* ...etc...
*/
export const getDefinitionsFromLsp: GetLspDefinitionsFunction = async (
filepath: string,
contents: string,
cursorIndex: number,
ide: IDE,
lang: AutocompleteLanguageInfo,
): Promise<AutocompleteCodeSnippet[]> => {
try {
const ast = await getAst(filepath, contents)
if (!ast) {
return []
}
const treePath = await getTreePathAtCursor(ast, cursorIndex)
if (!treePath) {
return []
}
const results: RangeInFileWithContents[] = []
for (const node of treePath.reverse()) {
const definitions = await getDefinitionsForNode(vscode.Uri.parse(filepath), node, ide, lang)
results.push(...definitions)
}
return results.map((result) => ({
filepath: result.filepath,
content: result.contents,
type: AutocompleteSnippetType.Code,
}))
} catch (e) {
console.warn("Error getting definitions from LSP: ", e)
return []
}
}
type SymbolProviderName = "vscode.executeDocumentSymbolProvider"
interface SymbolInput {
@@ -122,10 +122,6 @@ export class NextEditSuggestionManager implements vscode.Disposable {
return this.pending !== null
}
public getPending(): PendingNextEdit | null {
return this.pending
}
public setPending(p: PendingNextEdit): void {
this.clearDecorations()
this.pending = p
@@ -23,7 +23,6 @@ function toPosix(filePath: string): string {
export class FileIgnoreController {
private workspacePath: string
private ignoreInstance: Ignore = ignore()
private loadedContents: Array<{ file: string; content: string }> = []
private readonly realpathCache = new Map<string, string>()
constructor(workspacePath?: string) {
@@ -32,7 +31,6 @@ export class FileIgnoreController {
async initialize(): Promise<void> {
this.ignoreInstance = ignore()
this.loadedContents = []
this.realpathCache.clear()
if (!this.workspacePath) {
@@ -48,7 +46,6 @@ export class FileIgnoreController {
if (kilocodeignoreContent.trim()) {
this.ignoreInstance.add(kilocodeignoreContent)
this.ignoreInstance.add(KILOCODEIGNORE)
this.loadedContents.push({ file: KILOCODEIGNORE, content: kilocodeignoreContent })
return
}
}
@@ -59,7 +56,6 @@ export class FileIgnoreController {
const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8")
if (gitignoreContent.trim()) {
this.ignoreInstance.add(gitignoreContent)
this.loadedContents.push({ file: GITIGNORE, content: gitignoreContent })
}
}
@@ -127,31 +123,7 @@ export class FileIgnoreController {
return !this.ignoreInstance.ignores(relative)
}
/**
* Filter a list of candidate paths to those allowed.
* When no workspace path was provided, returns an empty array.
*/
filterPaths(paths: string[]): string[] {
if (!this.workspacePath) {
return []
}
return paths.filter((candidate) => this.validateAccess(candidate))
}
/**
* Returns user-facing instructions explaining why access is restricted.
*/
getInstructions(): string | undefined {
if (this.loadedContents.length === 0) {
return undefined
}
const sections = this.loadedContents.map(({ file, content }) => `# ${file}\n\n${content.trimEnd()}`)
return sections.join("\n\n")
}
dispose(): void {
this.loadedContents = []
this.realpathCache.clear()
this.ignoreInstance = ignore()
}
@@ -1,13 +1,12 @@
import * as vscode from "vscode"
import type { KiloClient, McpStatus } from "@kilocode/sdk/v2/client"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../cli-backend"
export type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected"
type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected"
export class BrowserAutomationService implements vscode.Disposable {
private state: BrowserAutomationState = "disabled"
private disposables: vscode.Disposable[] = []
private stateListeners: Array<(state: BrowserAutomationState) => void> = []
// MCP server name used when registering with the CLI backend
private static readonly MCP_SERVER_NAME = "kilo-playwright"
@@ -23,22 +22,6 @@ export class BrowserAutomationService implements vscode.Disposable {
)
}
/** Current state */
getState(): BrowserAutomationState {
return this.state
}
/** Subscribe to state changes */
onStateChange(listener: (state: BrowserAutomationState) => void): () => void {
this.stateListeners.push(listener)
return () => {
const idx = this.stateListeners.indexOf(listener)
if (idx >= 0) {
this.stateListeners.splice(idx, 1)
}
}
}
/**
* Read settings and enable/disable accordingly.
* Called on construction and when settings change.
@@ -150,24 +133,6 @@ export class BrowserAutomationService implements vscode.Disposable {
this.setState("disabled")
}
/**
* Get the current MCP server status from the CLI backend.
*/
async getServerStatus(): Promise<McpStatus | null> {
const client = this.getClient()
if (!client) {
return null
}
try {
const directory = this.getWorkspaceDirectory()
const { data: allStatus } = await client.mcp.status({ directory }, { throwOnError: true })
return allStatus[BrowserAutomationService.MCP_SERVER_NAME] ?? null
} catch {
return null
}
}
private getClient(): KiloClient | null {
try {
return this.connectionService.getClient()
@@ -190,9 +155,6 @@ export class BrowserAutomationService implements vscode.Disposable {
}
console.log(`[Kilo New] BrowserAutomationService: State ${this.state}${state}`)
this.state = state
for (const listener of this.stateListeners) {
listener(state)
}
}
dispose(): void {
@@ -200,6 +162,5 @@ export class BrowserAutomationService implements vscode.Disposable {
d.dispose()
}
this.disposables = []
this.stateListeners = []
}
}
@@ -136,10 +136,6 @@ export class MarketplaceApiClient {
}
}
clearCache(): void {
this.cache.clear()
}
dispose(): void {
this.cache.clear()
}
@@ -1,17 +1,5 @@
import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client"
/**
* Returns the number of sessions currently in "busy" state.
* Used to warn users before operations that will interrupt running sessions.
*/
export function getBusySessionCount(map: Map<string, SessionStatus["type"]>): number {
let count = 0
for (const status of map.values()) {
if (status === "busy") count++
}
return count
}
/**
* Fetch all current session statuses and seed the provided map + webview.
* Called on connect so the Settings panel knows about already-running sessions
@@ -149,13 +149,23 @@ export function sanitizeCustomProviderConfig(provider: unknown): { value: Saniti
}
type AnyRecord = Record<string, unknown>
type ProviderPatch = Omit<SanitizedProviderConfig, "models"> & {
models: Record<
string,
null | {
name: string
reasoning?: true | null
variants?: Record<string, VariantConfig | null>
}
>
}
function isRecord(v: unknown): v is AnyRecord {
return !!v && typeof v === "object" && !Array.isArray(v)
}
/**
* Build a provider patch that includes null sentinels for models and variants
* Build a provider patch that includes null sentinels for model properties
* that existed in the previous config but are absent from the new one. The CLI
* `config.update` endpoint deep-merges the payload with the existing config;
* without explicit nulls, removed entries would persist on disk.
@@ -163,7 +173,7 @@ function isRecord(v: unknown): v is AnyRecord {
export function withCustomProviderDeletions(existing: unknown, next: SanitizedProviderConfig): SanitizedProviderConfig {
if (!isRecord(existing)) return next
const oldModels = isRecord(existing.models) ? existing.models : {}
const patched: AnyRecord = { ...next.models }
const patched: ProviderPatch["models"] = { ...next.models }
for (const id of Object.keys(oldModels)) {
if (!(id in patched)) {
@@ -171,15 +181,21 @@ export function withCustomProviderDeletions(existing: unknown, next: SanitizedPr
continue
}
const oldModel = oldModels[id]
const oldVariants = isRecord(oldModel) && isRecord(oldModel.variants) ? oldModel.variants : {}
const newModel = patched[id]
if (!isRecord(newModel)) continue
if (!isRecord(oldModel) || !isRecord(newModel)) continue
const oldVariants = isRecord(oldModel.variants) ? oldModel.variants : {}
const newVariants = isRecord(newModel.variants) ? newModel.variants : {}
const removedVariants = Object.keys(oldVariants).filter((v) => !(v in newVariants))
if (removedVariants.length === 0) continue
const nulls = Object.fromEntries(removedVariants.map((v) => [v, null]))
patched[id] = { ...newModel, variants: { ...newVariants, ...nulls } }
const removed = Object.keys(oldVariants).filter((variant) => !(variant in newVariants))
const variants =
removed.length > 0
? { ...newVariants, ...Object.fromEntries(removed.map((variant) => [variant, null])) }
: newModel.variants
patched[id] = {
...newModel,
...(variants ? { variants } : {}),
...(oldModel.reasoning !== undefined && newModel.reasoning === undefined ? { reasoning: null } : {}),
}
}
return { ...next, models: patched as SanitizedProviderConfig["models"] }
return { ...next, models: patched } as SanitizedProviderConfig
}
@@ -81,7 +81,6 @@ describe("ErrorBackoff", () => {
})
it("is not fatal initially", () => {
expect(backoff.isFatal()).toBe(false)
expect(backoff.getFatalStatus()).toBeNull()
})
@@ -89,21 +88,18 @@ describe("ErrorBackoff", () => {
it("blocks after a 402 error", () => {
backoff.failure(new Error("SSE failed: 402 Payment Required"))
expect(backoff.blocked()).toBe(true)
expect(backoff.isFatal()).toBe(true)
expect(backoff.getFatalStatus()).toBe(402)
})
it("blocks after a 401 error", () => {
backoff.failure(new Error("SSE failed: 401 Unauthorized"))
expect(backoff.blocked()).toBe(true)
expect(backoff.isFatal()).toBe(true)
expect(backoff.getFatalStatus()).toBe(401)
})
it("blocks after a 403 error", () => {
backoff.failure(new Error("SSE failed: 403 Forbidden"))
expect(backoff.blocked()).toBe(true)
expect(backoff.isFatal()).toBe(true)
expect(backoff.getFatalStatus()).toBe(403)
})
@@ -168,7 +164,6 @@ describe("ErrorBackoff", () => {
backoff.reset()
expect(backoff.blocked()).toBe(false)
expect(backoff.isFatal()).toBe(false)
expect(backoff.getFatalStatus()).toBeNull()
})
@@ -178,7 +173,6 @@ describe("ErrorBackoff", () => {
backoff.success()
expect(backoff.blocked()).toBe(false)
expect(backoff.isFatal()).toBe(false)
})
})
@@ -232,7 +226,6 @@ describe("ErrorBackoff", () => {
it("fatal error overrides retriable backoff", () => {
backoff.failure(new Error("SSE failed: 500 Internal Server Error"))
backoff.failure(new Error("SSE failed: 402 Payment Required"))
expect(backoff.isFatal()).toBe(true)
expect(backoff.blocked()).toBe(true)
})
@@ -244,7 +237,6 @@ describe("ErrorBackoff", () => {
backoff.success()
expect(backoff.blocked()).toBe(false)
expect(backoff.isFatal()).toBe(false)
})
})
})
@@ -166,11 +166,12 @@ describe("withCustomProviderDeletions", () => {
expect(models.gone).toBeNull()
})
it("emits null for variants removed from a surviving model", () => {
it("emits null for reasoning and variants removed from a surviving model", () => {
const existing = {
models: {
keep: {
name: "Keep",
reasoning: true,
variants: { high: { reasoningEffort: "high" }, low: { reasoningEffort: "low" } },
},
},
@@ -182,9 +183,11 @@ describe("withCustomProviderDeletions", () => {
},
} as typeof baseNext
const result = withCustomProviderDeletions(existing, next)
const model = (result.models as Record<string, { variants: Record<string, unknown> }>).keep
expect(model.variants.high).toEqual({ reasoningEffort: "high" })
expect(model.variants.low).toBeNull()
const model = (result.models as Record<string, { reasoning?: boolean | null; variants?: Record<string, unknown> }>)
.keep
expect(model.reasoning).toBeNull()
expect(model.variants?.high).toEqual({ reasoningEffort: "high" })
expect(model.variants?.low).toBeNull()
})
it("does not touch variants on a model that is being deleted", () => {
@@ -40,7 +40,6 @@ describe("FileIgnoreController", () => {
expect(controller.validateAccess("secret/keys.txt")).toBe(false)
expect(controller.validateAccess(path.join(workspace, "a.snap"))).toBe(false)
expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true)
expect(controller.getInstructions()).toContain(".kilocodeignore")
})
it("does NOT block .env files unless explicitly listed", async () => {
@@ -81,7 +80,6 @@ describe("FileIgnoreController", () => {
expect(controller.validateAccess(path.join(workspace, "node_modules", "foo.js"))).toBe(false)
expect(controller.validateAccess(path.join(workspace, "build", "output.js"))).toBe(false)
expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true)
expect(controller.getInstructions()).toContain(".gitignore")
})
it("blocks .env files via hardcoded sensitive patterns", async () => {
@@ -167,12 +165,5 @@ describe("FileIgnoreController", () => {
expect(controller.validateAccess("/some/file.ts")).toBe(false)
expect(controller.validateAccess("relative/file.ts")).toBe(false)
})
it("filterPaths returns empty array", async () => {
const controller = new FileIgnoreController("")
await controller.initialize()
expect(controller.filterPaths(["/some/file.ts", "other.ts"])).toEqual([])
})
})
})
@@ -158,6 +158,12 @@ describe("sessionToWebview", () => {
expect(() => new Date(result.createdAt)).not.toThrow()
expect(new Date(result.createdAt).getTime()).toBe(1700000000000)
})
it("clears optional state omitted from a full session snapshot", () => {
const result = sessionToWebview(makeSession())
expect(result.revert).toBeNull()
expect(result.summary).toBeNull()
})
})
describe("applySessionPatch", () => {
@@ -1,9 +1,11 @@
import { describe, test, expect } from "bun:test"
import {
describePatterns,
describeRule,
resolveLabel,
TOOL_LABEL_KEYS,
} from "../../webview-ui/src/components/chat/permission-dock-utils"
import { resolveTemplate } from "../../webview-ui/src/context/language-utils"
// Mock t() that returns the English label for known keys, or the key itself
const labels: Record<string, string> = {
@@ -24,8 +26,10 @@ const labels: Record<string, string> = {
"ui.permission.toolLabel.task": "Task",
"ui.permission.toolLabel.skill": "Skill",
"ui.permission.toolLabel.lsp": "LSP",
"ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?",
"ui.permission.doomLoop.rule": "Continue {{tool}} calls",
}
const t = (key: string) => labels[key] ?? key
const t = (key: string, params?: Record<string, string>) => resolveTemplate(labels[key] ?? key, params)
describe("describePatterns", () => {
test("returns null when patterns is empty", () => {
@@ -95,6 +99,14 @@ describe("describePatterns", () => {
expect(result).toEqual({ kind: "single", text: "Web Search query" })
})
test("doom loop describes the repeated tool with its human-readable label", () => {
const result = describePatterns("doom_loop", ["read"], t)
expect(result).toEqual({
kind: "single",
text: "Potential loop detected for the Read tool. Continue running?",
})
})
test("TOOL_LABEL_KEYS maps all expected tools", () => {
const expected: Record<string, string> = {
read: "ui.permission.toolLabel.read",
@@ -162,6 +174,17 @@ describe("describePatterns", () => {
})
})
describe("describeRule", () => {
test("describes doom loop rules as the action being allowed or denied", () => {
expect(describeRule("doom_loop", "read", t)).toBe("Continue Read calls")
})
test("preserves existing permission rule labels", () => {
expect(describeRule("read", "*", t)).toBe("Read")
expect(describeRule("read", "src/app.ts", t)).toBe("Read src/app.ts")
})
})
describe("resolveLabel", () => {
test("returns translated label for known tool", () => {
expect(resolveLabel("read", t)).toBe("Read")
@@ -1,5 +1,11 @@
import { describe, expect, it } from "bun:test"
import { connectProvider, disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions"
import {
connectProvider,
disconnectProvider,
fetchProviderData,
resolveStoredKey,
saveCustomProvider,
} from "../../src/provider-actions"
type ExistingGlobal = { disabled_providers?: string[]; provider?: Record<string, unknown> }
@@ -237,7 +243,7 @@ describe("saveCustomProvider", () => {
expect(payload.myprovider.models["model-gone"]).toBeNull()
})
it("emits null sentinels for variants removed from a model that still exists", async () => {
it("emits null sentinels when reasoning and variants are removed from a model", async () => {
const existing = {
disabled_providers: [],
provider: {
@@ -264,11 +270,7 @@ describe("saveCustomProvider", () => {
name: "My Provider",
options: { baseURL: "https://example.com/v1" },
models: {
"model-1": {
name: "Model One",
reasoning: true,
variants: { high: { reasoningEffort: "high" } },
},
"model-1": { name: "Model One" },
},
}
await saveCustomProvider(ctx, "req", "myprovider", next, undefined, false, null, setCachedConfig)
@@ -277,11 +279,11 @@ describe("saveCustomProvider", () => {
const model = (
calls.config[0].config.provider as Record<
string,
{ models: Record<string, { variants?: Record<string, unknown> }> }
{ models: Record<string, { reasoning?: boolean | null; variants?: Record<string, unknown> }> }
>
).myprovider.models["model-1"]
expect(model.variants).toBeDefined()
expect(model.variants?.high).toBeDefined()
expect(model.reasoning).toBeNull()
expect(model.variants?.high).toBeNull()
expect(model.variants?.low).toBeNull()
})
@@ -441,4 +443,69 @@ describe("fetchProviderData", () => {
expect(result.authStates).toEqual({ "groq-test": "api" })
expect("key" in item).toBe(false)
})
it("retains stripped keys for providers with a configured baseURL", async () => {
const client = {
provider: {
list: async () => ({
data: {
all: [
{
id: "myprovider",
name: "My Provider",
source: "config",
key: "sk-stored",
env: [],
options: { baseURL: "https://example.com/v1" },
models: {},
},
{
id: "no-url",
name: "No URL",
source: "config",
key: "sk-other",
env: [],
models: {},
},
],
connected: [],
default: {},
},
}),
auth: async () => ({ data: {} }),
},
} as unknown as Parameters<typeof fetchProviderData>[0]
const result = await fetchProviderData(client, "/tmp")
expect(result.storedKeys).toEqual({
myprovider: { key: "sk-stored", baseURL: "https://example.com/v1" },
})
expect(result.response.all.every((item) => !("key" in (item as Record<string, unknown>)))).toBe(true)
})
})
describe("resolveStoredKey", () => {
const storedKeys = {
myprovider: { key: "sk-stored", baseURL: "https://example.com/v1" },
}
it("returns the stored key when the fetch URL matches the configured baseURL", () => {
expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v1")).toBe("sk-stored")
})
it("tolerates trailing-slash differences", () => {
expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v1/")).toBe("sk-stored")
})
it("refuses to apply the stored key to a different host or path", () => {
expect(resolveStoredKey(storedKeys, "myprovider", "https://evil.example.net/v1")).toBeUndefined()
expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v2")).toBeUndefined()
})
it("returns undefined for unknown or missing provider ids", () => {
expect(resolveStoredKey(storedKeys, "other", "https://example.com/v1")).toBeUndefined()
expect(resolveStoredKey(storedKeys, undefined, "https://example.com/v1")).toBeUndefined()
expect(resolveStoredKey(storedKeys, "", "https://example.com/v1")).toBeUndefined()
})
})
@@ -4,8 +4,18 @@ import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx")
const PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
const src = fs.readFileSync(TURN_FILE, "utf-8")
const provider = fs.readFileSync(PROVIDER_FILE, "utf-8")
function method(name: string, next: string) {
const start = provider.indexOf(` private async ${name}`)
const end = provider.indexOf(` private async ${next}`, start)
expect(start).toBeGreaterThan(-1)
expect(end).toBeGreaterThan(start)
return provider.slice(start, end)
}
describe("message revert checkpoints", () => {
it("keeps revert actions available after a session is already reverted", () => {
@@ -18,3 +28,28 @@ describe("message revert checkpoints", () => {
expect(src).not.toMatch(/data-revert-disabled=\{[\s\S]*?!session\.revert\(\)/)
})
})
describe("revert session synchronization", () => {
it("keeps REST responses as the mutation result", () => {
const revert = method("handleRevertSession", "handleUnrevertSession")
const unrevert = method("handleUnrevertSession", "handleCompact")
expect(revert).toContain("await this.client.session.revert")
expect(unrevert).toContain("await this.client.session.unrevert")
expect(revert).toContain('type: "sessionUpdated"')
expect(unrevert).toContain('type: "sessionUpdated"')
})
it("distinguishes partial sync patches from full bus snapshots", () => {
expect(provider).toMatch(/source: "sync"/)
expect(provider).toMatch(
/if \(event\.type === "session\.updated"\) return "source" in event && event\.source === "sync"/,
)
expect(provider).toMatch(
/isLegacySyncEvent\(event\)\s*\? applySessionPatch\(this\.currentSession, event\.properties\.info\)\s*:\s*event\.properties\.info/,
)
expect(provider).toMatch(
/isFullSessionUpdatedEvent\(event\)\s*\? \{ type: "sessionUpdated" as const, session: this\.sessionToWebview\(event\.properties\.info\) \}/,
)
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
import { seedSessionStatuses, getBusySessionCount } from "../../src/session-status"
import { seedSessionStatuses } from "../../src/session-status"
import type { SessionStatus } from "@kilocode/sdk/v2/client"
/**
@@ -196,23 +196,3 @@ describe("seedSessionStatuses", () => {
expect(msgs).toEqual([{ type: "sessionStatus", sessionID: "confirmed", status: "busy" }])
})
})
// ---------------------------------------------------------------------------
// getBusySessionCount
// ---------------------------------------------------------------------------
describe("getBusySessionCount", () => {
it("returns 0 for empty map", () => {
expect(getBusySessionCount(new Map())).toBe(0)
})
it("counts only busy entries, not idle or retry", () => {
const map = new Map<string, SessionStatus["type"]>([
["a", "busy"],
["b", "idle"],
["c", "retry"],
["d", "busy"],
])
expect(getBusySessionCount(map)).toBe(2)
})
})
@@ -17,7 +17,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { useConfig } from "../../context/config"
import { describePatterns, resolveLabel, savedRuleStates, type RuleDecision } from "./permission-dock-utils"
import { describePatterns, describeRule, savedRuleStates, type RuleDecision } from "./permission-dock-utils"
import { PermissionCommand } from "./PermissionCommand"
import { PermissionDiff } from "./PermissionDiff"
import { permissionDiffs } from "./permission-diff-utils"
@@ -249,11 +249,7 @@ export const PermissionDock: Component<{
</Tooltip>
</div>
<code data-slot="permission-rule">
{command()
? label(rule)
: rule === "*"
? resolveLabel(props.request.toolName, language.t)
: `${resolveLabel(props.request.toolName, language.t)} ${rule}`}
{command() ? label(rule) : describeRule(props.request.toolName, rule, language.t)}
</code>
</div>
)}
@@ -52,6 +52,17 @@ export function resolveLabel(tool: string, t: (key: string) => string): string {
return key ? t(key) : tool
}
export function describeRule(
tool: string,
rule: string,
t: (key: string, params?: Record<string, string>) => string,
): string {
if (tool === "doom_loop") {
return t("ui.permission.doomLoop.rule", { tool: resolveLabel(rule, t) })
}
return rule === "*" ? resolveLabel(tool, t) : `${resolveLabel(tool, t)} ${rule}`
}
/**
* Build a human-readable description for a permission request's patterns.
*
@@ -62,13 +73,20 @@ export function resolveLabel(tool: string, t: (key: string) => string): string {
export function describePatterns(
tool: string,
patterns: string[],
t: (key: string) => string,
t: (key: string, params?: Record<string, string>) => string,
): PatternDescription | null {
const filtered = patterns.filter((p) => p !== "*")
if (filtered.length === 0) return null
const key = TOOL_LABEL_KEYS[tool]
const label = key ? t(key) : tool
// doom-loop requests always contain one repeated tool pattern.
if (tool === "doom_loop") {
return {
kind: "single",
text: t("ui.permission.doomLoop.prompt", { tool: resolveLabel(filtered[0], t) }),
}
}
const label = resolveLabel(tool, t)
if (filtered.length === 1) return { kind: "single", text: `${label} ${filtered[0]}` }
return { kind: "multi", title: `${label}:`, paths: filtered }
}
@@ -43,6 +43,8 @@ interface SelectOption {
import SettingsRow from "./SettingsRow"
const builtin = (skill: SkillInfo) => skill.location === "builtin" || skill.location === "<built-in>"
// View states for the agents subtab
type AgentView = "list" | "create" | "edit"
@@ -848,10 +850,10 @@ const AgentBehaviourTab: Component = () => {
}}
>
<div>{skill.description}</div>
{skill.location !== "builtin" && <div>{skill.location}</div>}
{!builtin(skill) && <div>{skill.location}</div>}
</div>
</div>
{skill.location !== "builtin" && (
{!builtin(skill) && (
<IconButton size="small" variant="ghost" icon="close" onClick={() => confirmRemoveSkill(skill)} />
)}
</div>
@@ -195,6 +195,12 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
const raw = fetchKey().trim()
const env = raw.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const apiKey = raw && !env ? raw : undefined
// When editing an existing provider with the key field untouched, the
// webview has no key to send — keys are stripped before provider data
// reaches it. Send the providerID so the extension can authenticate the
// fetch with the stored key (#10139). Anything typed into the field
// (a key or {env:VAR} syntax) takes precedence.
const providerID = !raw && props.existing ? props.existing.providerID : undefined
const existing = new Set(form.models.map((m) => m.id.trim()).filter(Boolean))
const hdrs = form.headers
@@ -253,6 +259,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
requestId: rid,
baseURL: url,
apiKey,
providerID,
headers,
})
}
+2
View File
@@ -522,6 +522,8 @@ export const dict = {
"notification.permission.titleSubagent": "مطلوب إذن (وكيل فرعي)",
"notification.permission.description": "{{sessionTitle}} في {{projectName}} يحتاج إلى إذن",
"ui.permission.manageAutoApprove": "إدارة قواعد الموافقة التلقائية",
"ui.permission.doomLoop.prompt": "تم اكتشاف حلقة محتملة في أداة {{tool}}. هل تريد متابعة التشغيل؟",
"ui.permission.doomLoop.rule": "متابعة استدعاءات {{tool}}",
"ui.permission.rule.addToAllowed": "أضف إلى قائمة المسموح",
"ui.permission.rule.removeFromAllowed": "أزل من قائمة المسموح",
"ui.permission.rule.addToDenied": "أضف إلى قائمة المرفوض",
+2
View File
@@ -527,6 +527,8 @@ export const dict = {
"notification.permission.titleSubagent": "Permissão necessária (subagente)",
"notification.permission.description": "{{sessionTitle}} em {{projectName}} precisa de permissão",
"ui.permission.manageAutoApprove": "Gerenciar regras de aprovação automática",
"ui.permission.doomLoop.prompt": "Possível loop detectado na ferramenta {{tool}}. Continuar executando?",
"ui.permission.doomLoop.rule": "Continuar chamadas de {{tool}}",
"ui.permission.rule.addToAllowed": "Adicionar à lista de permitidos",
"ui.permission.rule.removeFromAllowed": "Remover da lista de permitidos",
"ui.permission.rule.addToDenied": "Adicionar à lista de negados",
+2
View File
@@ -530,6 +530,8 @@ export const dict = {
"notification.permission.titleSubagent": "Potrebna dozvola (podagent)",
"notification.permission.description": "{{sessionTitle}} u {{projectName}} traži dozvolu",
"ui.permission.manageAutoApprove": "Upravljanje pravilima automatskog odobravanja",
"ui.permission.doomLoop.prompt": "Otkrivena je moguća petlja za alat {{tool}}. Nastaviti izvršavanje?",
"ui.permission.doomLoop.rule": "Nastavi pozive alata {{tool}}",
"ui.permission.rule.addToAllowed": "Dodaj na listu dozvoljenih",
"ui.permission.rule.removeFromAllowed": "Ukloni sa liste dozvoljenih",
"ui.permission.rule.addToDenied": "Dodaj na listu odbijenih",
+2
View File
@@ -527,6 +527,8 @@ export const dict = {
"notification.permission.titleSubagent": "Tilladelse påkrævet (underagent)",
"notification.permission.description": "{{sessionTitle}} i {{projectName}} kræver tilladelse",
"ui.permission.manageAutoApprove": "Administrer regler for automatisk godkendelse",
"ui.permission.doomLoop.prompt": "Der blev registreret en mulig løkke for værktøjet {{tool}}. Fortsæt kørslen?",
"ui.permission.doomLoop.rule": "Fortsæt {{tool}}-kald",
"ui.permission.rule.addToAllowed": "Tilføj til tilladelseslisten",
"ui.permission.rule.removeFromAllowed": "Fjern fra tilladelseslisten",
"ui.permission.rule.addToDenied": "Tilføj til afvisningslisten",
+2
View File
@@ -534,6 +534,8 @@ export const dict = {
"notification.permission.titleSubagent": "Berechtigung erforderlich (Subagent)",
"notification.permission.description": "{{sessionTitle}} in {{projectName}} benötigt Berechtigung",
"ui.permission.manageAutoApprove": "Regeln für automatische Genehmigung verwalten",
"ui.permission.doomLoop.prompt": "Potenzielle Schleife beim Tool {{tool}} erkannt. Weiter ausführen?",
"ui.permission.doomLoop.rule": "{{tool}}-Aufrufe fortsetzen",
"ui.permission.rule.addToAllowed": "Zur Erlaubt-Liste hinzufügen",
"ui.permission.rule.removeFromAllowed": "Aus der Erlaubt-Liste entfernen",
"ui.permission.rule.addToDenied": "Zur Verweigert-Liste hinzufügen",
@@ -527,6 +527,8 @@ export const dict = {
"notification.permission.titleSubagent": "Permission required (subagent)",
"notification.permission.description": "{{sessionTitle}} in {{projectName}} needs permission",
"ui.permission.manageAutoApprove": "Manage Auto-Approve Rules",
"ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?",
"ui.permission.doomLoop.rule": "Continue {{tool}} calls",
"ui.permission.rule.addToAllowed": "Add to allowed list",
"ui.permission.rule.removeFromAllowed": "Remove from allowed list",
"ui.permission.rule.addToDenied": "Add to denied list",
+2
View File
@@ -531,6 +531,8 @@ export const dict = {
"notification.permission.titleSubagent": "Permiso requerido (subagente)",
"notification.permission.description": "{{sessionTitle}} en {{projectName}} necesita permiso",
"ui.permission.manageAutoApprove": "Gestionar reglas de aprobación automática",
"ui.permission.doomLoop.prompt": "Se detectó un posible bucle en la herramienta {{tool}}. ¿Continuar ejecutando?",
"ui.permission.doomLoop.rule": "Continuar llamadas a {{tool}}",
"ui.permission.rule.addToAllowed": "Añadir a la lista de permitidos",
"ui.permission.rule.removeFromAllowed": "Quitar de la lista de permitidos",
"ui.permission.rule.addToDenied": "Añadir a la lista de denegados",
+2
View File
@@ -536,6 +536,8 @@ export const dict = {
"notification.permission.titleSubagent": "Permission requise (sous-agent)",
"notification.permission.description": "{{sessionTitle}} dans {{projectName}} a besoin d'une permission",
"ui.permission.manageAutoApprove": "Gérer les règles d'approbation automatique",
"ui.permission.doomLoop.prompt": "Boucle potentielle détectée pour loutil {{tool}}. Continuer lexécution ?",
"ui.permission.doomLoop.rule": "Continuer les appels à {{tool}}",
"ui.permission.rule.addToAllowed": "Ajouter à la liste des autorisés",
"ui.permission.rule.removeFromAllowed": "Retirer de la liste des autorisés",
"ui.permission.rule.addToDenied": "Ajouter à la liste des refusés",
+2
View File
@@ -448,6 +448,8 @@ export const dict = {
"notification.permission.titleSubagent": "Autorizzazione richiesta (sub-agent)",
"notification.permission.description": "{{sessionTitle}} in {{projectName}} richiede autorizzazione",
"ui.permission.manageAutoApprove": "Gestisci regole approvazione automatica",
"ui.permission.doomLoop.prompt": "Rilevato un potenziale ciclo nello strumento {{tool}}. Continuare l'esecuzione?",
"ui.permission.doomLoop.rule": "Continua le chiamate a {{tool}}",
"ui.permission.rule.addToAllowed": "Aggiungi alla lista consentiti",
"ui.permission.rule.removeFromAllowed": "Rimuovi dalla lista consentiti",
"ui.permission.rule.addToDenied": "Aggiungi alla lista negati",
+2
View File
@@ -524,6 +524,8 @@ export const dict = {
"notification.permission.titleSubagent": "権限が必要です(サブエージェント)",
"notification.permission.description": "{{projectName}} の {{sessionTitle}} が権限を必要としています",
"ui.permission.manageAutoApprove": "自動承認ルールを管理",
"ui.permission.doomLoop.prompt": "{{tool}} ツールでループの可能性が検出されました。実行を続行しますか?",
"ui.permission.doomLoop.rule": "{{tool}} の呼び出しを続行",
"ui.permission.rule.addToAllowed": "許可リストに追加",
"ui.permission.rule.removeFromAllowed": "許可リストから削除",
"ui.permission.rule.addToDenied": "拒否リストに追加",
+2
View File
@@ -525,6 +525,8 @@ export const dict = {
"notification.permission.titleSubagent": "권한 필요 (서브에이전트)",
"notification.permission.description": "{{projectName}}의 {{sessionTitle}}에서 권한이 필요합니다",
"ui.permission.manageAutoApprove": "자동 승인 규칙 관리",
"ui.permission.doomLoop.prompt": "{{tool}} 도구에서 잠재적인 반복 실행이 감지되었습니다. 계속 실행하시겠습니까?",
"ui.permission.doomLoop.rule": "{{tool}} 호출 계속",
"ui.permission.rule.addToAllowed": "허용 목록에 추가",
"ui.permission.rule.removeFromAllowed": "허용 목록에서 제거",
"ui.permission.rule.addToDenied": "거부 목록에 추가",
+2
View File
@@ -530,6 +530,8 @@ export const dict = {
"notification.permission.titleSubagent": "Toestemming vereist (subagent)",
"notification.permission.description": "{{sessionTitle}} in {{projectName}} heeft toestemming nodig",
"ui.permission.manageAutoApprove": "Beheer automatisch goedkeuren regels",
"ui.permission.doomLoop.prompt": "Mogelijke lus gedetecteerd voor het hulpmiddel {{tool}}. Doorgaan met uitvoeren?",
"ui.permission.doomLoop.rule": "Doorgaan met {{tool}}-aanroepen",
"ui.permission.rule.addToAllowed": "Toevoegen aan toegestane lijst",
"ui.permission.rule.removeFromAllowed": "Verwijderen van toegestane lijst",
"ui.permission.rule.addToDenied": "Toevoegen aan geweigerde lijst",
+2
View File
@@ -531,6 +531,8 @@ export const dict = {
"notification.permission.titleSubagent": "Tillatelse påkrevd (underagent)",
"notification.permission.description": "{{sessionTitle}} i {{projectName}} trenger tillatelse",
"ui.permission.manageAutoApprove": "Administrer regler for automatisk godkjenning",
"ui.permission.doomLoop.prompt": "Mulig løkke oppdaget for verktøyet {{tool}}. Fortsette kjøringen?",
"ui.permission.doomLoop.rule": "Fortsett {{tool}}-kall",
"ui.permission.rule.addToAllowed": "Legg til i tillatt-listen",
"ui.permission.rule.removeFromAllowed": "Fjern fra tillatt-listen",
"ui.permission.rule.addToDenied": "Legg til i blokkert-listen",
+2
View File
@@ -529,6 +529,8 @@ export const dict = {
"notification.permission.titleSubagent": "Wymagane uprawnienie (podagent)",
"notification.permission.description": "{{sessionTitle}} w {{projectName}} potrzebuje uprawnienia",
"ui.permission.manageAutoApprove": "Zarządzaj regułami automatycznego zatwierdzania",
"ui.permission.doomLoop.prompt": "Wykryto potencjalną pętlę dla narzędzia {{tool}}. Kontynuować działanie?",
"ui.permission.doomLoop.rule": "Kontynuuj wywołania {{tool}}",
"ui.permission.rule.addToAllowed": "Dodaj do listy dozwolonych",
"ui.permission.rule.removeFromAllowed": "Usuń z listy dozwolonych",
"ui.permission.rule.addToDenied": "Dodaj do listy zablokowanych",
+3
View File
@@ -529,6 +529,9 @@ export const dict = {
"notification.permission.titleSubagent": "Требуется разрешение (субагент)",
"notification.permission.description": "{{sessionTitle}} в {{projectName}} требуется разрешение",
"ui.permission.manageAutoApprove": "Управление правилами автоодобрения",
"ui.permission.doomLoop.prompt":
"Обнаружен потенциальный цикл при работе инструмента {{tool}}. Продолжить выполнение?",
"ui.permission.doomLoop.rule": "Продолжать вызовы {{tool}}",
"ui.permission.rule.addToAllowed": "Добавить в список разрешённых",
"ui.permission.rule.removeFromAllowed": "Убрать из списка разрешённых",
"ui.permission.rule.addToDenied": "Добавить в список запрещённых",
+2
View File
@@ -524,6 +524,8 @@ export const dict = {
"notification.permission.titleSubagent": "ต้องการสิทธิ์ (ตัวแทนย่อย)",
"notification.permission.description": "{{sessionTitle}} ใน {{projectName}} ต้องการสิทธิ์",
"ui.permission.manageAutoApprove": "จัดการกฎการอนุมัติอัตโนมัติ",
"ui.permission.doomLoop.prompt": "ตรวจพบการวนซ้ำที่อาจเกิดขึ้นในเครื่องมือ {{tool}} ต้องการดำเนินการต่อหรือไม่",
"ui.permission.doomLoop.rule": "เรียกใช้ {{tool}} ต่อไป",
"ui.permission.rule.addToAllowed": "เพิ่มไปยังรายการที่อนุญาต",
"ui.permission.rule.removeFromAllowed": "นำออกจากรายการที่อนุญาต",
"ui.permission.rule.addToDenied": "เพิ่มไปยังรายการที่ถูกปฏิเสธ",
+2
View File
@@ -529,6 +529,8 @@ export const dict = {
"notification.permission.titleSubagent": "İzin gerekli (alt ajan)",
"notification.permission.description": "{{projectName}} içindeki {{sessionTitle}} izin gerektiriyor",
"ui.permission.manageAutoApprove": "Otomatik Onay Kurallarını Yönet",
"ui.permission.doomLoop.prompt": "{{tool}} aracında olası bir döngü algılandı. Çalıştırmaya devam edilsin mi?",
"ui.permission.doomLoop.rule": "{{tool}} çağrılarına devam et",
"ui.permission.rule.addToAllowed": "İzin listesine ekle",
"ui.permission.rule.removeFromAllowed": "İzin listesinden kaldır",
"ui.permission.rule.addToDenied": "Engel listesine ekle",
+3
View File
@@ -531,6 +531,9 @@ export const dict = {
"notification.permission.titleSubagent": "Потрібен дозвіл (підагент)",
"notification.permission.description": "{{sessionTitle}} у {{projectName}} потребує дозволу",
"ui.permission.manageAutoApprove": "Керувати правилами автоматичного схвалення",
"ui.permission.doomLoop.prompt":
"Виявлено потенційний цикл під час роботи інструмента {{tool}}. Продовжити виконання?",
"ui.permission.doomLoop.rule": "Продовжувати виклики {{tool}}",
"ui.permission.rule.addToAllowed": "Додати до дозволених",
"ui.permission.rule.removeFromAllowed": "Видалити з дозволених",
"ui.permission.rule.addToDenied": "Додати до заблокованих",
+2
View File
@@ -518,6 +518,8 @@ export const dict = {
"notification.permission.titleSubagent": "需要权限(子代理)",
"notification.permission.description": "{{sessionTitle}}{{projectName}})需要权限",
"ui.permission.manageAutoApprove": "管理自动审批规则",
"ui.permission.doomLoop.prompt": "检测到 {{tool}} 工具可能陷入循环。是否继续运行?",
"ui.permission.doomLoop.rule": "继续调用 {{tool}}",
"ui.permission.rule.addToAllowed": "添加到允许列表",
"ui.permission.rule.removeFromAllowed": "从允许列表移除",
"ui.permission.rule.addToDenied": "添加到拒绝列表",
+2
View File
@@ -518,6 +518,8 @@ export const dict = {
"notification.permission.titleSubagent": "需要權限(子代理)",
"notification.permission.description": "{{sessionTitle}}{{projectName}})需要權限",
"ui.permission.manageAutoApprove": "管理自動核准規則",
"ui.permission.doomLoop.prompt": "偵測到 {{tool}} 工具可能陷入迴圈。是否繼續執行?",
"ui.permission.doomLoop.rule": "繼續呼叫 {{tool}}",
"ui.permission.rule.addToAllowed": "加入允許清單",
"ui.permission.rule.removeFromAllowed": "從允許清單移除",
"ui.permission.rule.addToDenied": "加入拒絕清單",
@@ -971,6 +971,13 @@ export interface FetchCustomProviderModelsMessage {
requestId: string
baseURL: string
apiKey?: string
/**
* When editing an existing provider and the key field is untouched, the
* webview has no key to send (keys are stripped before they reach it).
* It sends the providerID instead so the extension can authenticate the
* fetch with the stored key which never crosses into the webview.
*/
providerID?: string
headers?: Record<string, string>
}
+2 -1
View File
@@ -285,7 +285,7 @@ for (const item of targets) {
const sessionExportWorkerPath = "./src/kilocode/session-export/worker.ts" // kilocode_change
const indexingWorkerPath = "./src/kilocode/indexing-worker.ts" // kilocode_change
// Use platform-specific bunfs root path based on target OS // kilocode_change
// Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
@@ -382,6 +382,7 @@ for (const item of targets) {
{
name,
version: Script.version,
preferUnplugged: true,
os: [item.os],
cpu: [item.arch],
keywords: pkg.keywords, // kilocode_change

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