Merge branch 'main' into jetbrains/release/v7.0.1-rc.4

This commit is contained in:
Kirill Kalishev
2026-05-29 12:26:44 -04:00
committed by GitHub
70 changed files with 1531 additions and 461 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Windows speech input device detection when FFmpeg lists DirectShow microphones by section.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Use Kilo session share links when sharing conversations from the CLI.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Support reading text from DOCX files through the read tool.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Support reading XLSX spreadsheets as labelled tabular text
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/kilo-ui": patch
---
Reduce lag and gray placeholders in the diff and Changes views by enabling worker-backed highlighting and rendering patch-backed review hunks without rebuilding full source files.
+25 -2
View File
@@ -7,10 +7,33 @@
</p>
<p align="center">
<img width="250" alt="kilo-code-logo" src="https://github.com/user-attachments/assets/bdb0c174-b9fd-40ad-a47b-f3aab9b54e8d" />
<a href="https://kilo.ai"><img width="250" alt="kilo-code-logo" src="https://github.com/user-attachments/assets/bdb0c174-b9fd-40ad-a47b-f3aab9b54e8d" /></a>
</p>
> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
<p align="center">
<strong>Kilo is the all-in-one agentic engineering platform.</strong><br>
Build, ship, and iterate faster with the most popular open source coding agent.
</p>
<p align="center">
<img width="100%" alt="Kilo Code running inside VS Code" src="https://kilo.ai/_next/image?url=%2Fscreenshots%2Fvs-code%2Fvs-code-home-page-screenshot.png&w=3840&q=75">
</p>
<p align="center">
<a href="https://kilo.ai">Website</a> ·
<a href="https://kilo.ai/install">Install</a> ·
<a href="https://kilo.ai/landing/vs-code">IDE</a> ·
<a href="https://kilo.ai/cli">CLI</a> ·
<a href="https://kilo.ai/docs">Docs</a> ·
<a href="https://kilo.ai/leaderboard">Models</a> ·
<a href="https://kilo.ai/gateway">Gateway</a> ·
<a href="https://kilo.ai/pricing">Pricing</a> ·
<a href="https://kilo.ai/pricing/kilo-pass">Kilo Pass</a>
</p>
<p align="center">
500+ models. One open source agent in <a href="https://kilo.ai/install">VS Code</a>, <a href="https://kilo.ai/features/jetbrains-native">JetBrains</a>, <a href="https://kilo.ai/cli">CLI</a>, <a href="https://kilo.ai/slack">Slack</a>, and <a href="https://kilo.ai/cloud">Cloud</a>.
</p>
- ✨ Generate code from natural language
- ✅ Checks its own work
+34 -15
View File
@@ -387,6 +387,7 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mammoth": "1.12.0",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
@@ -412,6 +413,7 @@
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5",
@@ -588,6 +590,7 @@
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.46",
@@ -2258,6 +2261,8 @@
"@webcontainer/env": ["@webcontainer/env@1.1.1", "", {}, "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
"@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.2.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
@@ -2320,7 +2325,7 @@
"arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
@@ -2412,6 +2417,8 @@
"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"blueimp-md5": ["blueimp-md5@2.19.0", "", {}, "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
@@ -2742,6 +2749,8 @@
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="],
@@ -2764,6 +2773,8 @@
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -3368,6 +3379,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
@@ -3386,6 +3399,8 @@
"make-fetch-happen": ["make-fetch-happen@15.0.5", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg=="],
"mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
"map-age-cleaner": ["map-age-cleaner@0.1.3", "", { "dependencies": { "p-defer": "^1.0.0" } }, "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w=="],
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
@@ -3602,6 +3617,8 @@
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
@@ -3666,6 +3683,8 @@
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@@ -4018,7 +4037,7 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
@@ -4354,9 +4373,11 @@
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }, "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -4814,6 +4835,8 @@
"istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
@@ -4842,6 +4865,8 @@
"log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
"markdown-it/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"matcher/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
@@ -4992,6 +5017,8 @@
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"tedious/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
@@ -5018,6 +5045,8 @@
"xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"yargs-unparser/decamelize": ["decamelize@4.0.0", "", {}, "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ=="],
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
@@ -5228,6 +5257,8 @@
"aws-sdk/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"aws-sdk/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
@@ -5338,8 +5369,6 @@
"friendly-words/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
@@ -5396,8 +5425,6 @@
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
@@ -5406,8 +5433,6 @@
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"supertap/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"table/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -5556,8 +5581,6 @@
"friendly-words/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"gray-matter/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"mocha/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"mocha/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -5590,12 +5613,8 @@
"qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"read-yaml-file/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"supertap/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"tar-fs/tar-stream/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"test-exclude/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-p2dqj1gdOLrjEYKF7fq+U83KWnkNa8jJvv8PK7mmtKA=",
"aarch64-linux": "sha256-D3IQh19XPPdO+oxZK+MZO8GdNI/3ZmgGXspL6KR8HKc=",
"aarch64-darwin": "sha256-vdcVihHSL1g8IKPwWLcVu+IPcGu+d6aqQNrrQgkV2/g=",
"x86_64-darwin": "sha256-rHS7DaVmxdkQzn2Pv9xxvyfWvSo6/m1MMlRCVMyV1+s="
"x86_64-linux": "sha256-KqxasDpdyLQBZC+N85z4HFXLn/15D9qqDyEqgvWTY7c=",
"aarch64-linux": "sha256-YEBF+S2+8imYWO4PfbCY5AOA5uIqAZY80GO6HK8k/sM=",
"aarch64-darwin": "sha256-CxE5cdMcvcAENZLCilCU6Ndx+wiZVbFxtx0UNySdNDA=",
"x86_64-darwin": "sha256-Z9eU77dkavAGAM0AlcnKXCXrrFuRyLnYwM/F9nrkwrk="
}
}
+2 -1
View File
@@ -147,7 +147,8 @@
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.3.17",
"peerDependencies": {}
+10 -4
View File
@@ -1,4 +1,4 @@
import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { DIFFS_TAG_NAME, FileDiff, processFile, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
@@ -16,11 +16,14 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const [local, others] = splitProps(props, [
"before",
"after",
"patch",
"fileDiff",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
"virtualized",
])
const workerPool = useWorkerPool(props.diffStyle)
@@ -227,7 +230,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
onCleanup(() => monitor.disconnect())
}
const virtualizer = getVirtualizer()
const virtualizer = local.virtualized === false ? undefined : getVirtualizer()
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
@@ -250,9 +253,12 @@ export function Diff<T>(props: SSRDiffProps<T>) {
)
// @ts-expect-error - fileContainer is private but needed for SSR hydration
fileDiffInstance.fileContainer = fileDiffRef
const patch = "patch" in local && typeof local.patch === "string" ? local.patch : ""
const metadata = local.fileDiff ?? (patch ? processFile(patch, { cacheKey: patch }) : undefined)
fileDiffInstance.hydrate({
oldFile: local.before,
newFile: local.after,
oldFile: metadata ? undefined : local.before,
newFile: metadata ? undefined : local.after,
fileDiff: metadata,
lineAnnotations: local.annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
+39 -5
View File
@@ -1,5 +1,12 @@
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import {
FileDiff,
type FileDiffMetadata,
type FileDiffOptions,
processFile,
type SelectedLineRange,
VirtualizedFileDiff,
} from "@pierre/diffs"
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, on, onCleanup, splitProps, untrack } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
@@ -142,6 +149,7 @@ export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
let observer: MutationObserver | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
let parsed: { patch: string; diff: FileDiffMetadata } | undefined
let renderToken = 0
let selectionFrame: number | undefined
let dragFrame: number | undefined
@@ -156,6 +164,7 @@ export function Diff<T>(props: DiffProps<T>) {
const [local, others] = splitProps(props, [
"before",
"after",
"patch",
"fileDiff",
"class",
"classList",
@@ -163,6 +172,7 @@ export function Diff<T>(props: DiffProps<T>) {
"selectedLines",
"commentedLines",
"onRendered",
"virtualized",
])
const mobile = createMediaQuery("(max-width: 640px)")
@@ -178,11 +188,24 @@ export function Diff<T>(props: DiffProps<T>) {
})
const estimate = createMemo(() => {
const value = Math.max(lines(before()), lines(after())) * ESTIMATED_LINE_HEIGHT
// A tracked detail response already carries a hunk-bounded git patch. Base
// placeholder height on that patch instead of the full source file so a
// tiny change in a large file does not reserve a large gray body.
const patch = "patch" in local && typeof local.patch === "string" ? local.patch : ""
const value = (patch ? lines(patch) : Math.max(lines(before()), lines(after()))) * ESTIMATED_LINE_HEIGHT
if (value === 0) return MIN_PLACEHOLDER_HEIGHT
return Math.max(MIN_PLACEHOLDER_HEIGHT, Math.min(value, MAX_PLACEHOLDER_HEIGHT))
})
const patchDiff = () => {
if (!("patch" in local) || typeof local.patch !== "string" || local.patch.length === 0) return
if (parsed?.patch === local.patch) return parsed.diff
const diff = processFile(local.patch, { cacheKey: local.patch })
if (!diff) return
parsed = { patch: local.patch, diff }
return diff
}
const large = createMemo(() => {
return Math.max(before().length, after().length) > 500_000
})
@@ -681,8 +704,19 @@ export function Diff<T>(props: DiffProps<T>) {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
// Eager (non-virtualized) patch-backed diffs render their visible hunks once
// and never re-render on scroll or height changes, avoiding Pierre's
// re-render-all storms. Full-content or oversized diffs keep virtualizing.
const virtualizer = local.virtualized === false ? undefined : getVirtualizer()
if (local.virtualized === false && sharedVirtualizer) {
sharedVirtualizer.release()
sharedVirtualizer = undefined
}
const annotations = untrack(() => local.annotations)
// Parse hunk-bounded patches only after the deferred visibility gate. This
// preserves quick session switching while avoiding a full before/after diff
// reconstruction for tiny changes inside large source files.
const metadata = local.fileDiff ?? patchDiff()
// Preserve container height during re-render to prevent scroll jumps.
// When Pierre tears down the DOM (innerHTML = ""), the container collapses
@@ -699,9 +733,9 @@ export function Diff<T>(props: DiffProps<T>) {
container.innerHTML = ""
if (local.fileDiff) {
if (metadata) {
instance.render({
fileDiff: local.fileDiff,
fileDiff: metadata,
lineAnnotations: annotations,
containerWrapper: container,
})
+8
View File
@@ -48,6 +48,11 @@ type DiffShared<T> = FileDiffOptions<T> & {
commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
// When false, render the supplied diff once instead of row-virtualizing it.
// Callers should supply hunk-bounded `fileDiff`/`patch` data for large source
// files so eager rendering does not expand full before/after content.
// Defaults to virtualized.
virtualized?: boolean
class?: string
classList?: ComponentProps<"div">["classList"]
}
@@ -55,6 +60,8 @@ type DiffShared<T> = FileDiffOptions<T> & {
type DiffPair<T> = DiffShared<T> & {
before: FileContents
after: FileContents
/** Unified patch used to parse only rendered hunks instead of full file contents. */
patch?: string
fileDiff?: undefined
}
@@ -62,6 +69,7 @@ type DiffPatch<T> = DiffShared<T> & {
fileDiff: FileDiffMetadata
before?: undefined
after?: undefined
patch?: undefined
}
export type DiffProps<T = {}> = DiffPair<T> | DiffPatch<T>
+7
View File
@@ -181,6 +181,12 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw
- **Prefer kilo-ui styles**: Always reuse existing kilo-ui CSS variables, tokens, and component styles instead of writing custom CSS. If a style doesn't exist in kilo-ui yet, add it there and reuse it rather than inlining or duplicating styles in the webview.
- **Icons**: kilo-ui has 75+ custom SVG icons in [`packages/ui/src/components/icon.tsx`](../../packages/ui/src/components/icon.tsx). To list all available icon names: `node -e "const c=require('fs').readFileSync('../../packages/ui/src/components/icon.tsx','utf8');[...c.matchAll(/^\\s{2}[\"']?([\\w-]+)[\"']?:\\s*\x60/gm)].map(m=>m[1]).sort().forEach(n=>console.log(n))"`. Icon names use both hyphenated (`arrow-left`) and bare-word (`brain`, `console`, `providers`) keys.
### Diff Rendering Performance
- Preserve hunk-bounded unified `patch` data through Changes/review detail flows and pass patch-derived `FileDiffMetadata` to Pierre when available. Do not eagerly render Pierre from complete `before`/`after` contents based only on changed-line counts: a tiny patch in a large source file can otherwise parse and render the entire file while the user sees a placeholder.
- Pierre workers can offload highlighted updates, but they do not make an expensive synchronous initial render safe. Keep initial rendering hunk-bounded, and keep patch parsing behind deferred visibility/activation where session-switch responsiveness depends on it.
- When changing diff scheduling, verify both rapid session switching and fast scrolling through a review. Improving one by shifting work into the other is a regression, not an optimization.
## Docs Screenshot Stories
When adding or updating Storybook stories for screenshots used by docs, make the story content match the docs page closely before replacing the docs image. Do not replace screenshots from VSCode Legacy docs tabs or sections.
@@ -191,6 +197,7 @@ Generated screenshot baselines live under `packages/kilo-docs/public/img/screens
- Extension logs: "Extension Host" output channel (not Debug Console)
- Webview logs: Command Palette → "Developer: Open Webview Developer Tools"
- In Chrome/VS Code performance traces, associate CPU `ProfileChunk` events to their `Profile.id` target before attributing work to a thread. `v8:ProfEvntProc` is a profile delivery thread, not evidence that application work ran off the webview main thread.
- All debug output must be prepended with `[Kilo New]` for easy filtering
## Naming Conventions
+24 -12
View File
@@ -6,22 +6,34 @@
<a href="https://www.reddit.com/r/kilocode/"><img src="https://raster.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit" height="20"></a>
</p>
# 🚀 Kilo
<p align="center">
<a href="https://kilo.ai"><img width="250" alt="kilo-code-logo" src="https://github.com/user-attachments/assets/bdb0c174-b9fd-40ad-a47b-f3aab9b54e8d" /></a>
</p>
> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
<p align="center">
<strong>Kilo is the all-in-one agentic engineering platform.</strong><br>
Build, ship, and iterate faster with the most popular open source coding agent.
</p>
- ✨ Generate code from natural language
- ✅ Checks its own work
- 🧪 Run terminal commands
- 🌐 Automate the browser
- ⚡ Inline autocomplete suggestions
- 🤖 Latest AI models
- 🎁 API keys optional
<p align="center">
<img width="100%" alt="Kilo Code running inside VS Code" src="https://kilo.ai/_next/image?url=%2Fscreenshots%2Fvs-code%2Fvs-code-home-page-screenshot.png&w=3840&q=75">
</p>
## Quick Links
<p align="center">
<a href="https://kilo.ai">Website</a> ·
<a href="https://kilo.ai/install">Install</a> ·
<a href="https://kilo.ai/landing/vs-code">IDE</a> ·
<a href="https://kilo.ai/cli">CLI</a> ·
<a href="https://kilo.ai/docs">Docs</a> ·
<a href="https://kilo.ai/leaderboard">Models</a> ·
<a href="https://kilo.ai/gateway">Gateway</a> ·
<a href="https://kilo.ai/pricing">Pricing</a> ·
<a href="https://kilo.ai/pricing/kilo-pass">Kilo Pass</a>
</p>
- [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download)
- [Official Kilo.ai Home page](https://kilo.ai) (learn more)
<p align="center">
500+ models. One open source agent in <a href="https://kilo.ai/install">VS Code</a>, <a href="https://kilo.ai/features/jetbrains-native">JetBrains</a>, <a href="https://kilo.ai/cli">CLI</a>, <a href="https://kilo.ai/slack">Slack</a>, and <a href="https://kilo.ai/cloud">Cloud</a>.
</p>
> 🚀 **Coming from Roo Code?** Switch to Kilo and check out our [migration guide](https://kilo.ai/articles/roo-to-kilo-migration-guide)!
+57 -33
View File
@@ -56,45 +56,46 @@ const esbuildProblemMatcherPlugin = {
}
/**
* Stub the pierre worker module so the Diff/Code components work without
* web workers in the VS Code webview. The `@pierre/diffs` library handles
* undefined worker pools gracefully (renders without syntax highlighting).
* Route the shared `@opencode-ai/ui/pierre/worker` module (and its relative
* variants) to the Kilo implementation in `webview-ui/pierre-worker.ts`.
*
* We stub the entire worker module rather than just the URL import because
* `new Worker('')` would throw at runtime.
* The upstream module loads Pierre's Shiki worker via a Vite-only
* `?worker&url` import that esbuild can't resolve. The Kilo replacement loads
* the worker from the bundled `dist/shiki-worker.js` asset instead, so syntax
* highlighting runs off the main thread. `@pierre/diffs/worker` (used by that
* replacement) is left alone.
*
* @type {import('esbuild').Plugin}
*/
const pierreWorkerStubPlugin = {
name: "pierre-worker-stub",
const pierreWorkerAliasPlugin = {
name: "pierre-worker-alias",
setup(build) {
// Stub the Vite-specific ?worker&url import
build.onResolve({ filter: /\?worker&url$/ }, (args) => ({
path: args.path,
namespace: "worker-url-stub",
}))
build.onLoad({ filter: /.*/, namespace: "worker-url-stub" }, () => ({
contents: "export default ''",
loader: "js",
}))
// Stub the pierre worker module so getWorkerPool always returns undefined
build.onResolve({ filter: /pierre\/worker$/ }, (args) => {
// Only stub the local UI worker module, not @pierre/diffs/worker
if (args.path.includes("@pierre")) return
return {
path: args.path,
namespace: "pierre-worker-stub",
}
return { path: path.join(__dirname, "webview-ui", "pierre-worker.ts") }
})
},
}
/**
* Resolve the synthetic `kilo-shiki-worker` entry point to Pierre's Shiki worker
* so esbuild can bundle it (and its inlined oniguruma WebAssembly) into a single
* `dist/shiki-worker.js` asset loaded by `webview-ui/pierre-worker.ts`. Switch to
* `worker-portable.js` to drop WebAssembly and use the JS regex engine instead.
*
* @type {import('esbuild').Plugin}
*/
const shikiWorkerEntryPlugin = {
name: "shiki-worker-entry",
setup(build) {
build.onResolve({ filter: /^kilo-shiki-worker$/ }, async () => {
const resolved = await build.resolve("@pierre/diffs/worker/worker.js", {
kind: "import-statement",
resolveDir: __dirname,
})
if (resolved.errors.length > 0) return { errors: resolved.errors }
return { path: resolved.path }
})
build.onLoad({ filter: /.*/, namespace: "pierre-worker-stub" }, () => ({
contents: `
export function getWorkerPool() { return undefined }
export function getWorkerPools() { return { unified: undefined, split: undefined } }
export function workerFactory() { return undefined }
`,
loader: "js",
}))
},
}
@@ -157,7 +158,7 @@ function createBrowserWebviewContext(entryPoint, outfile) {
},
plugins: [
solidDedupePlugin,
pierreWorkerStubPlugin,
pierreWorkerAliasPlugin,
svgSpritePlugin,
cssPackageResolvePlugin,
solidPlugin(),
@@ -166,6 +167,23 @@ function createBrowserWebviewContext(entryPoint, outfile) {
})
}
// Bundle Pierre's Shiki worker into a single self-contained asset that the
// webviews load off the main thread for syntax highlighting.
function createShikiWorkerContext() {
return esbuild.context({
entryPoints: ["kilo-shiki-worker"],
bundle: true,
format: "iife",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
outfile: "dist/shiki-worker.js",
logLevel: "silent",
plugins: [shikiWorkerEntryPlugin, esbuildProblemMatcherPlugin],
})
}
async function main() {
// Build extension
const extensionCtx = await esbuild.context({
@@ -200,6 +218,9 @@ async function main() {
// Build webview
const webviewCtx = await createBrowserWebviewContext("webview-ui/src/index.tsx", "dist/webview.js")
// Build the shared Shiki highlighting worker asset
const shikiWorkerCtx = await createShikiWorkerContext()
if (watch) {
await Promise.all([
extensionCtx.watch(),
@@ -208,6 +229,7 @@ async function main() {
diffViewerCtx.watch(),
diffVirtualCtx.watch(),
kiloClawCtx.watch(),
shikiWorkerCtx.watch(),
])
} else {
await Promise.all([
@@ -217,14 +239,16 @@ async function main() {
kiloClawCtx.rebuild(),
diffViewerCtx.rebuild(),
diffVirtualCtx.rebuild(),
shikiWorkerCtx.rebuild(),
])
await Promise.all([
extensionCtx.dispose(),
webviewCtx.dispose(),
agentManagerCtx.dispose(),
kiloClawCtx.dispose(),
diffViewerCtx.dispose(),
diffVirtualCtx.dispose(),
kiloClawCtx.dispose(),
shikiWorkerCtx.dispose(),
])
}
}
+1
View File
@@ -6,6 +6,7 @@
"webview-ui/diff-viewer/index.tsx",
"webview-ui/diff-virtual/index.tsx",
"webview-ui/kiloclaw/index.tsx",
"webview-ui/pierre-worker.ts",
"webview-ui/src/index.tsx",
"src/**/__tests__/**/*.{ts,spec.ts}",
"src/**/*.test.ts",
+5 -5
View File
@@ -37,11 +37,11 @@
"agent",
"agentic",
"coding",
"coding-agent",
"coding-assistant",
"coding agent",
"coding assistant",
"autocomplete",
"code-completion",
"pair-programming",
"code completion",
"pair programming",
"chat",
"terminal",
"chatgpt",
@@ -49,7 +49,7 @@
"sonnet",
"anthropic",
"openai",
"zoo-code"
"zoo code"
],
"activationEvents": [
"onStartupFinished"
@@ -113,6 +113,7 @@ export class DiffVirtualProvider implements vscode.Disposable {
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-virtual.js")),
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-virtual.css")),
iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
title: "Diff Virtual",
extraStyles: "#root { display: flex; flex-direction: column; height: 100%; }",
})
+1
View File
@@ -3494,6 +3494,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")),
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.css")),
iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
title: "Kilo Code",
port: this.connectionService.getServerInfo()?.port,
extraStyles: `.container { height: 100%; display: flex; flex-direction: column; height: 100vh; border-right: 1px solid var(--border-weak-base); }`,
@@ -84,6 +84,7 @@ export class VscodeHost implements Host {
scriptUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "agent-manager.js")),
styleUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "agent-manager.css")),
iconsBaseUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
workerUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
title: "Agent Manager",
port,
})
@@ -238,6 +238,7 @@ export class DiffViewerProvider implements vscode.Disposable {
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.js")),
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-viewer.css")),
iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
workerUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
title: "Changes",
port: this.connection.getServerInfo()?.port,
extraStyles: "#root { display: flex; flex-direction: column; }",
@@ -71,6 +71,7 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile {
file: raw.file,
before: view ? text(view, "deletions") : "",
after: view ? text(view, "additions") : "",
patch: raw.patch,
additions: raw.additions,
deletions: raw.deletions,
status: raw.status,
@@ -90,11 +90,17 @@ export function createStagedDiffSource(): DiffSource {
// For added: HEAD has no blob. For deleted: index has no blob.
const before = entry.status === "added" ? "" : await showBlob(git, dir, "HEAD", file)
const after = entry.status === "deleted" ? "" : await showBlob(git, dir, INDEX_REF, file)
const result = await git.execGit(
["-c", "core.quotepath=false", "diff", "--cached", "--no-ext-diff", "--no-renames", "HEAD", "--", file],
dir,
)
const patch = result.code === 0 ? result.stdout : undefined
const summarized = before === "" && after === "" && entry.status === "modified"
return {
file,
before,
after,
patch,
additions: entry.additions,
deletions: entry.deletions,
status: entry.status,
@@ -130,6 +130,10 @@ export function createUnstagedDiffSource(): DiffSource {
// after = disk content (or "" for deleted).
const before = !entry.tracked || entry.status === "added" ? "" : await showBlob(git, dir, INDEX_REF, file)
const after = entry.status === "deleted" ? "" : await readDisk(dir, file)
const result = entry.tracked
? await git.execGit(["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", "--", file], dir)
: undefined
const patch = result?.code === 0 ? result.stdout : undefined
const summarized = before === "" && after === "" && entry.status === "modified"
// For untracked added files numstat doesn't return counts, so backfill
@@ -139,6 +143,7 @@ export function createUnstagedDiffSource(): DiffSource {
file,
before,
after,
patch,
additions,
deletions: entry.deletions,
status: entry.status,
@@ -139,15 +139,16 @@ async function resolveOverrideRef(
/**
* Project a `WorktreeDiffEntry` from `local-diff.ts` onto the `DiffFile` shape
* expected by the diff viewer. Drops `patch` (the webview rebuilds before/after
* for itself) and coerces optional `before`/`after` to empty strings when the
* entry is summarized.
* expected by the diff viewer. Preserve its hunk-bounded `patch` so Pierre can
* parse the git diff directly rather than recomputing a diff from full source
* contents; summarized entries still coerce optional content to empty strings.
*/
function toDiffFile(entry: WorktreeDiffEntry): DiffFile {
return {
file: entry.file,
before: entry.before ?? "",
after: entry.after ?? "",
patch: entry.patch,
additions: entry.additions,
deletions: entry.deletions,
status: entry.status,
+2
View File
@@ -17,6 +17,8 @@ export interface DiffFile {
file: string
before: string
after: string
/** Hunk-bounded unified patch used by Pierre to avoid re-diffing full files. */
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
@@ -144,6 +144,7 @@ export class KiloClawProvider implements vscode.Disposable {
scriptUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "dist", "kiloclaw.js")),
styleUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "dist", "kiloclaw.css")),
iconsBaseUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "assets", "icons")),
workerUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.uri, "dist", "shiki-worker.js")),
title: "KiloClaw",
})
@@ -313,7 +313,29 @@ async function listDshowAudioDevices(bin: string): Promise<string[]> {
export function parseDshowAudioDevices(raw: string): string[] {
const devices = new Set<string>()
for (const match of raw.matchAll(/"([^"]+)"\s+\(audio\)/g)) devices.add(match[1]!)
const state = { audio: false }
const legacy = /"([^"]+)"\s+\(audio\)/
const quoted = /"([^"]+)"/
const section = (line: string) => /DirectShow audio devices/i.test(line)
const other = (line: string) => /DirectShow (video|external) devices/i.test(line)
const alt = (line: string) => /\]\s+Alternative name\s+"/i.test(line)
for (const line of raw.split(/\r?\n/)) {
const match = legacy.exec(line)
if (match) devices.add(match[1]!)
if (section(line)) {
state.audio = true
continue
}
if (other(line)) {
if (!state.audio) continue
state.audio = false
break
}
if (!state.audio || alt(line)) continue
const found = quoted.exec(line)
if (found) devices.add(found[1]!)
}
return [...devices]
}
+2 -1
View File
@@ -37,6 +37,7 @@ export function buildWebviewHtml(
scriptUri: vscode.Uri
styleUri: vscode.Uri
iconsBaseUri: vscode.Uri
workerUri: vscode.Uri
title: string
port?: number
extraStyles?: string
@@ -81,7 +82,7 @@ export function buildWebviewHtml(
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}";</script>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}";</script>
<script nonce="${nonce}" src="${opts.scriptUri}"></script>
</body>
</html>`
@@ -26,6 +26,8 @@ export function buildCspString(cspSource: string, nonce: string, port?: number):
"default-src 'none'",
`style-src 'unsafe-inline' ${cspSource}`,
`script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`,
// Allow the bundled Shiki highlighting worker (loaded as a webview resource).
`worker-src ${cspSource}`,
`font-src ${cspSource}`,
`connect-src ${cspSource} ${connectSrc}`,
`img-src ${cspSource} data: https:`,
@@ -1,8 +1,10 @@
import { describe, expect, it } from "bun:test"
import { mergeWorktreeDiffs } from "../../webview-ui/agent-manager/diff-state"
import {
EAGER_DIFF_REVIEW_LINES,
EXTREME_DIFF_CHANGED_LINES,
allOpenFiles,
eagerDiffFiles,
expandableOpenFiles,
initialOpenFiles,
toggleOpenFiles,
@@ -26,16 +28,27 @@ function diff(overrides: Partial<WorktreeFileDiff>): WorktreeFileDiff {
}
describe("agent manager diff state", () => {
it("preserves loaded detail when summary metadata is unchanged", () => {
const prev = [diff({ summarized: false, before: "old\n", after: "new\n" })]
it("preserves loaded detail and patch when summary metadata is unchanged", () => {
const prev = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" })]
const next = [diff({ summarized: true })]
const result = mergeWorktreeDiffs(prev, next)
expect(result.diffs).toEqual([diff({ summarized: false, before: "old\n", after: "new\n" })])
expect(result.diffs).toEqual([
diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" }),
])
expect(result.diffs[0]).toBe(prev[0])
expect(result.stale.size).toBe(0)
})
it("replaces detailed content when patch anchors change", () => {
const prev = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" })]
const next = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -100 +100 @@\n-old\n+new\n" })]
const result = mergeWorktreeDiffs(prev, next)
expect(result.diffs[0]).toBe(next[0])
expect(result.diffs[0]?.patch).toContain("@@ -100 +100 @@")
})
it("preserves cached content and marks stale when summary metadata changes", () => {
const prev = [diff({ summarized: false, before: "old\n", after: "new\n", additions: 1 })]
const next = [diff({ summarized: true, additions: 2 })]
@@ -97,3 +110,46 @@ describe("agent manager diff state", () => {
expect(toggleOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toEqual([])
})
})
describe("eager diff files", () => {
it("renders hunk-bounded detailed patches eagerly", () => {
const diffs = [
diff({ file: "src/a.ts", patch: "@@ -1 +1 @@\n-a\n+b\n", additions: 10, deletions: 5 }),
diff({ file: "src/b.ts", patch: "@@ -1 +1 @@\n-a\n+b\n", additions: 3, deletions: 0 }),
]
expect(eagerDiffFiles(diffs)).toEqual(new Set(["src/a.ts", "src/b.ts"]))
})
it("virtualizes a full-content detail without a hunk-bounded patch", () => {
const diffs = [diff({ file: "src/large-source.ts", before: "a\n".repeat(4000), after: "b\n", additions: 1 })]
expect(eagerDiffFiles(diffs)).toEqual(new Set())
})
it("virtualizes files larger than the large-file threshold", () => {
const diffs = [
diff({ file: "src/big.ts", patch: "large", additions: EXTREME_DIFF_CHANGED_LINES + 1, deletions: 0 }),
diff({ file: "src/small.ts", patch: "small", additions: 5, deletions: 0 }),
]
expect(eagerDiffFiles(diffs)).toEqual(new Set(["src/small.ts"]))
})
it("stops rendering eagerly once the review budget is exhausted", () => {
// Each file is under the large-file threshold, but together they exceed the
// aggregate budget, so the overflow falls back to virtualization.
const diffs = [
diff({ file: "src/a.ts", patch: "a", additions: 2000, deletions: 0 }),
diff({ file: "src/b.ts", patch: "b", additions: 2000, deletions: 0 }),
diff({ file: "src/c.ts", patch: "c", additions: 2000, deletions: 0 }),
diff({ file: "src/d.ts", patch: "d", additions: EAGER_DIFF_REVIEW_LINES - 6005, deletions: 0 }),
diff({ file: "src/e.ts", patch: "e", additions: 2000, deletions: 0 }),
diff({ file: "src/f.ts", patch: "f", additions: 5, deletions: 0 }),
]
const eager = eagerDiffFiles(diffs)
expect(eager.has("src/a.ts")).toBe(true)
expect(eager.has("src/d.ts")).toBe(true)
// Budget exhausted, so the next sizeable file virtualizes.
expect(eager.has("src/e.ts")).toBe(false)
// A smaller later file still fits within the remaining budget.
expect(eager.has("src/f.ts")).toBe(true)
})
})
@@ -69,6 +69,7 @@ describe("createSessionDiffSource.fetch", () => {
expect(foo.file).toBe("foo.ts")
expect(foo.before).toBe("keep\nold\n")
expect(foo.after).toBe("keep\nnew\n")
expect(foo.patch).toBe(modifiedPatch)
expect(foo.additions).toBe(1)
expect(foo.deletions).toBe(1)
expect(foo.status).toBe("modified")
@@ -14,6 +14,27 @@ describe("parseDshowAudioDevices", () => {
expect(parseDshowAudioDevices(raw)).toEqual(["Microphone Array (Realtek Audio)", "Webcam Microphone"])
})
it("extracts section-listed Windows dshow audio device names", () => {
const raw = `
[dshow @ 000001] DirectShow video devices (some may be both video and audio devices)
[dshow @ 000001] "OBS Virtual Camera"
[dshow @ 000001] Alternative name "@device_video"
[dshow @ 000001] DirectShow audio devices
[dshow @ 000001] "Headset (2- Bose QuietComfort 35 Series II)"
[dshow @ 000001] Alternative name "@device_headset"
[dshow @ 000001] "Microphone (MSI Sound Tune)"
[dshow @ 000001] Alternative name "@device_microphone"
[dshow @ 000001] "Alternative name Microphone"
[dshow @ 000001] Alternative name "@device_alternative"
`
expect(parseDshowAudioDevices(raw)).toEqual([
"Headset (2- Bose QuietComfort 35 Series II)",
"Microphone (MSI Sound Tune)",
"Alternative name Microphone",
])
})
it("deduplicates repeated dshow audio device names", () => {
const raw = `"Microphone" (audio)\n"Microphone" (audio)`
@@ -48,6 +48,12 @@ describe("buildCspString", () => {
expect(result).toContain(`font-src ${cspSource}`)
})
it("allows only webview resources for the Shiki worker", () => {
const result = buildCspString(cspSource, nonce)
expect(result).toContain(`worker-src ${cspSource}`)
expect(result).not.toContain(`worker-src ${cspSource} blob:`)
})
it("includes cspSource and https: in img-src", () => {
const result = buildCspString(cspSource, nonce)
expect(result).toContain("img-src")
@@ -33,6 +33,7 @@ import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech
import {
LONG_DIFF_MARKER_FILE_COUNT,
allOpenFiles,
eagerDiffFiles,
initialOpenFiles,
isLargeDiffFile,
toggleOpenFiles,
@@ -119,6 +120,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
// Reorder diffs to match the file-tree's depth-first visual order so
// scrolling through the accordion matches the tree grouping.
const sorted = createMemo(() => treeOrder(props.diffs))
const eager = createMemo(() => eagerDiffFiles(sorted()))
const comments = () => props.comments
const setComments = (next: ReviewComment[]) => props.onCommentsChange(next)
@@ -611,7 +613,9 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
patch={diff.patch}
diffStyle={props.diffStyle ?? "unified"}
virtualized={!eager().has(diff.file)}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
@@ -41,6 +41,7 @@ import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech
import {
LONG_DIFF_MARKER_FILE_COUNT,
allOpenFiles,
eagerDiffFiles,
initialOpenFiles,
isLargeDiffFile,
toggleOpenFiles,
@@ -135,6 +136,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
// Reorder diffs to match the file-tree's depth-first visual order so
// scrolling through the diff panel matches the tree on the left.
const sorted = createMemo(() => treeOrder(props.diffs))
const eager = createMemo(() => eagerDiffFiles(sorted()))
const comments = () => props.comments
const setComments = (next: ReviewComment[]) => props.onCommentsChange(next)
@@ -708,7 +710,9 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
patch={diff.patch}
diffStyle={props.diffStyle}
virtualized={!eager().has(diff.file)}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={props.canComment !== false}
@@ -2,11 +2,32 @@ import type { WorktreeFileDiff } from "../src/types/messages"
export const LONG_DIFF_MARKER_FILE_COUNT = 50
export const EXTREME_DIFF_CHANGED_LINES = 2_000
// Total changed rows rendered eagerly (non-virtualized) across one review before
// the remainder falls back to virtualization. Bounds eager DOM for huge reviews
// while letting normal reviews render fully so scrolling never shows gap buffers.
export const EAGER_DIFF_REVIEW_LINES = 8_000
export function isLargeDiffFile(diff: WorktreeFileDiff): boolean {
return diff.additions + diff.deletions > EXTREME_DIFF_CHANGED_LINES
}
// Files whose hunk-bounded patches should render eagerly (no row virtualization)
// so their rows stay mounted and never re-render on scroll. Never eager-render a
// detail without a patch: a tiny change in a very large source file would make
// Pierre re-diff and render full before/after contents on the main thread.
export function eagerDiffFiles(diffs: WorktreeFileDiff[]): Set<string> {
const eager = new Set<string>()
let used = 0
for (const diff of diffs) {
if (!diff.patch || isLargeDiffFile(diff)) continue
const size = diff.additions + diff.deletions
if (used + size > EAGER_DIFF_REVIEW_LINES) continue
used += size
eager.add(diff.file)
}
return eager
}
export function expandableOpenFiles(diffs: WorktreeFileDiff[]): string[] {
return diffs.filter((diff) => !isLargeDiffFile(diff) && diff.generatedLike !== true).map((diff) => diff.file)
}
@@ -38,6 +38,7 @@ export function mergeWorktreeDiffs(prev: WorktreeFileDiff[], next: WorktreeFileD
existing.file === diff.file &&
existing.before === diff.before &&
existing.after === diff.after &&
existing.patch === diff.patch &&
sameDiffMeta(existing, diff)
)
return existing
@@ -45,8 +46,19 @@ export function mergeWorktreeDiffs(prev: WorktreeFileDiff[], next: WorktreeFileD
if (!diff.summarized) return diff
// Metadata matches — restore cached content as before.
if (sameDiffMeta({ ...existing, summarized: true }, diff)) {
const merged = { ...diff, before: existing.before, after: existing.after, summarized: false }
if (existing.before === merged.before && existing.after === merged.after && sameDiffMeta(existing, merged))
const merged = {
...diff,
before: existing.before,
after: existing.after,
patch: existing.patch,
summarized: false,
}
if (
existing.before === merged.before &&
existing.after === merged.after &&
existing.patch === merged.patch &&
sameDiffMeta(existing, merged)
)
return existing
return merged
}
@@ -0,0 +1,67 @@
// Kilo-specific replacement for the shared `@opencode-ai/ui/pierre/worker`
// module (wired up by the pierre-worker-alias plugin in esbuild.js).
//
// The upstream module loads Pierre's Shiki worker through a Vite-only
// `?worker&url` import, which esbuild cannot resolve and which the VS Code
// webview CSP blocks. As a result the webview previously ran syntax
// highlighting synchronously on the main thread (`findNextMatchSync`), which
// froze scrolling on large diffs.
//
// Here we instead load the worker from a real dist asset (`dist/shiki-worker.js`,
// also produced by esbuild.js) using the webview URI the extension injects into
// the page. Pierre can offload highlighted updates to the pool after its initial
// plain render. The diff wrapper still needs to keep that initial render cheap,
// which is why review surfaces pass hunk-bounded patches instead of full files.
import { WorkerPoolManager } from "@pierre/diffs/worker"
export type WorkerPoolStyle = "unified" | "split"
// Oniguruma WebAssembly engine (matches the highlighting quality of the rest of
// the app). The CSP already allows `wasm-unsafe-eval`, and if the worker fails to
// instantiate, WorkerPoolManager falls back to the main-thread highlighter. To
// avoid WebAssembly entirely, build the portable worker and use "shiki-js".
const ENGINE = "shiki-wasm"
function uri(): string | undefined {
if (typeof window === "undefined") return undefined
return (window as { KILO_SHIKI_WORKER_URI?: string }).KILO_SHIKI_WORKER_URI
}
export function workerFactory(): Worker {
const url = uri()
if (!url) throw new Error("KILO_SHIKI_WORKER_URI is not set")
return new Worker(url)
}
function createPool(lineDiffType: "none" | "word-alt") {
const pool = new WorkerPoolManager(
{ workerFactory, poolSize: 2 },
{ theme: "Kilo", lineDiffType, preferredHighlighter: ENGINE },
)
void pool.initialize()
return pool
}
let unified: WorkerPoolManager | undefined
let split: WorkerPoolManager | undefined
export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
// No injected worker URI means we can't spawn the worker; returning undefined
// makes Pierre fall back to the existing main-thread highlighter.
if (!uri()) return undefined
if (style === "split") {
if (!split) split = createPool("word-alt")
return split
}
if (!unified) unified = createPool("none")
return unified
}
export function getWorkerPools() {
return {
unified: getWorkerPool("unified"),
split: getWorkerPool("split"),
}
}
@@ -123,6 +123,8 @@ export interface WorktreeFileDiff {
file: string
before: string
after: string
/** Hunk-bounded unified patch used by Pierre to avoid re-diffing full files. */
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
+2
View File
@@ -166,6 +166,7 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mammoth": "1.12.0",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
@@ -191,6 +192,7 @@
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5"
@@ -196,7 +196,9 @@ export namespace KiloSessions {
).filter((p) => p.sessionID === sessionID)
if (permissions.length > 0) return "permission"
const questions = (await Question.list()).filter((q) => q.sessionID === sessionID)
const questions = (
await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
).filter((q) => q.sessionID === sessionID)
if (questions.length > 0) return "question"
const status = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(SessionID.make(sessionID))))
@@ -77,6 +77,11 @@ export namespace RemoteSender {
readonly list: () => Promise<ReadonlyArray<Permission.Request>>
readonly reply: (input: Permission.ReplyInput) => Promise<boolean>
}
question?: {
readonly list: () => Promise<ReadonlyArray<Question.Request>>
readonly reply: (input: Parameters<Question.Interface["reply"]>[0]) => Promise<void>
readonly reject: (requestID: QuestionID) => Promise<void>
}
prompt?: (input: SessionPrompt.PromptInput) => Promise<unknown>
}
@@ -99,6 +104,20 @@ export namespace RemoteSender {
return AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input)))
},
}
const question = options.question ?? {
list: async () => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
},
reply: async (input: Parameters<Question.Interface["reply"]>[0]) => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
},
reject: async (requestID: QuestionID) => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(requestID)))
},
}
const prompt =
options.prompt ??
(async (input: SessionPrompt.PromptInput) => {
@@ -157,7 +176,7 @@ export namespace RemoteSender {
async function replay(sessionId: string) {
const [suggestions, questions, permissions] = await Promise.all([
Suggestion.list(),
Question.list(),
question.list(),
permission.list(),
])
for (const suggestion of suggestions) {
@@ -324,7 +343,7 @@ export namespace RemoteSender {
}
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
dispatchQuick(msg, dir, () =>
Question.reply({ ...parsed.data, requestID: QuestionID.make(parsed.data.requestID) }),
question.reply({ ...parsed.data, requestID: QuestionID.make(parsed.data.requestID) }),
)
return
}
@@ -339,7 +358,7 @@ export namespace RemoteSender {
return
}
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
dispatchQuick(msg, dir, () => Question.reject(QuestionID.make(parsed.data.requestID)))
dispatchQuick(msg, dir, () => question.reject(QuestionID.make(parsed.data.requestID)))
return
}
if (msg.command === "suggestion_accept") {
@@ -48,6 +48,9 @@ export const PlanFollowupRuntime = {
reject(requestID: Parameters<Question.Interface["reject"]>[0]) {
return questions().runPromise((svc) => svc.reject(requestID))
},
reply(input: Parameters<Question.Interface["reply"]>[0]) {
return questions().runPromise((svc) => svc.reply(input))
},
},
todo: {
get(sessionID: SessionID) {
@@ -1,8 +1,9 @@
// kilocode_change - new file
import { remapChildren as _remapChildren } from "./fork"
import z from "zod"
import { Effect, Schema } from "effect"
import { Cause, Effect, Schema } from "effect"
import { BusEvent } from "@/bus/bus-event"
import { EffectBridge } from "@/effect/bridge"
import { Session } from "@/session/session"
import { MessageID, SessionID } from "@/session/schema"
import { fn } from "@/util/fn"
@@ -230,14 +231,18 @@ export namespace KiloSession {
// Session lifecycle hooks (share, unshare, remove)
// ---------------------------------------------------------------------------
export async function shareSession(id: string): Promise<{ url: string }> {
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
return KiloSessions.share(id)
export function shareSession(id: SessionID) {
return EffectBridge.fromPromise(async () => {
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
return KiloSessions.share(id)
}).pipe(Effect.catchCause((cause) => Effect.fail(Cause.squash(cause))))
}
export async function unshareSession(id: string): Promise<void> {
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
await KiloSessions.unshare(id)
export function unshareSession(id: SessionID) {
return EffectBridge.fromPromise(async () => {
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
await KiloSessions.unshare(id)
}).pipe(Effect.catchCause((cause) => Effect.fail(Cause.squash(cause))))
}
export async function removeSession(id: string): Promise<void> {
@@ -0,0 +1,17 @@
import mammoth from "mammoth"
import * as path from "path"
import { Readable } from "stream"
export function accepts(filepath: string) {
return path.extname(filepath).toLowerCase() === ".docx"
}
export async function open(filepath: string) {
const result = await mammoth.extractRawText({ path: filepath }).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to extract text from DOCX file: ${filepath}\n${message}`, { cause: err })
})
const warnings = result.messages.filter((item) => item.type === "warning").map((item) => item.message)
const note = warnings.length > 0 ? `\n\n(DOCX extraction warnings: ${warnings.join("; ")})` : ""
return Readable.from([result.value + note])
}
@@ -0,0 +1,15 @@
import type { Readable } from "stream"
import * as Docx from "./read-docx"
import * as Notebook from "./notebook"
import * as Xlsx from "./xlsx"
export function binary(filepath: string) {
return Docx.accepts(filepath) || Xlsx.is(filepath)
}
export async function open(filepath: string): Promise<Readable | undefined> {
if (Docx.accepts(filepath)) return Docx.open(filepath)
if (Xlsx.is(filepath)) return Xlsx.open(filepath)
if (Notebook.isFile(filepath)) return Notebook.open(filepath)
return undefined
}
@@ -0,0 +1,78 @@
import path from "path"
import { Readable } from "stream"
import { read, utils, type CellObject, type WorkBook } from "xlsx"
const ROW_LIMIT = 50_000
const MAX_SIZE = 50 * 1024 * 1024
const MAX_SIZE_LABEL = `${MAX_SIZE / (1024 * 1024)} MB`
export function is(filepath: string) {
return path.extname(filepath).toLowerCase() === ".xlsx"
}
export async function open(filepath: string) {
const file = Bun.file(filepath)
if (file.size > MAX_SIZE) {
throw new Error(`Cannot read spreadsheet file: ${filepath} exceeds the ${MAX_SIZE_LABEL} size limit`)
}
const bytes = new Uint8Array(await file.arrayBuffer())
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
throw new Error(`Cannot read spreadsheet file: ${filepath} is not a valid XLSX workbook`)
}
try {
const book = read(bytes, { type: "array", cellDates: true })
return Readable.from(lines(book))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Cannot read spreadsheet file: ${filepath}: ${message}`, { cause: err })
}
}
function cell(value: CellObject | undefined) {
if (!value) return ""
if (value.f) {
if (value.w !== undefined && value.w !== null) return value.w
if (value.v !== undefined && value.v !== null) return String(value.v)
return `[Formula: ${value.f}]`
}
if (value.v === undefined || value.v === null) return ""
if (value.t === "e") return `[Error: ${value.w ?? String(value.v)}]`
if (value.t === "d") return value.v instanceof Date ? value.v.toISOString().slice(0, 10) : String(value.v)
if (value.l?.Target) return `${value.w ?? String(value.v)} (${value.l.Target})`
return value.w ?? String(value.v)
}
function* lines(book: WorkBook) {
const sheets = book.SheetNames.filter((_, index) => {
const hidden = book.Workbook?.Sheets?.[index]?.Hidden
return hidden !== 1 && hidden !== 2
})
for (const [index, name] of sheets.entries()) {
if (index > 0) yield "\n"
yield `--- Sheet: ${name} ---\n`
const sheet = book.Sheets[name]
if (!sheet?.["!ref"]) continue
const range = utils.decode_range(sheet["!ref"])
const end = Math.min(range.e.r, ROW_LIMIT - 1)
const rows = new Map<number, Map<number, string>>()
for (const key of Object.keys(sheet)) {
if (key.startsWith("!")) continue
const pos = utils.decode_cell(key)
if (pos.r < range.s.r || pos.r > end || pos.c < range.s.c || pos.c > range.e.c) continue
const value = cell(sheet[key])
if (!value.trim()) continue
const row = rows.get(pos.r) ?? new Map<number, string>()
row.set(pos.c, value)
rows.set(pos.r, row)
}
for (const values of [...rows.entries()].sort((a, b) => a[0] - b[0]).map((entry) => entry[1])) {
const last = Math.max(...values.keys())
const row = Array.from({ length: last - range.s.c + 1 }, (_, col) => values.get(col + range.s.c) ?? "")
yield row.join("\t") + "\n"
}
if (range.e.r > end) yield `[... truncated at row ${ROW_LIMIT} ...]\n`
}
}
+8 -14
View File
@@ -7,7 +7,6 @@ import { zod } from "@/util/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
import { withStatics } from "@/util/schema"
import { QuestionID } from "./schema"
import { makeRuntime } from "@/effect/run-service" // kilocode_change
import { KiloQuestion } from "@/kilocode/question" // kilocode_change
const log = Log.create({ service: "question" })
@@ -134,6 +133,10 @@ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Que
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
requestID: QuestionID,
}) {}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
@@ -152,8 +155,8 @@ export interface Interface {
blocking?: boolean // kilocode_change
tool?: Tool
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void, NotFoundError>
readonly reject: (requestID: QuestionID) => Effect.Effect<void, NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly dismissAll: (sessionID: SessionID) => Effect.Effect<void> // kilocode_change
}
@@ -225,7 +228,7 @@ export const layer = Layer.effect(
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
return yield* new NotFoundError({ requestID: input.requestID })
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
@@ -242,7 +245,7 @@ export const layer = Layer.effect(
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
return yield* new NotFoundError({ requestID })
}
pending.delete(requestID)
log.info("rejected", { requestID })
@@ -273,13 +276,4 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
// kilocode_change start - legacy promise helpers for Kilo callsites
const { runPromise } = makeRuntime(Service, defaultLayer)
export const list = () => runPromise((svc) => svc.list())
export const ask = (input: Parameters<Interface["ask"]>[0]) => runPromise((svc) => svc.ask(input))
export const reply = (input: Parameters<Interface["reply"]>[0]) => runPromise((svc) => svc.reply(input))
export const reject = (requestID: QuestionID) => runPromise((svc) => svc.reject(requestID))
export const dismissAll = (sessionID: string) => runPromise((svc) => svc.dismissAll(SessionID.make(sessionID)))
// kilocode_change end
export * as Question from "."
@@ -1,7 +1,7 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" // kilocode_change - map Question missing requests to declared 404 errors
import { InstanceHttpApi } from "../api"
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
@@ -16,15 +16,21 @@ export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question"
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
// kilocode_change start - map missing Question requests to the declared transport error
yield* svc
.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
.pipe(Effect.mapError(() => new HttpApiError.NotFound({})))
// kilocode_change end
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
// kilocode_change start - map missing Question requests to the declared transport error
yield* svc.reject(ctx.params.requestID).pipe(Effect.mapError(() => new HttpApiError.NotFound({})))
// kilocode_change end
return true
})
@@ -1,8 +1,10 @@
import { Effect } from "effect" // kilocode_change - translate Question not-found failures for the legacy route
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
import { Question } from "@/question"
import { NotFoundError } from "@/storage/storage" // kilocode_change - expose upstream Question not-found behavior on legacy routes
import z from "zod"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
@@ -69,10 +71,18 @@ export const QuestionRoutes = lazy(() =>
const params = c.req.valid("param")
const json = c.req.valid("json")
const svc = yield* Question.Service
yield* svc.reply({
requestID: params.requestID,
answers: json.answers,
})
// kilocode_change start - preserve documented 404 for unknown requests
yield* svc
.reply({
requestID: params.requestID,
answers: json.answers,
})
.pipe(
Effect.mapError(
() => new NotFoundError({ message: `Question request not found: ${params.requestID}` }),
),
)
// kilocode_change end
return true
}),
)
@@ -104,7 +114,15 @@ export const QuestionRoutes = lazy(() =>
jsonRequest("QuestionRoutes.reject", c, function* () {
const params = c.req.valid("param")
const svc = yield* Question.Service
yield* svc.reject(params.requestID)
// kilocode_change start - preserve documented 404 for unknown requests
yield* svc
.reject(params.requestID)
.pipe(
Effect.mapError(
() => new NotFoundError({ message: `Question request not found: ${params.requestID}` }),
),
)
// kilocode_change end
return true
}),
),
+3 -1
View File
@@ -117,6 +117,7 @@ export const layer = Layer.effect(
const commands = yield* Command.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const question = yield* Question.Service // kilocode_change - dismiss superseded pending questions through the shared service
const fsys = yield* AppFileSystem.Service
const mcp = yield* MCP.Service
const lsp = yield* LSP.Service
@@ -1440,7 +1441,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
// runLoop checks hasFollowup between steps to break out once it has been
// enqueued during the turn.
yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID))
yield* Effect.promise(() => Question.dismissAll(input.sessionID))
yield* question.dismissAll(input.sessionID)
if (input.noReply === true) return message
return yield* KiloSessionPromptQueue.enqueue(
input.sessionID,
@@ -2009,6 +2010,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Question.defaultLayer), // kilocode_change - provide pending question dismissal dependency
Layer.provide(MCP.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
+3 -5
View File
@@ -4,7 +4,7 @@ import { SyncEvent } from "@/sync"
import { Effect, Layer, Scope, Context } from "effect"
import { Config } from "@/config/config"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as ShareNext from "./share-next"
import { KiloSession } from "@/kilocode/session" // kilocode_change
export interface Interface {
readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>
@@ -19,20 +19,19 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const cfg = yield* Config.Service
const session = yield* Session.Service
const shareNext = yield* ShareNext.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
const conf = yield* cfg.get()
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* shareNext.create(sessionID)
const result = yield* KiloSession.shareSession(sessionID) // kilocode_change - use Kilo public share URLs
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } })
return result
})
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
yield* shareNext.remove(sessionID)
yield* KiloSession.unshareSession(sessionID) // kilocode_change - use Kilo public share URLs
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })
})
@@ -50,7 +49,6 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(ShareNext.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
+6 -3
View File
@@ -15,7 +15,7 @@ import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
// kilocode_change start
import * as Encoding from "../kilocode/encoding"
import * as TextStream from "../kilocode/text-stream"
import * as Notebook from "../kilocode/tool/notebook"
import * as Extract from "../kilocode/tool/read-extract"
// kilocode_change end
const DEFAULT_READ_LIMIT = 2000
@@ -301,7 +301,8 @@ export const ReadTool = Tool.define(
}
}
if (isBinaryFile(filepath, sample)) {
// kilocode_change start - route extractable binary documents through lines()
if (!Extract.binary(filepath) && isBinaryFile(filepath, sample)) {
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}
@@ -313,6 +314,7 @@ export const ReadTool = Tool.define(
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
)
}
// kilocode_change end
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>\n"].join("\n")
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
@@ -359,7 +361,8 @@ export const ReadTool = Tool.define(
// routed through TextStream.withFallback so non-UTF-8 files are decoded via
// iconv. The body otherwise matches upstream.
export async function lines(filepath: string, opts: { limit: number; offset: number }) {
if (Notebook.isFile(filepath)) return readLines(await Notebook.open(filepath), opts) // kilocode_change - extract readable notebook cells before paging
const extracted = await Extract.open(filepath) // kilocode_change - extract supported document contents before paging
if (extracted) return readLines(extracted, opts) // kilocode_change
return TextStream.withFallback(filepath, (stream) => readLines(stream, opts))
}
@@ -7,8 +7,7 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { PlanFollowup } from "../../src/kilocode/plan-followup"
import { Question } from "../../src/question"
import { PlanFollowup, PlanFollowupRuntime } from "../../src/kilocode/plan-followup"
import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
@@ -123,7 +122,7 @@ async function seed(input: {
async function waitQuestion(sessionID: string) {
for (let i = 0; i < 50; i++) {
const list = await Question.list()
const list = await PlanFollowupRuntime.question.list()
const question = list.find((item) => item.sessionID === sessionID)
if (question) return question
await Bun.sleep(10)
@@ -155,7 +154,7 @@ describe("plan_exit detection", () => {
expect(question).toBeDefined()
if (!question) return
expect(question.questions[0].header).toBe("Implement")
await Question.reject(question.id)
await PlanFollowupRuntime.question.reject(question.id)
await expect(pending).resolves.toBe("break")
}))
@@ -194,7 +193,7 @@ describe("plan_exit detection", () => {
PlanFollowup.ANSWER_CONTINUE,
])
expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe("code")
await Question.reject(question.id)
await PlanFollowupRuntime.question.reject(question.id)
await expect(pending).resolves.toBe("break")
} finally {
if (prev === undefined) delete process.env.KILO_CLIENT
@@ -224,7 +223,7 @@ describe("plan_exit detection", () => {
const question = await waitQuestion(seeded.sessionID)
expect(question).toBeDefined()
if (!question) return
await Question.reply({
await PlanFollowupRuntime.question.reply({
requestID: question.id,
answers: [[PlanFollowup.ANSWER_CONTINUE]],
})
@@ -247,7 +246,7 @@ describe("plan_exit detection", () => {
text: "Here is a partial plan, I have questions",
})
expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(false)
const list = await Question.list()
const list = await PlanFollowupRuntime.question.list()
expect(list).toHaveLength(0)
}))
@@ -326,7 +325,7 @@ describe("plan_exit detection", () => {
expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
// Confirm no questions were posted
const list = await Question.list()
const list = await PlanFollowupRuntime.question.list()
expect(list).toHaveLength(0)
}))
@@ -440,7 +439,7 @@ describe("plan_exit detection", () => {
const question = await waitQuestion(seeded.sessionID)
expect(question).toBeDefined()
if (!question) return
await Question.reply({
await PlanFollowupRuntime.question.reply({
requestID: question.id,
answers: [[PlanFollowup.ANSWER_CONTINUE]],
})
@@ -543,7 +542,7 @@ describe("plan_exit detection", () => {
expect(question).toBeDefined()
if (!question) return
expect(question.questions[0].header).toBe("Implement")
await Question.reply({
await PlanFollowupRuntime.question.reply({
requestID: question.id,
answers: [[PlanFollowup.ANSWER_CONTINUE]],
})
@@ -36,9 +36,9 @@ describe("prompt.ts Kilo-specific invariants", () => {
// an in-flight handle.process blocked on a pending tool prompt can return.
// Critically, the block must NOT call state.cancel or KiloSessionPromptQueue.reserve —
// either of those would abort the running streamText mid-tokens, which was
// the #9332 regression. Order: dismissAll(Suggestion) → dismissAll(Question) → enqueue.
// the #9332 regression. Order: dismissAll(Suggestion), question.dismissAll, enqueue.
const block = content.match(
/kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue/,
/kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue/,
)
expect(block).not.toBeNull()
expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/)
@@ -1,174 +1,119 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue"
import { WithInstance } from "../../src/project/with-instance"
import { Question } from "../../src/question"
import { MessageID, SessionID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
const prompt = [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go" },
{ label: "No", description: "Stop" },
],
},
]
const waitFor = (question: Question.Interface, count: number) =>
Effect.gen(function* () {
for (let i = 0; i < 50; i++) {
const pending = yield* question.list()
if (pending.length >= count) return pending
yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`))
})
describe("Question.dismissAll", () => {
test("rejects pending asks for the target session and clears them", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
it.instance(
"rejects pending asks for the target session and clears them",
() =>
Effect.gen(function* () {
const question = yield* Question.Service
const sesA = SessionID.make("ses_a")
const sesB = SessionID.make("ses_b")
const a1 = yield* question.ask({ sessionID: sesA, questions: prompt }).pipe(Effect.forkScoped)
const a2 = yield* question.ask({ sessionID: sesA, questions: prompt }).pipe(Effect.forkScoped)
const b1 = yield* question.ask({ sessionID: sesB, questions: prompt }).pipe(Effect.forkScoped)
const a1 = Question.ask({
sessionID: sesA,
questions: [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go" },
{ label: "No", description: "Stop" },
],
},
],
}).catch((err) => {
if (err instanceof Question.RejectedError) return "rejected"
throw err
})
expect(yield* waitFor(question, 3)).toHaveLength(3)
yield* question.dismissAll(sesA)
const a2 = Question.ask({
sessionID: sesA,
questions: [
{
header: "Retry?",
question: "Try again?",
options: [
{ label: "Retry", description: "Retry" },
{ label: "Cancel", description: "Cancel" },
],
},
],
}).catch((err) => {
if (err instanceof Question.RejectedError) return "rejected"
throw err
})
const b1 = Question.ask({
sessionID: sesB,
questions: [
{
header: "Deploy?",
question: "Deploy now?",
options: [
{ label: "Ship", description: "Ship" },
{ label: "Wait", description: "Wait" },
],
},
],
}).catch((err) => {
if (err instanceof Question.RejectedError) return "rejected-b"
throw err
})
// Wait for all three asks to register so we can dismiss them.
for (let i = 0; i < 50; i++) {
if ((await Question.list()).length >= 3) break
await Bun.sleep(10)
for (const fiber of [a1, a2]) {
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
}
expect(await Question.list()).toHaveLength(3)
// Track whether B's promise settles.
let settled = false
b1.then(() => {
settled = true
})
yield* Effect.sleep("10 millis")
await Question.dismissAll("ses_a")
expect(await a1).toBe("rejected")
expect(await a2).toBe("rejected")
await new Promise((r) => setTimeout(r, 10))
expect(settled).toBe(false)
const remaining = await Question.list()
const remaining = yield* question.list()
expect(remaining).toHaveLength(1)
expect(remaining[0]?.sessionID).toBe(sesB)
await Question.reject(remaining[0]!.id)
expect(await b1).toBe("rejected-b")
},
})
})
yield* question.reject(remaining[0]!.id)
const exit = yield* Fiber.await(b1)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
}),
{ git: true },
)
test("is a no-op when no questions exist", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Question.dismissAll("ses_missing")
expect(await Question.list()).toEqual([])
},
})
})
it.instance(
"is a no-op when no questions exist",
() =>
Effect.gen(function* () {
const question = yield* Question.Service
yield* question.dismissAll(SessionID.make("ses_missing"))
expect(yield* question.list()).toEqual([])
}),
{ git: true },
)
test("ask rejects immediately when a followup is queued on the session", async () => {
// When a newer prompt has already been enqueued on the session, a tool
// that subsequently calls Question.ask would otherwise block the run until
// the user manually dismisses it. Verify the pre-emptive hasFollowup check
// rejects with RejectedError before any pending entry is registered.
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
it.instance(
"ask rejects immediately when a followup is queued on the session",
() =>
Effect.gen(function* () {
const question = yield* Question.Service
const sessionID = SessionID.make("ses_auto_ask")
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
// Slot 1 stays running so activeSince is pinned to its seq.
const first = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_ask_1"),
Effect.gen(function* () {
started.resolve()
yield* Effect.promise(() => release.promise)
return "first" as const
}),
Effect.succeed("first-cancelled" as const),
),
)
await started.promise
const first = yield* KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_ask_1"),
Effect.gen(function* () {
started.resolve()
yield* Effect.promise(() => release.promise)
return "first" as const
}),
Effect.succeed("first-cancelled" as const),
).pipe(Effect.forkScoped)
yield* Effect.promise(() => started.promise)
// Slot 2 arrives while slot 1 is active — latest > activeSince.
const second = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_ask_2"),
Effect.succeed("second" as const),
Effect.succeed("second-cancelled" as const),
),
)
await Bun.sleep(10)
const second = yield* KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_ask_2"),
Effect.succeed("second" as const),
Effect.succeed("second-cancelled" as const),
).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis")
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
await expect(
Question.ask({
sessionID,
questions: [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go" },
{ label: "No", description: "Stop" },
],
},
],
}),
).rejects.toBeInstanceOf(Question.RejectedError)
expect(await Question.list()).toEqual([])
const exit = yield* question.ask({ sessionID, questions: prompt }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
expect(yield* question.list()).toEqual([])
release.resolve()
expect(await first).toBe("first")
expect(await second).toBe("second")
},
})
})
expect(yield* Fiber.join(first)).toBe("first")
expect(yield* Fiber.join(second)).toBe("second")
}),
{ git: true },
)
})
@@ -0,0 +1,199 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "../../src/lsp/lsp"
import { Instruction } from "../../src/session/instruction"
import { MessageID, SessionID } from "../../src/session/schema"
import { ReadTool } from "../../src/tool/read"
import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx: Tool.Context = {
sessionID: SessionID.make("ses_test-docx"),
messageID: MessageID.make(""),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const expanded: Tool.Context = { ...ctx, extra: { includeDirectoryFiles: true } }
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
),
)
const init = Effect.fn("ReadDocxTest.init")(function* () {
const info = yield* ReadTool
return yield* Tool.init(info)
})
const run = Effect.fn("ReadDocxTest.run")(function* (
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* tool.execute(args, next)
})
const exec = Effect.fn("ReadDocxTest.exec")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
return yield* provideInstance(dir)(run(args, next))
})
const fail = Effect.fn("ReadDocxTest.fail")(function* (dir: string, args: Tool.InferParameters<typeof ReadTool>) {
const exit = yield* exec(dir, args).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected read to fail")
})
const put = Effect.fn("ReadDocxTest.put")(function* (filepath: string, content: string | Uint8Array) {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(filepath, content)
})
const document = async (paragraphs: string[], extra = "") => {
const writer = new ZipWriter(new Uint8ArrayWriter())
await writer.add(
"[Content_Types].xml",
new TextReader(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
"</Types>",
),
)
await writer.add(
"_rels/.rels",
new TextReader(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
"</Relationships>",
),
)
await writer.add(
"word/document.xml",
new TextReader(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>' +
paragraphs.map((text) => `<w:p><w:r><w:t>${text}</w:t></w:r></w:p>`).join("") +
extra +
"</w:body></w:document>",
),
)
return writer.close()
}
describe("kilocode DOCX reads", () => {
it.live("extracts paragraph text from .docx and .DOCX files", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const bytes = yield* Effect.promise(() => document(["First paragraph", "Second paragraph"]))
for (const ext of ["docx", "DOCX"]) {
const filepath = path.join(dir, `sample.${ext}`)
yield* put(filepath, bytes)
const result = yield* exec(dir, { filePath: filepath })
expect(result.output).toContain("1: First paragraph")
expect(result.output).toContain("Second paragraph")
expect(result.attachments).toBeUndefined()
}
}),
)
it.live("applies normal read pagination to extracted text", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "paged.docx")
yield* put(filepath, yield* Effect.promise(() => document(["First paragraph", "Second paragraph"])))
const result = yield* exec(dir, { filePath: filepath, limit: 1 })
expect(result.output).toContain("1: First paragraph")
expect(result.output).not.toContain("Second paragraph")
expect(result.output).toContain("Use offset=2")
expect(result.metadata.truncated).toBe(true)
}),
)
it.live("fails clearly for malformed DOCX files", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "invalid.docx")
yield* put(filepath, new Uint8Array([0x50, 0x4b, 0x03, 0x04]))
const err = yield* fail(dir, { filePath: filepath })
expect(err.message).toContain("Failed to extract text from DOCX file")
expect(err.message).toContain(filepath)
}),
)
it.live("includes extraction warnings for unsupported document elements", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const filepath = path.join(dir, "warning.docx")
yield* put(filepath, yield* Effect.promise(() => document(["Readable text"], "<w:unsupported/>")))
const result = yield* exec(dir, { filePath: filepath })
expect(result.output).toContain("Readable text")
expect(result.output).toContain("DOCX extraction warnings")
}),
)
it.live("does not expand DOCX content in directory reads", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const folder = path.join(dir, "folder")
yield* put(path.join(folder, "sample.docx"), yield* Effect.promise(() => document(["Hidden paragraph"])))
const result = yield* exec(dir, { filePath: folder }, expanded)
expect(result.output).toContain("sample.docx")
expect(result.output).not.toContain("Hidden paragraph")
}),
)
it.live("preserves PDF attachments and rejects unsupported binary files", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const pdf = path.join(dir, "sample.pdf")
const doc = path.join(dir, "sample.doc")
yield* put(pdf, "%PDF-1.7\nfixture")
yield* put(doc, new Uint8Array([0x00, 0x01, 0x02]))
const result = yield* exec(dir, { filePath: pdf })
const err = yield* fail(dir, { filePath: doc })
expect(result.output).toBe("PDF read successfully")
expect(result.attachments?.[0].mime).toBe("application/pdf")
expect(err.message).toContain("Cannot read binary file")
}),
)
})
@@ -0,0 +1,240 @@
import { Cause, Effect, Exit, Layer } from "effect"
import { describe, expect } from "bun:test"
import { truncate } from "fs/promises"
import path from "path"
import { write, utils, type WorkBook, type WorkSheet } from "xlsx"
import { TextReader, TextWriter, Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter } from "@zip.js/zip.js"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { LSP } from "../../src/lsp/lsp"
import { Instruction } from "../../src/session/instruction"
import { MessageID, SessionID } from "../../src/session/schema"
import { ReadTool } from "../../src/tool/read"
import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
),
)
const run = Effect.fn("XlsxReadTest.run")(function* (
dir: string,
file: string,
opts: { limit?: number; offset?: number } = {},
) {
const info = yield* ReadTool
const tool = yield* Tool.init(info)
return yield* provideInstance(dir)(tool.execute({ filePath: file, ...opts }, ctx))
})
const fail = Effect.fn("XlsxReadTest.fail")(function* (dir: string, file: string) {
const exit = yield* run(dir, file).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected read to fail")
})
const put = Effect.fn("XlsxReadTest.put")(function* (file: string, bytes: Uint8Array | string) {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(file, bytes)
})
function bytes(book: WorkBook) {
return new Uint8Array(write(book, { bookType: "xlsx", type: "buffer" }) as Uint8Array)
}
async function range(bytes: Uint8Array) {
const reader = new ZipReader(new Uint8ArrayReader(bytes))
const output = new ZipWriter(new Uint8ArrayWriter())
for (const entry of await reader.getEntries()) {
if (entry.directory) {
await output.add(entry.filename)
continue
}
if (entry.filename === "xl/worksheets/sheet1.xml") {
const xml = await entry.getData!(new TextWriter())
const sheet = xml
.replace(/ref="A1"/, 'ref="A1:XFD50001"')
.replace("</sheetData>", '<row r="50001"><c r="A50001" t="str"><v>last</v></c></row></sheetData>')
await output.add(entry.filename, new TextReader(sheet))
continue
}
await output.add(entry.filename, new Uint8ArrayReader(await entry.getData!(new Uint8ArrayWriter())))
}
await reader.close()
return output.close()
}
function book(sheet: WorkSheet, name = "Visible") {
const value = utils.book_new()
utils.book_append_sheet(value, sheet, name)
return value
}
describe("kilocode XLSX reads", () => {
it.live("extracts labelled formatted content from case-variant XLSX files", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const sheet: WorkSheet = {
A1: { t: "s", v: "Link", l: { Target: "https://kilo.ai" } },
B1: { t: "d", v: new Date("2026-05-29T00:00:00.000Z") },
C1: { t: "n", v: 42, f: "SUM(40,2)" },
D1: { t: "e", v: 0x07, w: "#DIV/0!" },
C2: { t: "n", f: "SUM(C1:C1)" },
A4: { t: "s", v: "After blank row" },
"!ref": "A1:D4",
}
const file = path.join(dir, "report.XLSX")
yield* put(file, bytes(book(sheet)))
const result = yield* run(dir, file)
expect(result.output).toContain("--- Sheet: Visible ---")
expect(result.output).toContain("Link (https://kilo.ai)")
expect(result.output).toContain("2026-05-29")
expect(result.output).toContain("42")
expect(result.output).toContain("[Formula: SUM(C1:C1)]")
expect(result.output).toContain("[Error: #DIV/0!]")
expect(result.output).toContain("After blank row")
expect(result.attachments).toBeUndefined()
}),
)
it.live("omits hidden and very-hidden worksheets", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const value = book(utils.aoa_to_sheet([["Visible content"]]))
utils.book_append_sheet(value, utils.aoa_to_sheet([["Hidden content"]]), "Hidden")
utils.book_append_sheet(value, utils.aoa_to_sheet([["Secret content"]]), "Secret")
value.Workbook = { Sheets: [{ Hidden: 0 }, { Hidden: 1 }, { Hidden: 2 }] }
const file = path.join(dir, "sheets.xlsx")
yield* put(file, bytes(value))
const result = yield* run(dir, file)
expect(result.output).toContain("Visible content")
expect(result.output).not.toContain("Hidden content")
expect(result.output).not.toContain("Secret content")
}),
)
it.live("caps worksheet extraction rows before ordinary read limits", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const sheet = utils.aoa_to_sheet(Array.from({ length: 50_001 }, (_, row) => [`row-${row + 1}`]))
const file = path.join(dir, "large.xlsx")
yield* put(file, bytes(book(sheet)))
const result = yield* run(dir, file, { offset: 49_999, limit: 4 })
expect(result.output).toContain("row-50000")
expect(result.output).not.toContain("row-50001")
expect(result.output).toContain("[... truncated at row 50000 ...]")
}),
)
it.live("applies ordinary read line limits to spreadsheet text", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const file = path.join(dir, "limited.xlsx")
yield* put(file, bytes(book(utils.aoa_to_sheet([["one"], ["two"], ["three"]]))))
const result = yield* run(dir, file, { limit: 2 })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("1: --- Sheet: Visible ---")
expect(result.output).toContain("2: one")
expect(result.output).not.toContain("3: two")
}),
)
it.live("does not traverse every blank cell in a sparse wide range", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const file = path.join(dir, "sparse.xlsx")
const source = bytes(book(utils.aoa_to_sheet([["first"]])))
yield* put(file, yield* Effect.promise(() => range(source)))
const result = yield* run(dir, file)
expect(result.output).toContain("--- Sheet: Visible ---")
expect(result.output).toContain("first")
expect(result.output).toContain("[... truncated at row 50000 ...]")
}),
)
it.live("fails clearly for invalid spreadsheet input", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const file = path.join(dir, "invalid.xlsx")
yield* put(file, "not an xlsx workbook")
const err = yield* fail(dir, file)
expect(err.message).toContain("Cannot read spreadsheet file")
expect(err.message).toContain("not a valid XLSX workbook")
}),
)
it.live("rejects spreadsheets larger than the parser input limit", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const file = path.join(dir, "large-input.xlsx")
yield* put(file, new Uint8Array([0x50, 0x4b]))
yield* Effect.promise(() => truncate(file, 50 * 1024 * 1024 + 1))
const err = yield* fail(dir, file)
expect(err.message).toContain("Cannot read spreadsheet file")
expect(err.message).toContain("exceeds the 50 MB size limit")
}),
)
it.live("continues rejecting unsupported workbook formats as binary", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const file = path.join(dir, "legacy.xls")
yield* put(file, bytes(book(utils.aoa_to_sheet([["ignored"]]))))
const err = yield* fail(dir, file)
expect(err.message).toContain("Cannot read binary file")
}),
)
it.live("continues returning PDF files as native attachments", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const file = path.join(dir, "native.pdf")
yield* put(file, "%PDF-1.7\n")
const result = yield* run(dir, file)
expect(result.output).toBe("PDF read successfully")
expect(result.attachments?.[0].mime).toBe("application/pdf")
}),
)
})
@@ -165,6 +165,7 @@ function makeHttp() {
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provideMerge(question), // kilocode_change - SessionPrompt now dismisses questions via its service dependency
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
@@ -158,6 +158,7 @@ function makeHttp() {
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provideMerge(question), // kilocode_change - SessionPrompt now dismisses questions via its service dependency
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
@@ -4,7 +4,6 @@ import { Effect } from "effect"
import { Bus } from "../../src/bus"
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
import { Suggestion } from "../../src/kilocode/suggestion"
import { Question } from "../../src/question"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Session } from "../../src/session/session"
@@ -662,62 +661,6 @@ describe("session prompt queue", () => {
})
})
test("new prompt dismisses a pending question", async () => {
const asked = Promise.withResolvers<void>()
const rejected = Promise.withResolvers<void>()
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () =>
scoped(tmp.path, async (prompt) => {
const session = await sessions.create({ title: "Question unblock regression" })
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
if (event.properties.sessionID === session.id) asked.resolve()
})
const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => {
if (event.properties.sessionID === session.id) rejected.resolve()
})
try {
const pending = Question.ask({
sessionID: session.id,
questions: [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go ahead" },
{ label: "No", description: "Stop" },
],
},
],
}).catch((err) => {
if (err instanceof Question.RejectedError) return "rejected"
throw err
})
await asked.promise
await Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "code",
parts: [{ type: "text", text: "replacement prompt" }],
noReply: true,
}),
)
await rejected.promise
expect(await pending).toBe("rejected")
expect(await Question.list()).toEqual([])
} finally {
offAsked()
offRejected()
}
}),
})
})
test("auto-dismisses a suggestion shown after a queued prompt", async () => {
// Reverse ordering of the "new prompt dismisses a pending suggestion" test:
// queue the follow-up first, then open the blocker. Suggestion.show must see
@@ -783,70 +726,4 @@ describe("session prompt queue", () => {
})
})
test("auto-dismisses a question shown after a queued prompt", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_question")
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const first = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_auto_q_1"),
Effect.gen(function* () {
started.resolve()
yield* Effect.promise(() => release.promise)
return "first" as const
}),
Effect.succeed("first-cancelled" as const),
),
)
await started.promise
const second = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_auto_q_2"),
Effect.succeed("second" as const),
Effect.succeed("second-cancelled" as const),
),
)
await Bun.sleep(10)
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
let asked = 0
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
if (event.properties.sessionID === sessionID) asked++
})
try {
await expect(
Question.ask({
sessionID,
questions: [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go ahead" },
{ label: "No", description: "Stop" },
],
},
],
}),
).rejects.toBeInstanceOf(Question.RejectedError)
} finally {
offAsked()
}
expect(asked).toBe(0)
expect(await Question.list()).toEqual([])
release.resolve()
expect(await first).toBe("first")
expect(await second).toBe("second")
},
})
})
})
@@ -0,0 +1,63 @@
import { expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Session } from "../../src/session/session"
import { SessionShare } from "../../src/share/session"
import { Storage } from "../../src/storage/storage"
import { SyncEvent } from "../../src/sync"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Auth.defaultLayer, Storage.defaultLayer, CrossSpawnSpawner.defaultLayer))
const layer = SessionShare.layer.pipe(
Layer.provideMerge(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
)
it.instance("shares and unshares sessions through Kilo public URLs", () => {
const urls: string[] = []
const fetch: typeof globalThis.fetch = Object.assign(
async (input: RequestInfo | URL) => {
const url = String(input)
urls.push(url)
if (url.endsWith("/api/user")) return new Response("{}", { status: 200 })
if (url.endsWith("/share")) return Response.json({ public_id: "public-1" })
if (url.endsWith("/unshare")) return new Response(null, { status: 200 })
return new Response("{}", { status: 200 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const request = spyOn(globalThis, "fetch").mockImplementation(fetch)
return Effect.gen(function* () {
const auth = yield* Auth.Service
const share = yield* SessionShare.Service
const session = yield* Session.Service
const storage = yield* Storage.Service
yield* auth.set("kilo", { type: "api", key: "test-token" })
const info = yield* share.create({ title: "share-test" })
yield* storage.write(["session_share", info.id], { id: "remote-1", ingestPath: "/api/ingest/session-1" })
const result = yield* share.share(info.id)
expect(result.url).toBe("https://app.kilo.ai/s/public-1")
expect((yield* session.get(info.id)).share?.url).toBe("https://app.kilo.ai/s/public-1")
yield* share.unshare(info.id)
expect((yield* session.get(info.id)).share).toBeUndefined()
expect(urls.some((url) => url.endsWith(`/api/session/${info.id}/share`))).toBe(true)
expect(urls.some((url) => url.endsWith(`/api/session/${info.id}/unshare`))).toBe(true)
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove("kilo").pipe(Effect.ignore)
request.mockRestore()
}),
),
Effect.provide(layer),
)
})
@@ -6,6 +6,7 @@ import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws"
import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol"
import type { SessionPrompt } from "../../../src/session/prompt"
import { Question } from "../../../src/question"
import { QuestionID } from "../../../src/question/schema"
import { Permission } from "../../../src/permission"
import { PermissionID } from "../../../src/permission/schema"
import { ModelID, ProviderID } from "../../../src/provider/schema"
@@ -57,6 +58,14 @@ function permissions(items: Permission.Request[] = []) {
}
}
function questions(items: Question.Request[] = []) {
return {
list: async () => items,
reply: async (_input: Parameters<Question.Interface["reply"]>[0]) => {},
reject: async (_requestID: QuestionID) => {},
}
}
function prompts(calls: SessionPrompt.PromptInput[]) {
return async (input: SessionPrompt.PromptInput) => {
calls.push(input)
@@ -461,15 +470,18 @@ describe("RemoteSender", () => {
test("question_reply sends response after work completes", async () => {
const { conn, sent } = fakeConn()
let provideCalled = false
const calls: Parameters<Question.Interface["reply"]>[0][] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async () => {
provideCalled = true
return {} as any
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
question: {
...questions(),
reply: async (input) => {
calls.push(input)
},
},
})
@@ -480,12 +492,12 @@ describe("RemoteSender", () => {
data: { requestID: "r1", answers: [["yes"]] },
})
// Response not sent synchronously waits for provide to finish
// Response not sent synchronously - waits for provide to finish.
expect(sent).toHaveLength(0)
await new Promise((r) => setTimeout(r, 10))
expect(provideCalled).toBe(true)
expect(calls).toEqual([{ requestID: QuestionID.make("r1"), answers: [["yes"]] }])
expect(sent).toHaveLength(1)
expect(sent[0]).toEqual({ type: "response", id: "req_q", result: {} })
})
@@ -528,8 +540,12 @@ describe("RemoteSender", () => {
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async () => {
throw new Error("boom")
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
question: {
...questions(),
reply: async () => {
throw new Error("boom")
},
},
})
@@ -548,6 +564,37 @@ describe("RemoteSender", () => {
expect(sent[0].error).toContain("boom")
})
test("question_reply reports unknown request errors", async () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
question: {
...questions(),
reply: async (input) => {
throw new Question.NotFoundError({ requestID: input.requestID })
},
},
})
sender.handle({
type: "command",
id: "req_q_missing",
command: "question_reply",
data: { requestID: "missing", answers: [["yes"]] },
})
await new Promise((r) => setTimeout(r, 10))
expect(sent).toHaveLength(1)
expect(sent[0].type).toBe("response")
expect(sent[0].id).toBe("req_q_missing")
expect(sent[0].error).toContain("Question.NotFoundError")
})
test("suggestion_accept sends response after work completes", async () => {
const { conn, sent } = fakeConn()
const accept = spyOn(Suggestion, "accept").mockResolvedValue(true)
@@ -595,15 +642,18 @@ describe("RemoteSender", () => {
test("question_reject sends response after work completes", async () => {
const { conn, sent } = fakeConn()
let provideCalled = false
const calls: QuestionID[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async () => {
provideCalled = true
return {} as any
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
question: {
...questions(),
reject: async (requestID) => {
calls.push(requestID)
},
},
})
@@ -616,11 +666,42 @@ describe("RemoteSender", () => {
await new Promise((r) => setTimeout(r, 10))
expect(provideCalled).toBe(true)
expect(calls).toEqual([QuestionID.make("r1")])
expect(sent).toHaveLength(1)
expect(sent[0]).toEqual({ type: "response", id: "req_qr", result: {} })
})
test("question_reject reports unknown request errors", async () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
question: {
...questions(),
reject: async (requestID) => {
throw new Question.NotFoundError({ requestID })
},
},
})
sender.handle({
type: "command",
id: "req_qr_missing",
command: "question_reject",
data: { requestID: "missing" },
})
await new Promise((r) => setTimeout(r, 10))
expect(sent).toHaveLength(1)
expect(sent[0].type).toBe("response")
expect(sent[0].id).toBe("req_qr_missing")
expect(sent[0].error).toContain("Question.NotFoundError")
})
test("question_reject with invalid data sends error response", () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
@@ -917,10 +998,6 @@ describe("RemoteSender", () => {
const bus = fakeBus()
spyOn(Suggestion, "list").mockResolvedValue([])
spyOn(Question, "list").mockResolvedValue([
{ id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any,
{ id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any,
])
const sender = RemoteSender.create({
conn,
@@ -929,6 +1006,10 @@ describe("RemoteSender", () => {
subscribe: bus.subscribe,
provide: async (input: any) => input.fn(),
permission: permissions(),
question: questions([
{ id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any,
{ id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any,
]),
})
sender.handle({ type: "subscribe", sessionId: "ses_target" })
@@ -949,7 +1030,6 @@ describe("RemoteSender", () => {
const bus = fakeBus()
spyOn(Suggestion, "list").mockResolvedValue([])
spyOn(Question, "list").mockResolvedValue([])
const sender = RemoteSender.create({
conn,
@@ -957,6 +1037,7 @@ describe("RemoteSender", () => {
log: nolog,
subscribe: bus.subscribe,
provide: async (input: any) => input.fn(),
question: questions(),
permission: permissions([
{
id: "permission_1",
@@ -1004,7 +1085,6 @@ describe("RemoteSender", () => {
spyOn(Suggestion, "list").mockResolvedValue([
{ id: "sug_1", sessionID: "ses_other", text: "Review?", actions: [] } as any,
])
spyOn(Question, "list").mockResolvedValue([{ id: "question_1", sessionID: "ses_other", questions: [] } as any])
const sender = RemoteSender.create({
conn,
@@ -1012,6 +1092,7 @@ describe("RemoteSender", () => {
log: nolog,
subscribe: bus.subscribe,
provide: async (input: any) => input.fn(),
question: questions([{ id: "question_1", sessionID: "ses_other", questions: [] } as any]),
permission: permissions([
{
id: "permission_1",
@@ -1049,7 +1130,6 @@ describe("RemoteSender", () => {
actions: [{ label: "Skip", prompt: "skip" }],
} as any,
])
spyOn(Question, "list").mockResolvedValue([])
const sender = RemoteSender.create({
conn,
@@ -1058,6 +1138,7 @@ describe("RemoteSender", () => {
subscribe: bus.subscribe,
provide: async (input: any) => input.fn(),
permission: permissions(),
question: questions(),
})
sender.handle({ type: "subscribe", sessionId: "ses_target" })
@@ -1,11 +1,11 @@
import { afterEach, expect, test } from "bun:test"
import { afterEach, expect } from "bun:test" // kilocode_change - blocking behavior now uses the scoped service test helper
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdir, tmpdirScoped } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" // kilocode_change - blocking coverage no longer uses the Promise facade fixture
import { SessionID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -15,6 +15,7 @@ const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.de
const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Question.Info>
blocking?: boolean // kilocode_change
tool?: Question.Tool
}) {
const question = yield* Question.Service
@@ -110,12 +111,11 @@ it.instance(
)
// kilocode_change start - review follow-up uses non-blocking question prompts
test("ask - preserves blocking flag", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
it.instance(
"ask - preserves blocking flag",
() =>
Effect.gen(function* () {
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
blocking: false,
questions: [
@@ -125,16 +125,18 @@ test("ask - preserves blocking flag", async () => {
options: [{ label: "Start", description: "Run review" }],
},
],
})
}).pipe(Effect.forkScoped)
const pending = await Question.list()
const pending = yield* waitForPending(1)
expect(pending[0]?.blocking).toBe(false)
await Question.reject(pending[0].id)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
},
})
})
yield* rejectEffect(pending[0].id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
}),
{ git: true },
)
// kilocode_change end
// reply tests
@@ -205,15 +207,22 @@ it.instance(
{ git: true },
)
// kilocode_change start - preserve upstream unknown-request failure behavior during facade migration
it.instance(
"reply - does nothing for unknown requestID",
"reply - fails for unknown requestID",
() =>
replyEffect({
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
Effect.gen(function* () {
const id = QuestionID.make("que_unknown")
const exit = yield* replyEffect({ requestID: id, answers: [["Option 1"]] }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Question.NotFoundError)
if (err instanceof Question.NotFoundError) expect(err.requestID).toBe(id)
}),
{ git: true },
)
// kilocode_change end
// reject tests
@@ -275,9 +284,22 @@ it.instance(
{ git: true },
)
it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), {
git: true,
})
// kilocode_change start - preserve upstream unknown-request failure behavior during facade migration
it.instance(
"reject - fails for unknown requestID",
() =>
Effect.gen(function* () {
const id = QuestionID.make("que_unknown")
const exit = yield* rejectEffect(id).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Question.NotFoundError)
if (err instanceof Question.NotFoundError) expect(err.requestID).toBe(id)
}),
{ git: true },
)
// kilocode_change end
// multiple questions tests
@@ -213,6 +213,7 @@ function makeHttp() {
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provideMerge(question), // kilocode_change - SessionPrompt now dismisses questions via its service dependency
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
@@ -396,6 +397,51 @@ it.live("loop calls LLM and returns assistant message", () =>
),
)
// kilocode_change start - replacement prompts unblock pending Question service requests
it.live("new prompt dismisses a pending question", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const question = yield* Question.Service
const chat = yield* sessions.create({ title: "Question unblock regression" })
const pending = yield* question
.ask({
sessionID: chat.id,
questions: [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go ahead" },
{ label: "No", description: "Stop" },
],
},
],
})
.pipe(Effect.forkScoped)
yield* waitFor(
"pending question",
question.list().pipe(Effect.map((items) => items.find((item) => item.sessionID === chat.id))),
)
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
parts: [{ type: "text", text: "replacement prompt" }],
noReply: true,
})
const exit = yield* Fiber.await(pending)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
expect(yield* question.list()).toEqual([])
}),
{ git: true, config: providerCfg },
),
)
// kilocode_change end
it.live("prompt emits v2 prompted and synthetic events", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
@@ -150,6 +150,7 @@ function makeHttp() {
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
Layer.provideMerge(question), // kilocode_change - SessionPrompt now dismisses questions via its service dependency
Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
+33
View File
@@ -0,0 +1,33 @@
diff --git a/lib/xml/xmldom.js b/lib/xml/xmldom.js
index 752c353f0c97f86280c96984fe963c8b6f6d0c6c..b75bd10746eb035276cfded6d624e2cbda3c3c6e 100644
--- a/lib/xml/xmldom.js
+++ b/lib/xml/xmldom.js
@@ -2,20 +2,22 @@ var xmldom = require("@xmldom/xmldom");
var dom = require("@xmldom/xmldom/lib/dom");
function parseFromString(string) {
- var error = null;
+ var fatalError = null;
var domParser = new xmldom.DOMParser({
- errorHandler: function(level, message) {
- error = {level: level, message: message};
+ onError: function(level, message) {
+ if (level === "fatalError") {
+ fatalError = message;
+ }
}
});
- var document = domParser.parseFromString(string);
+ var document = domParser.parseFromString(string, "text/xml");
- if (error === null) {
+ if (fatalError === null) {
return document;
} else {
- throw new Error(error.level + ": " + error.message);
+ throw new Error("fatalError: " + fatalError);
}
}
-1
View File
@@ -23,7 +23,6 @@ const allow: Record<string, string> = {
"bus/index.ts": "core bus callback and synchronous runtime boundary",
"cli/cmd/tui/config/tui.ts": "separately tracked TUI config facade",
"installation/index.ts": "existing installation facade outside #10655",
"question/index.ts": "transitional facade deferred for upstream reconciliation in #10655",
"session/compaction.ts": "existing compaction facade outside #10655",
"sync/index.ts": "sync event runtime boundary",
}
+1
View File
@@ -205,6 +205,7 @@ export const defaultConfig: MergeConfig = {
"packages/kilo-gateway",
"packages/kilo-telemetry",
"packages/kilo-vscode",
"packages/kilo-jetbrains",
"packages/kilo-ui",
"packages/kilo-docs",
"packages/kilo-i18n",