mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge branch 'main' into jetbrains/release/v7.0.1-rc.4
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Windows speech input device detection when FFmpeg lists DirectShow microphones by section.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Use Kilo session share links when sharing conversations from the CLI.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support reading text from DOCX files through the read tool.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)!
|
||||
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%; }",
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user