mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 04:46:43 +08:00
Merge branch 'main' into feat/minimax-m3-thinking-toggle
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow reasoning to be removed from custom provider models after it has been enabled.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Clarify potential loop permission prompts and auto-approval rules with localized tool names.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore reverted sessions on the first Redo click.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restore Kilo branding, fork-specific CLI commands, and CLI lifecycle initialization after upstream merges.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent skill removal from recursively deleting working directories.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v1.14.51
|
||||
v1.15.4
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
}) {}
|
||||
@@ -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") {}
|
||||
@@ -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",
|
||||
}) {}
|
||||
@@ -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"
|
||||
+12
-12
@@ -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,
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
-20
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
-4
@@ -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.
|
||||
*/
|
||||
|
||||
+3
-23
@@ -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: [],
|
||||
})
|
||||
|
||||
|
||||
-2
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-1
@@ -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 {
|
||||
/**
|
||||
|
||||
-125
@@ -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])
|
||||
}
|
||||
})
|
||||
})
|
||||
+3
-127
@@ -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
|
||||
}
|
||||
|
||||
-25
@@ -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.
|
||||
|
||||
+77
-284
@@ -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
-47
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-179
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
-219
@@ -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
|
||||
}
|
||||
}
|
||||
+76
-415
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+33
-161
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+3
-15
@@ -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 [
|
||||
|
||||
+2
-7
@@ -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)
|
||||
|
||||
|
||||
-8
@@ -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,
|
||||
|
||||
+2
-20
@@ -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
|
||||
}
|
||||
|
||||
|
||||
-23
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
-36
@@ -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
-294
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 l’outil {{tool}}. Continuer l’exé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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user