mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
Merge remote-tracking branch 'origin/main' into fix/memory-leak-agent-manager
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support browsing and resuming sessions from Agent Manager with `/sessions`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Add `xhigh` reasoning effort option to custom provider model variants so users can access the highest tier on models that support it (gpt-5.2, gpt-5.3, gpt-5.4, gpt-5.1-codex-max, etc.).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support mentioning folders in the prompt with @ references, including top-level folder file contents.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show MCP tool call inputs alongside outputs in chat, with JSON syntax highlighting for both.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show an inline error in the Settings save bar when the configuration fails to save (for example, due to an invalid value) so the user can correct the config and retry instead of losing their unsaved changes silently.
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
@@ -86,7 +86,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -170,7 +170,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -199,7 +199,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -234,7 +234,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -247,7 +247,7 @@
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -267,7 +267,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
@@ -355,7 +355,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -498,7 +498,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -522,7 +522,7 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3",
|
||||
},
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -548,7 +548,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -571,7 +571,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -621,7 +621,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
|
||||
+1
-1
@@ -136,6 +136,6 @@
|
||||
"@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"
|
||||
},
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "7.2.11"
|
||||
version = "7.2.12"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/Kilo-Org/kilocode"
|
||||
@@ -11,26 +11,26 @@ name = "Kilo"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -58,6 +58,33 @@ git checkout -b docs/your-change-description
|
||||
- Reference issue numbers when applicable
|
||||
- Keep commits focused on a single change
|
||||
|
||||
### Changesets
|
||||
|
||||
User-facing changes (features, fixes, breaking changes) require a changeset file so the update shows up in the next release notes. Run the interactive tool, or create the file by hand:
|
||||
|
||||
```bash
|
||||
bunx changeset add
|
||||
```
|
||||
|
||||
Or create `.changeset/<slug>.md` manually:
|
||||
|
||||
```md
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Short description of the change for the changelog.
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes.
|
||||
- Descriptions are read by end users in release notes — keep them concise and feature-oriented. Describe **what changed from the user's perspective**, not implementation details.
|
||||
- Write in imperative mood (e.g. "Support exporting conversations as markdown" rather than "Add a new export handler that serializes session messages to .md files").
|
||||
- Changesets are consumed at release time by the `publish.yml` workflow, which generates changelog entries for the GitHub release notes.
|
||||
|
||||
Skip the changeset only for internal refactors, CI tweaks, test-only changes, or docs that do not affect users.
|
||||
|
||||
### Testing Your Changes
|
||||
|
||||
- Run the test suite:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Source Code Links
|
||||
|
||||
<!-- Auto-generated by script/extract-source-links.ts — DO NOT EDIT -->
|
||||
<!-- 79 unique URLs extracted from extension and CLI source -->
|
||||
<!-- 80 unique URLs extracted from extension and CLI source -->
|
||||
|
||||
- <https://api.apertis.ai/v1>
|
||||
<!-- packages/opencode/src/provider/model-cache.ts -->
|
||||
@@ -51,6 +51,8 @@
|
||||
<!-- packages/opencode/src/lsp/server.ts -->
|
||||
- <https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts>
|
||||
<!-- packages/opencode/src/tool/edit.ts -->
|
||||
- <https://github.com/Kilo-Org/kilo-marketplace>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/marketplace/MarketplaceContribute.tsx -->
|
||||
- <https://github.com/Kilo-Org/kilocode>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx -->
|
||||
- <https://github.com/Kilo-Org/kilocode/issues/6986>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -118,6 +118,11 @@ export async function fetchKiloModels(options?: {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip models that don't support tools — Kilo requires tool calling
|
||||
if (!model.supported_parameters?.includes("tools")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const transformedModel = transformToModelDevFormat(model)
|
||||
models[model.id] = transformedModel
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "عدة جلسات تعمل وستتوقف",
|
||||
"marketplace.warning.installAnyway": "تثبيت على أي حال",
|
||||
"marketplace.warning.cancel": "إلغاء",
|
||||
"marketplace.contribute.prompt": "هل تفتقد مهارة أو وضعًا أو خادم MCP؟",
|
||||
"marketplace.contribute.cta": "ساهم على GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Várias sessões estão em execução e serão interrompidas",
|
||||
"marketplace.warning.installAnyway": "Instalar mesmo assim",
|
||||
"marketplace.warning.cancel": "Cancelar",
|
||||
"marketplace.contribute.prompt": "Está faltando uma skill, modo ou servidor MCP?",
|
||||
"marketplace.contribute.cta": "Contribuir no GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -74,4 +74,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Nekoliko sesija je pokrenuto i bit će prekinuto",
|
||||
"marketplace.warning.installAnyway": "Instaliraj svejedno",
|
||||
"marketplace.warning.cancel": "Otkaži",
|
||||
"marketplace.contribute.prompt": "Nedostaje vještina, način rada ili MCP server?",
|
||||
"marketplace.contribute.cta": "Doprinesi na GitHub-u",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Flere sessioner kører og vil blive afbrudt",
|
||||
"marketplace.warning.installAnyway": "Installer alligevel",
|
||||
"marketplace.warning.cancel": "Annuller",
|
||||
"marketplace.contribute.prompt": "Mangler du en skill, tilstand eller MCP-server?",
|
||||
"marketplace.contribute.cta": "Bidrag på GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Mehrere Sitzungen laufen und werden unterbrochen",
|
||||
"marketplace.warning.installAnyway": "Trotzdem installieren",
|
||||
"marketplace.warning.cancel": "Abbrechen",
|
||||
"marketplace.contribute.prompt": "Fehlt ein Skill, Modus oder MCP-Server?",
|
||||
"marketplace.contribute.cta": "Auf GitHub beitragen",
|
||||
}
|
||||
|
||||
@@ -71,4 +71,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Several sessions are running and will be interrupted",
|
||||
"marketplace.warning.installAnyway": "Install anyway",
|
||||
"marketplace.warning.cancel": "Cancel",
|
||||
"marketplace.contribute.prompt": "Missing a skill, mode, or MCP server?",
|
||||
"marketplace.contribute.cta": "Contribute on GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Varias sesiones están en ejecución y se interrumpirán",
|
||||
"marketplace.warning.installAnyway": "Instalar de todas formas",
|
||||
"marketplace.warning.cancel": "Cancelar",
|
||||
"marketplace.contribute.prompt": "¿Falta una skill, modo o servidor MCP?",
|
||||
"marketplace.contribute.cta": "Contribuir en GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Plusieurs sessions sont en cours et seront interrompues",
|
||||
"marketplace.warning.installAnyway": "Installer quand même",
|
||||
"marketplace.warning.cancel": "Annuler",
|
||||
"marketplace.contribute.prompt": "Il manque une skill, un mode ou un serveur MCP ?",
|
||||
"marketplace.contribute.cta": "Contribuer sur GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -67,4 +67,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "複数のセッションが実行中で中断されます",
|
||||
"marketplace.warning.installAnyway": "それでもインストール",
|
||||
"marketplace.warning.cancel": "キャンセル",
|
||||
"marketplace.contribute.prompt": "スキル、モード、MCP サーバーが見つかりませんか?",
|
||||
"marketplace.contribute.cta": "GitHub で貢献する",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -67,4 +67,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "여러 세션이 실행 중이며 중단됩니다",
|
||||
"marketplace.warning.installAnyway": "그래도 설치",
|
||||
"marketplace.warning.cancel": "취소",
|
||||
"marketplace.contribute.prompt": "스킬, 모드 또는 MCP 서버가 없나요?",
|
||||
"marketplace.contribute.cta": "GitHub에서 기여하기",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -71,4 +71,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Er zijn meerdere sessies actief en deze zullen worden onderbroken",
|
||||
"marketplace.warning.installAnyway": "Toch installeren",
|
||||
"marketplace.warning.cancel": "Annuleren",
|
||||
"marketplace.contribute.prompt": "Mist er een skill, modus of MCP-server?",
|
||||
"marketplace.contribute.cta": "Bijdragen op GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Flere økter kjører og vil bli avbrutt",
|
||||
"marketplace.warning.installAnyway": "Installer uansett",
|
||||
"marketplace.warning.cancel": "Avbryt",
|
||||
"marketplace.contribute.prompt": "Mangler du en skill, modus eller MCP-server?",
|
||||
"marketplace.contribute.cta": "Bidra på GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Kilka sesji jest uruchomionych i zostanie przerwanych",
|
||||
"marketplace.warning.installAnyway": "Zainstaluj mimo to",
|
||||
"marketplace.warning.cancel": "Anuluj",
|
||||
"marketplace.contribute.prompt": "Brakuje skilla, trybu lub serwera MCP?",
|
||||
"marketplace.contribute.cta": "Wnieś wkład na GitHubie",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Несколько сеансов выполняются и будут прерваны",
|
||||
"marketplace.warning.installAnyway": "Установить в любом случае",
|
||||
"marketplace.warning.cancel": "Отмена",
|
||||
"marketplace.contribute.prompt": "Не хватает навыка, режима или MCP-сервера?",
|
||||
"marketplace.contribute.cta": "Внести вклад на GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "มีหลายเซสชันกำลังทำงานและจะถูกขัดจังหวะ",
|
||||
"marketplace.warning.installAnyway": "ติดตั้งต่อไป",
|
||||
"marketplace.warning.cancel": "ยกเลิก",
|
||||
"marketplace.contribute.prompt": "ไม่พบทักษะ โหมด หรือเซิร์ฟเวอร์ MCP ที่ต้องการ?",
|
||||
"marketplace.contribute.cta": "ร่วมสมทบบน GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Birden fazla oturum çalışıyor ve kesintiye uğrayacak",
|
||||
"marketplace.warning.installAnyway": "Yine de yükle",
|
||||
"marketplace.warning.cancel": "İptal",
|
||||
"marketplace.contribute.prompt": "Bir yetenek, mod veya MCP sunucusu mu eksik?",
|
||||
"marketplace.contribute.cta": "GitHub'da katkıda bulun",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -69,4 +69,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "Виконується кілька сесій, їх буде перервано",
|
||||
"marketplace.warning.installAnyway": "Встановити все одно",
|
||||
"marketplace.warning.cancel": "Скасувати",
|
||||
"marketplace.contribute.prompt": "Бракує навички, режиму або MCP-сервера?",
|
||||
"marketplace.contribute.cta": "Зробити внесок на GitHub",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -66,4 +66,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "多个会话正在运行,将被中断",
|
||||
"marketplace.warning.installAnyway": "仍然安装",
|
||||
"marketplace.warning.cancel": "取消",
|
||||
"marketplace.contribute.prompt": "缺少技能、模式或 MCP 服务器?",
|
||||
"marketplace.contribute.cta": "在 GitHub 上贡献",
|
||||
}
|
||||
|
||||
Generated
+2
@@ -66,4 +66,6 @@ export const dict = {
|
||||
"marketplace.warning.busyMany": "多個工作階段正在執行,將被中斷",
|
||||
"marketplace.warning.installAnyway": "仍然安裝",
|
||||
"marketplace.warning.cancel": "取消",
|
||||
"marketplace.contribute.prompt": "缺少技能、模式或 MCP 伺服器?",
|
||||
"marketplace.contribute.cta": "在 GitHub 上貢獻",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -156,6 +156,24 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
|
||||
}
|
||||
}
|
||||
|
||||
/* Reposition copy button tooltip to appear below (not above) to avoid clipping */
|
||||
[data-component="tool-output"] [data-slot="markdown-copy-button"]::after {
|
||||
bottom: auto;
|
||||
top: calc(100% + 4px);
|
||||
}
|
||||
|
||||
[data-slot="mcp-section-label"] {
|
||||
padding: 6px 12px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-weak, var(--vscode-descriptionForeground));
|
||||
}
|
||||
|
||||
[data-slot="mcp-tool-divider"] {
|
||||
height: 1px;
|
||||
background: var(--border-weak-base, var(--vscode-panel-border));
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Expandable tool output content */
|
||||
[data-component="tool-output"] {
|
||||
padding: 8px 12px;
|
||||
|
||||
@@ -1026,24 +1026,84 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
|
||||
// GenericTool (upstream) does not render output; this override does.
|
||||
// When hideDetails is true, render as a row (no content), otherwise as a panel with markdown output.
|
||||
function McpTool(props: ToolProps) {
|
||||
const i18n = useI18n()
|
||||
const labelKeys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
|
||||
const skipKeys = new Set(labelKeys)
|
||||
|
||||
const subtitle = () =>
|
||||
labelKeys
|
||||
.map((key) => props.input?.[key])
|
||||
.find((value): value is string => typeof value === "string" && value.length > 0)
|
||||
|
||||
const inputArgs = () => {
|
||||
if (!props.input) return []
|
||||
return Object.entries(props.input)
|
||||
.filter(([key]) => !skipKeys.has(key))
|
||||
.flatMap(([key, value]) => {
|
||||
if (typeof value === "string") return [`${key}=${value}`]
|
||||
if (typeof value === "number") return [`${key}=${value}`]
|
||||
if (typeof value === "boolean") return [`${key}=${value}`]
|
||||
return []
|
||||
})
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
const formatted = createMemo(() => {
|
||||
if (!props.input || Object.keys(props.input).length === 0) return ""
|
||||
return "```json\n" + JSON.stringify(props.input, null, 2) + "\n```"
|
||||
})
|
||||
|
||||
const formattedOutput = createMemo(() => {
|
||||
if (!props.output) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(props.output)
|
||||
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```"
|
||||
} catch {
|
||||
return props.output
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!props.hideDetails}
|
||||
fallback={<BasicTool hideDetails icon="mcp" status={props.status} trigger={{ title: props.tool }} />}
|
||||
fallback={
|
||||
<BasicTool
|
||||
hideDetails
|
||||
icon="mcp"
|
||||
status={props.status}
|
||||
trigger={{ title: props.tool, subtitle: subtitle(), args: inputArgs() }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status={props.status}
|
||||
trigger={{ title: props.tool }}
|
||||
trigger={{ title: props.tool, subtitle: subtitle(), args: inputArgs() }}
|
||||
defaultOpen={props.defaultOpen}
|
||||
forceOpen={props.forceOpen}
|
||||
locked={props.locked}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={output()} />
|
||||
</div>
|
||||
<Show when={formatted()}>
|
||||
{(text) => (
|
||||
<>
|
||||
<div data-slot="mcp-section-label">{i18n.t("ui.messagePart.mcp.input")}</div>
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={text()} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={formattedOutput()}>
|
||||
{(text) => (
|
||||
<>
|
||||
<Show when={formatted()}>
|
||||
<div data-slot="mcp-tool-divider" />
|
||||
</Show>
|
||||
<div data-slot="mcp-section-label">{i18n.t("ui.messagePart.mcp.output")}</div>
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={text()} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</BasicTool>
|
||||
@@ -1068,7 +1128,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
|
||||
return (
|
||||
<Show when={!hideQuestion()}>
|
||||
<div data-component="tool-part-wrapper" data-tool={part.tool}>
|
||||
<div data-component="tool-part-wrapper" data-part-type="tool" data-tool={part.tool}>
|
||||
<Switch>
|
||||
<Match when={part.state.status === "error" && part.state.error}>
|
||||
{(error) => {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# kilo-code
|
||||
|
||||
## 7.2.12
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#9099](https://github.com/Kilo-Org/kilocode/pull/9099) [`49b283e`](https://github.com/Kilo-Org/kilocode/commit/49b283e72a7307a1398c38c0862214faa043c128) - Add a "Contribute on GitHub" call-to-action in the Marketplace view so users can easily propose a new skill, mode, or MCP server. The CTA appears as a subtle footer below the card grid and inside the empty search-results state, linking to the `kilo-marketplace` repository.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9066](https://github.com/Kilo-Org/kilocode/pull/9066) [`79ba643`](https://github.com/Kilo-Org/kilocode/commit/79ba643cba201971a9779e454fe66769019a7a5a) - Add `xhigh` reasoning effort option to custom provider model variants so users can access the highest tier on models that support it (gpt-5.2, gpt-5.3, gpt-5.4, gpt-5.1-codex-max, etc.).
|
||||
|
||||
- [#9068](https://github.com/Kilo-Org/kilocode/pull/9068) [`e65c2d9`](https://github.com/Kilo-Org/kilocode/commit/e65c2d99c0d234d3dc1dff2e75e58e22bea8ce7f) Thanks [@kilo-code-bot](https://github.com/apps/kilo-code-bot)! - Hide Kilo Gateway models that do not support tool calling from the model list.
|
||||
|
||||
- [#9040](https://github.com/Kilo-Org/kilocode/pull/9040) [`7d2f5a7`](https://github.com/Kilo-Org/kilocode/commit/7d2f5a7eced0b3599231f75656c0a27c68770d74) - Preserve local Agent Manager sessions after the panel reloads while still pruning stale worktree sessions.
|
||||
|
||||
- [#9057](https://github.com/Kilo-Org/kilocode/pull/9057) [`1526a4b`](https://github.com/Kilo-Org/kilocode/commit/1526a4b8a890e01c0c890b4e17b595f35bfd745b) - Keep the active chat session responsive while other sessions stream in the background. Applies to both the sidebar and the Agent Manager.
|
||||
|
||||
## 7.2.11
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "kilo-code",
|
||||
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
|
||||
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
|
||||
"version": "7.2.11",
|
||||
"version": "7.2.12",
|
||||
"icon": "assets/icons/logo-outline-black.png",
|
||||
"galleryBanner": {
|
||||
"color": "#FFFFFF",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
buildSettingPath,
|
||||
mapSSEEventToWebviewMessage,
|
||||
getErrorMessage,
|
||||
getConfigErrorDetails,
|
||||
isEventFromForeignProject,
|
||||
MessageConfirmation,
|
||||
runWithMessageConfirmation,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
|
||||
resolveContextDirectory,
|
||||
resolveWorkspaceDirectory,
|
||||
mergeFileSearchResults,
|
||||
SessionStreamScheduler,
|
||||
type SessionRefreshContext,
|
||||
} from "./kilo-provider-utils"
|
||||
import { GitOps } from "./agent-manager/GitOps"
|
||||
@@ -45,6 +46,7 @@ import { retry } from "./services/cli-backend/retry"
|
||||
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
|
||||
import { handleContinueInWorktree } from "./kilo-provider/continue-worktree"
|
||||
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
|
||||
import { handleFileSearch } from "./kilo-provider/file-search"
|
||||
import { getTerminalContents } from "./services/terminal/context"
|
||||
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
|
||||
import { childID } from "./kilo-provider/task-session"
|
||||
@@ -86,6 +88,7 @@ import {
|
||||
handleQuestionReject,
|
||||
fetchAndSendPendingQuestions,
|
||||
} from "./kilo-provider/handlers/question"
|
||||
import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion"
|
||||
|
||||
import {
|
||||
buildActionContext,
|
||||
@@ -172,6 +175,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
/** Set when refreshSessions() is called before the client is ready.
|
||||
* Cleared and retried once the connection transitions to "connected". */
|
||||
private pendingSessionRefresh = false
|
||||
private readonly streams = new SessionStreamScheduler((msg) => this.postMessage(msg))
|
||||
private readonly confirmations = new MessageConfirmation()
|
||||
private unsubscribeEvent: (() => void) | null = null
|
||||
private unsubscribeState: (() => void) | null = null
|
||||
@@ -241,6 +245,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected })
|
||||
}
|
||||
private focusSession(id?: string): void {
|
||||
this.streams.focus(id)
|
||||
if (id) this.connectionService.registerFocused(this.instanceId, id)
|
||||
else this.connectionService.unregisterFocused(this.instanceId)
|
||||
}
|
||||
@@ -279,12 +284,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
// Edit tool parts carry full file contents in metadata.filediff.before/after.
|
||||
// A session with many edits can produce multi-MB payloads serialized through
|
||||
// postMessage on every session switch. Stripping those strings down to just
|
||||
// file path + addition/deletion counts eliminates the dominant cost.
|
||||
// Logic extracted to kilo-provider/slim-metadata.ts
|
||||
|
||||
// Strip edit-tool metadata.filediff.before/after (multi-MB for edit-heavy
|
||||
// sessions) to keep session switches fast. Logic in kilo-provider/slim-metadata.ts.
|
||||
private slimPart<T>(part: T): T {
|
||||
if (!this.slimEditMetadata) return part
|
||||
return slimPart(part)
|
||||
@@ -507,6 +508,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await Promise.all([
|
||||
fetchAndSendPendingPermissions(this.permissionCtx),
|
||||
fetchAndSendPendingQuestions(this.questionCtx),
|
||||
fetchAndSendPendingSuggestions(this.questionCtx),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -560,6 +562,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
await routeSuggestionWebviewMessage(this.questionCtx, message)
|
||||
switch (message.type) {
|
||||
case "webviewReady":
|
||||
console.log("[Kilo New] KiloProvider: ✅ webviewReady received")
|
||||
@@ -812,29 +815,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
)
|
||||
break
|
||||
}
|
||||
case "requestFileSearch": {
|
||||
const sdkClient = this.client
|
||||
if (sdkClient) {
|
||||
const dir = this.getWorkspaceDirectory(this.currentSession?.id)
|
||||
const openPaths = dir ? await this.getOpenTabPaths(dir) : new Set<string>()
|
||||
void sdkClient.find
|
||||
.files({ query: message.query, directory: dir, type: "file", limit: 50 }, { throwOnError: true })
|
||||
.then(({ data: paths }) => {
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const active =
|
||||
uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath).replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query: message.query, backend: paths, open: openPaths, active })
|
||||
this.postMessage({ type: "fileSearchResult", paths: result, dir, requestId: message.requestId })
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("[Kilo New] File search failed:", error)
|
||||
this.postMessage({ type: "fileSearchResult", paths: [], dir, requestId: message.requestId })
|
||||
})
|
||||
} else {
|
||||
this.postMessage({ type: "fileSearchResult", paths: [], dir: "", requestId: message.requestId })
|
||||
}
|
||||
case "requestFileSearch":
|
||||
await handleFileSearch({
|
||||
client: this.client,
|
||||
message,
|
||||
current: this.currentSession?.id,
|
||||
context: this.contextSessionID,
|
||||
dir: (id) => this.getWorkspaceDirectory(id),
|
||||
open: (dir) => this.getOpenTabPaths(dir),
|
||||
post: (msg) => this.postMessage(msg),
|
||||
})
|
||||
break
|
||||
}
|
||||
case "requestTerminalContext":
|
||||
void this.handleTerminalContext(message.requestId)
|
||||
break
|
||||
@@ -1368,12 +1359,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
|
||||
}
|
||||
|
||||
this.postMessage({
|
||||
type: "messagesLoaded",
|
||||
sessionID,
|
||||
messages,
|
||||
})
|
||||
|
||||
// Snapshot must reflect every SSE event up to its taken-time; any
|
||||
// delta still queued here is either already applied in the snapshot
|
||||
// (re-emitting would duplicate streamed text) or trails the snapshot
|
||||
// and is silently lost via drop().
|
||||
this.streams.drop(sessionID)
|
||||
this.postMessage({ type: "messagesLoaded", sessionID, messages })
|
||||
// Recover any prompts missed while the webview was loading or during an SSE reconnection.
|
||||
this.recoverPendingPrompts()
|
||||
} catch (error) {
|
||||
@@ -1426,12 +1417,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
|
||||
}
|
||||
|
||||
this.postMessage({
|
||||
type: "messagesLoaded",
|
||||
sessionID,
|
||||
messages,
|
||||
})
|
||||
|
||||
// Snapshot supersedes any queued deltas (see handleLoadMessages for the
|
||||
// snapshot-freshness assumption that governs drop() here).
|
||||
this.streams.drop(sessionID)
|
||||
this.postMessage({ type: "messagesLoaded", sessionID, messages })
|
||||
// Recover any prompts emitted by the child before we started tracking it.
|
||||
this.recoverPendingPrompts()
|
||||
} catch (err) {
|
||||
@@ -1524,11 +1513,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const workspaceDir = this.getWorkspaceDirectory(sessionID)
|
||||
await this.client.session.delete({ sessionID, directory: workspaceDir }, { throwOnError: true })
|
||||
this.trackedSessionIds.delete(sessionID)
|
||||
this.streams.drop(sessionID)
|
||||
this.syncedChildSessions.delete(sessionID)
|
||||
this.sessionDirectories.delete(sessionID)
|
||||
this.connectionService.pruneSession(sessionID)
|
||||
if (this.currentSession?.id === sessionID) {
|
||||
this.currentSession = null
|
||||
this.focusSession(undefined)
|
||||
}
|
||||
this.postMessage({ type: "sessionDeleted", sessionID })
|
||||
} catch (error) {
|
||||
@@ -2251,7 +2242,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
*/
|
||||
private async handleUpdateConfig(partial: Partial<Config>): Promise<void> {
|
||||
if (!this.client || this.connectionState !== "connected") {
|
||||
this.postMessage({ type: "error", message: "Not connected to CLI backend" })
|
||||
this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2260,42 +2251,39 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
partial.disabled_providers !== undefined ||
|
||||
partial.enabled_providers !== undefined
|
||||
|
||||
// Belt-and-suspenders guard: prevent fetchAndSendConfig from sending a
|
||||
// stale configLoaded while this write is in flight (the SSE-triggered reload
|
||||
// races with the async config.update() write on the CLI backend).
|
||||
// Guard against fetchAndSendConfig pushing stale data while the write is in flight.
|
||||
this.pending++
|
||||
|
||||
// Phase 1: write. Errors here = real save failures the user can fix + retry.
|
||||
try {
|
||||
// Reject all pending permissions and questions across every provider
|
||||
// so their CLI-side Promises resolve before disposeAll() wipes
|
||||
// Instance state. Throws on failure to abort the config save.
|
||||
await this.connectionService.drainPendingPrompts()
|
||||
|
||||
await this.client.global.config.update({ config: partial }, { throwOnError: true })
|
||||
|
||||
// Re-fetch the full merged config (global + project + all layers) so the
|
||||
// webview receives the complete resolved config, not just global-only data.
|
||||
// Config.state is reset by updateGlobal (via Instance.resetStateEntry) so
|
||||
// config.get() returns fresh data without a full dispose cycle.
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
|
||||
|
||||
this.cachedConfigMessage = { type: "configLoaded", config: merged }
|
||||
this.postMessage({ type: "configUpdated", config: merged })
|
||||
|
||||
if (refreshProviders) {
|
||||
await this.fetchAndSendProviders()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to update config:", error)
|
||||
this.postMessage({
|
||||
type: "error",
|
||||
type: "configUpdateFailed",
|
||||
message: getErrorMessage(error) || "Failed to update config",
|
||||
details: getConfigErrorDetails(error),
|
||||
})
|
||||
// Send configUpdated with the last known good config so the webview
|
||||
// clears its saving flag and reverts optimistic state.
|
||||
if (this.cachedConfigMessage) {
|
||||
this.postMessage({ type: "configUpdated", config: (this.cachedConfigMessage as { config: unknown }).config })
|
||||
}
|
||||
this.pending--
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: refresh. Config is already on disk — post-write errors are
|
||||
// transient, so send an optimistic configUpdated to clear the webview's
|
||||
// saving/draft state. SSE global.config.updated pushes the real data next.
|
||||
try {
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
|
||||
this.cachedConfigMessage = { type: "configLoaded", config: merged }
|
||||
this.postMessage({ type: "configUpdated", config: merged })
|
||||
if (refreshProviders) await this.fetchAndSendProviders()
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error)
|
||||
const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config
|
||||
const optimistic =
|
||||
cached && typeof cached === "object" ? { ...(cached as Record<string, unknown>), ...partial } : partial
|
||||
this.postMessage({ type: "configUpdated", config: optimistic })
|
||||
} finally {
|
||||
this.pending--
|
||||
}
|
||||
@@ -2916,7 +2904,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const sid = event.properties.sessionID
|
||||
this.sessionStatusMap.set(sid, event.properties.status.type)
|
||||
const msg = mapSSEEventToWebviewMessage(event, sid)
|
||||
if (msg) this.postMessage(msg)
|
||||
if (msg) {
|
||||
this.streams.flush(sid)
|
||||
this.postMessage(msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2992,16 +2983,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
handleNetworkEvent(event.type as string, event.properties as any, this.client, (s) => this.getWorkspaceDirectory(s))
|
||||
|
||||
const msg = mapSSEEventToWebviewMessage(event, sessionID)
|
||||
if (msg) {
|
||||
if (msg.type === "partUpdated") {
|
||||
this.postMessage({
|
||||
...msg,
|
||||
part: this.slimPart(msg.part),
|
||||
})
|
||||
return
|
||||
}
|
||||
this.postMessage(msg)
|
||||
if (!msg) return
|
||||
if (msg.type === "partUpdated") {
|
||||
this.streams.push({ ...msg, part: this.slimPart(msg.part) })
|
||||
return
|
||||
}
|
||||
this.streams.flush(sessionID)
|
||||
this.postMessage(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3335,6 +3323,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.viewStateDisposable?.dispose()
|
||||
this.visibilityDisposable?.dispose()
|
||||
this.webviewMessageDisposable?.dispose()
|
||||
this.streams.dispose()
|
||||
this.isWebviewReady = false
|
||||
this.promptRecoveryQueued = false
|
||||
clearNetworkWaits(this.trackedSessionIds)
|
||||
|
||||
@@ -265,6 +265,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
if (this.prBridge.handleMessage(msg)) return null
|
||||
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
|
||||
return { ...msg, sessionID: this.activeSessionId }
|
||||
}
|
||||
const m = msg as unknown as AgentManagerInMessage
|
||||
|
||||
const worktree = await this.onWorktreeMessage(m)
|
||||
|
||||
@@ -533,8 +533,13 @@ export class WorktreeStateManager {
|
||||
}
|
||||
let pruned = 0
|
||||
for (const [id, s] of Object.entries(data.sessions ?? {})) {
|
||||
// Skip orphaned sessions (null worktreeId or referencing a deleted worktree)
|
||||
if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) {
|
||||
const ref = s.worktreeId
|
||||
if (ref === null) {
|
||||
this.sessions.set(id, { id, ...s })
|
||||
continue
|
||||
}
|
||||
// Skip orphaned sessions referencing a deleted worktree.
|
||||
if (!ref || !this.worktrees.has(ref)) {
|
||||
pruned++
|
||||
continue
|
||||
}
|
||||
@@ -580,9 +585,11 @@ export class WorktreeStateManager {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// Prune orphaned sessions (worktreeId is null or references a deleted worktree)
|
||||
// Preserve local sessions; prune only sessions that reference missing worktrees.
|
||||
for (const s of [...this.sessions.values()]) {
|
||||
if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) {
|
||||
const ref = s.worktreeId
|
||||
if (ref === null) continue
|
||||
if (!ref || !this.worktrees.has(ref)) {
|
||||
this.sessions.delete(s.id)
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ function createHarness() {
|
||||
const manager = Object.create(AgentManagerProvider.prototype) as {
|
||||
host: Host
|
||||
panel: { sessions: { registerSession: ReturnType<typeof vi.fn> } } | undefined
|
||||
prBridge: { handleMessage: ReturnType<typeof vi.fn> }
|
||||
activeSessionId: string | undefined
|
||||
stateReady: Promise<void> | undefined
|
||||
createWorktreeOnDisk: ReturnType<typeof vi.fn>
|
||||
runSetupScriptForWorktree: ReturnType<typeof vi.fn>
|
||||
@@ -107,6 +109,7 @@ function createHarness() {
|
||||
notifyWorktreeReady: ReturnType<typeof vi.fn>
|
||||
log: ReturnType<typeof vi.fn>
|
||||
onCreateWorktree: () => Promise<null>
|
||||
onMessage: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
manager.host = host
|
||||
@@ -115,6 +118,8 @@ function createHarness() {
|
||||
registerSession: vi.fn(),
|
||||
},
|
||||
}
|
||||
manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) }
|
||||
manager.activeSessionId = undefined
|
||||
manager.stateReady = Promise.resolve()
|
||||
manager.createWorktreeOnDisk = vi.fn()
|
||||
manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -169,4 +174,13 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
|
||||
expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("routes file search through the active worktree session", async () => {
|
||||
const manager = createHarness()
|
||||
manager.activeSessionId = "session-wt"
|
||||
|
||||
const result = await manager.onMessage({ type: "requestFileSearch", query: "src", requestId: "r1" })
|
||||
|
||||
expect(result).toEqual({ type: "requestFileSearch", query: "src", requestId: "r1", sessionID: "session-wt" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/v2/client"
|
||||
import { prettifyError } from "zod/v4"
|
||||
import type { CloudSessionMessage } from "./services/cli-backend/types"
|
||||
import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler"
|
||||
|
||||
export { SessionStreamScheduler } from "./kilo-provider/session-stream-scheduler"
|
||||
|
||||
/** A single provider entry as returned by the /provider list endpoint. */
|
||||
export type ProviderInfo = ProviderListResponse["all"][number]
|
||||
@@ -14,48 +18,94 @@ export type ProviderInfo = ProviderListResponse["all"][number]
|
||||
* - NotFoundError: { name: "NotFoundError", data: { message: "..." } }
|
||||
* - Plain string (raw text response)
|
||||
*/
|
||||
/** Extract a message from the first element of an array of strings or `{ message }` objects. */
|
||||
function firstMessage(arr: unknown): string | undefined {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return undefined
|
||||
const first = arr[0]
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first === "object") {
|
||||
const msg = (first as Record<string, unknown>).message
|
||||
if (typeof msg === "string") return msg
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Extract a message from SDK error `data` field shapes (NotFoundError, ConfigInvalidError, Hono validator). */
|
||||
function messageFromData(data: Record<string, unknown>): string | undefined {
|
||||
if (typeof data.message === "string") return data.message
|
||||
// ConfigInvalidError: { path, issues: [{ message, path, code }] }
|
||||
const fromIssues = firstMessage(data.issues)
|
||||
if (fromIssues) return fromIssues
|
||||
// Hono validator: { data, error: [...], success: false }
|
||||
return firstMessage(data.error)
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string | undefined {
|
||||
try {
|
||||
const json = JSON.stringify(value)
|
||||
if (json !== "{}" && json.length < 500) return json
|
||||
} catch (err) {
|
||||
console.warn("[Kilo New] getErrorMessage: JSON.stringify failed", err)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "string") return error
|
||||
if (error && typeof error === "object") {
|
||||
const obj = error as Record<string, unknown>
|
||||
// Direct .message field
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
// Direct .error field (string)
|
||||
if (typeof obj.error === "string") return obj.error
|
||||
// SDK throwOnError shape: { error: { message: "..." } } or { error: { ... } }
|
||||
if (obj.error && typeof obj.error === "object") {
|
||||
const nested = obj.error as Record<string, unknown>
|
||||
if (typeof nested.message === "string") return nested.message
|
||||
}
|
||||
// NotFoundError shape: { data: { message: "..." } }
|
||||
if (obj.data && typeof obj.data === "object") {
|
||||
const data = obj.data as Record<string, unknown>
|
||||
if (typeof data.message === "string") return data.message
|
||||
// Hono validator shape: { data: ..., error: [...], success: false }
|
||||
if (Array.isArray(data.error) && data.error.length > 0) {
|
||||
const first = data.error[0]
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first === "object" && typeof (first as Record<string, unknown>).message === "string") {
|
||||
return (first as Record<string, unknown>).message as string
|
||||
}
|
||||
}
|
||||
}
|
||||
// BadRequestError shape: { errors: [{ message: "..." }] }
|
||||
if (Array.isArray(obj.errors) && obj.errors.length > 0) {
|
||||
const first = obj.errors[0]
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first.message === "string") return first.message
|
||||
}
|
||||
// Last resort: try JSON.stringify for debuggability
|
||||
try {
|
||||
const json = JSON.stringify(error)
|
||||
if (json !== "{}" && json.length < 500) return json
|
||||
} catch (err) {
|
||||
console.warn("[Kilo New] getErrorMessage: JSON.stringify failed", err)
|
||||
}
|
||||
if (!error || typeof error !== "object") return String(error)
|
||||
|
||||
const obj = error as Record<string, unknown>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.error === "string") return obj.error
|
||||
|
||||
// SDK throwOnError shape: { error: { message: "..." } }
|
||||
if (obj.error && typeof obj.error === "object") {
|
||||
const nested = (obj.error as Record<string, unknown>).message
|
||||
if (typeof nested === "string") return nested
|
||||
}
|
||||
return String(error)
|
||||
|
||||
if (obj.data && typeof obj.data === "object") {
|
||||
const fromData = messageFromData(obj.data as Record<string, unknown>)
|
||||
if (fromData) return fromData
|
||||
}
|
||||
|
||||
// BadRequestError: { errors: [...] }
|
||||
const fromErrors = firstMessage(obj.errors)
|
||||
if (fromErrors) return fromErrors
|
||||
|
||||
return safeStringify(error) ?? String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a full human-readable breakdown of a config save failure, including
|
||||
* the file path and every Zod issue. Used as the expandable details next to
|
||||
* the short getErrorMessage() summary.
|
||||
*
|
||||
* Zod issues are formatted via zod's built-in `prettifyError` so the output
|
||||
* matches Zod's canonical format (array indices rendered as `foo[0].bar`, etc).
|
||||
*
|
||||
* Returns undefined when the error doesn't carry structured config data —
|
||||
* callers should omit the details section in that case.
|
||||
*/
|
||||
export function getConfigErrorDetails(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined
|
||||
const data = (error as Record<string, unknown>).data
|
||||
if (!data || typeof data !== "object") return undefined
|
||||
const scoped = data as Record<string, unknown>
|
||||
const path = typeof scoped.path === "string" ? scoped.path : undefined
|
||||
const issues = Array.isArray(scoped.issues) ? scoped.issues : undefined
|
||||
if (!path && (!issues || issues.length === 0)) return undefined
|
||||
|
||||
const out: string[] = []
|
||||
if (path) out.push(`File: ${path}`)
|
||||
if (issues && issues.length > 0) {
|
||||
if (out.length > 0) out.push("")
|
||||
// prettifyError accepts any object with an `issues` array; the cast is
|
||||
// safe because it only reads the issues field.
|
||||
out.push(prettifyError({ issues } as Parameters<typeof prettifyError>[0]))
|
||||
}
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
export class MessageConfirmation {
|
||||
@@ -281,13 +331,8 @@ export function resolveContextDirectory(input: {
|
||||
}
|
||||
|
||||
export type WebviewMessage =
|
||||
| {
|
||||
type: "partUpdated"
|
||||
sessionID: string
|
||||
messageID: string
|
||||
part: unknown
|
||||
delta?: { type: "text-delta"; textDelta: string }
|
||||
}
|
||||
| PartUpdate
|
||||
| PartBatch
|
||||
| {
|
||||
type: "messageCreated"
|
||||
message: Record<string, unknown>
|
||||
@@ -307,8 +352,24 @@ export type WebviewMessage =
|
||||
}
|
||||
}
|
||||
| { type: "todoUpdated"; sessionID: string; items: unknown[] }
|
||||
| { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } }
|
||||
| {
|
||||
type: "questionRequest"
|
||||
question: { id: string; sessionID: string; questions: unknown[]; blocking?: boolean; tool?: unknown }
|
||||
}
|
||||
| { type: "questionResolved"; requestID: string }
|
||||
| {
|
||||
type: "suggestionRequest"
|
||||
suggestion: {
|
||||
id: string
|
||||
sessionID: string
|
||||
text: string
|
||||
actions: unknown[]
|
||||
blocking?: boolean
|
||||
tool?: unknown
|
||||
}
|
||||
}
|
||||
| { type: "suggestionResolved"; requestID: string }
|
||||
| { type: "suggestionError"; requestID: string }
|
||||
| { type: "permissionResolved"; permissionID: string }
|
||||
| { type: "permissionError"; permissionID: string }
|
||||
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview>; draftID?: string }
|
||||
@@ -412,6 +473,7 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
questions: event.properties.questions,
|
||||
blocking: event.properties.blocking,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
@@ -421,6 +483,24 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
|
||||
type: "questionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
}
|
||||
case "suggestion.shown":
|
||||
return {
|
||||
type: "suggestionRequest",
|
||||
suggestion: {
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
actions: event.properties.actions,
|
||||
blocking: event.properties.blocking,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
case "suggestion.accepted":
|
||||
case "suggestion.dismissed":
|
||||
return {
|
||||
type: "suggestionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
}
|
||||
case "session.error": {
|
||||
return {
|
||||
type: "sessionError",
|
||||
@@ -484,12 +564,16 @@ export function mergeFileSearchResults(input: {
|
||||
open: Set<string>
|
||||
active?: string
|
||||
}): string[] {
|
||||
const query = input.query.trim().toLowerCase()
|
||||
const norm = (p: string) => p.replaceAll("\\", "/")
|
||||
const query = norm(input.query).trim().toLowerCase()
|
||||
const open = new Set([...input.open].map(norm))
|
||||
const active = input.active ? norm(input.active) : undefined
|
||||
const backend = input.backend.map(norm)
|
||||
const ok = (p: string) => !query || p.toLowerCase().includes(query)
|
||||
const tabs =
|
||||
input.active && input.open.has(input.active) && ok(input.active)
|
||||
? [input.active, ...[...input.open].filter((p) => p !== input.active && ok(p))]
|
||||
: [...input.open].filter(ok)
|
||||
active && open.has(active) && ok(active)
|
||||
? [active, ...[...open].filter((p) => p !== active && ok(p))]
|
||||
: [...open].filter(ok)
|
||||
const seen = new Set(tabs)
|
||||
return [...tabs, ...input.backend.filter((p) => !seen.has(p))]
|
||||
return [...tabs, ...backend.filter((p) => !seen.has(p))]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type FileSearchItem = { path: string; type: "file" | "folder" }
|
||||
|
||||
const normalize = (p: string) => p.replaceAll("\\", "/")
|
||||
const trim = (p: string) => normalize(p).replace(/\/+$/, "")
|
||||
|
||||
function base(p: string): string {
|
||||
const clean = trim(p)
|
||||
return clean.split("/").pop() ?? clean
|
||||
}
|
||||
|
||||
function rank(query: string, p: string): number {
|
||||
const clean = trim(p).toLowerCase()
|
||||
const name = base(p).toLowerCase()
|
||||
if (clean === query || name === query) return 0
|
||||
if (name.startsWith(query) || (query.includes("/") && clean.startsWith(query))) return 1
|
||||
if (name.includes(query)) return 2
|
||||
if (clean.includes(query)) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] {
|
||||
const query = normalize(input.query).trim().toLowerCase()
|
||||
const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const }))
|
||||
// Dedup folders against themselves; a file and a folder that share a stem are distinct entries.
|
||||
const seen = new Set<string>()
|
||||
const folders = input.folders
|
||||
.filter((p) => {
|
||||
const key = trim(p)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.map((p, index) => ({
|
||||
item: { path: normalize(p), type: "folder" as const },
|
||||
index,
|
||||
rank: query ? rank(query, p) : 4,
|
||||
}))
|
||||
|
||||
if (!query) return [...files, ...folders.map((x) => x.item)]
|
||||
|
||||
const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||
const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item)
|
||||
const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item)
|
||||
return [...boosted, ...files, ...rest]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { mergeFileSearchResults } from "../kilo-provider-utils"
|
||||
import { mergeFileSearchItems } from "./file-search-items"
|
||||
|
||||
type Message = {
|
||||
query: string
|
||||
requestId: string
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
type Input = {
|
||||
client: KiloClient | null
|
||||
message: Message
|
||||
current?: string
|
||||
context?: string
|
||||
dir: (id?: string) => string
|
||||
open: (dir: string) => Promise<Set<string>>
|
||||
post: (message: unknown) => void
|
||||
}
|
||||
|
||||
export async function handleFileSearch(input: Input): Promise<void> {
|
||||
const client = input.client
|
||||
if (!client) {
|
||||
input.post({ type: "fileSearchResult", paths: [], items: [], dir: "", requestId: input.message.requestId })
|
||||
return
|
||||
}
|
||||
|
||||
const id = input.message.sessionID ?? input.current ?? input.context
|
||||
const dir = input.dir(id)
|
||||
const open = dir ? await input.open(dir) : new Set<string>()
|
||||
|
||||
const query = input.message.query
|
||||
void Promise.allSettled([
|
||||
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
|
||||
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
|
||||
]).then(([fileRes, folderRes]) => {
|
||||
const files = settled(fileRes, "file")
|
||||
const folders = settled(folderRes, "folder")
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
|
||||
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query, backend: files, open, active })
|
||||
const items = mergeFileSearchItems({ query, files: result, folders })
|
||||
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
|
||||
})
|
||||
}
|
||||
|
||||
function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] {
|
||||
if (result.status === "fulfilled") return result.value.data
|
||||
console.error(`[Kilo New] File search (${kind}) failed:`, result.reason)
|
||||
return []
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promis
|
||||
id: q.id,
|
||||
sessionID: q.sessionID,
|
||||
questions: q.questions,
|
||||
blocking: q.blocking,
|
||||
tool: q.tool,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Suggestion handlers — extracted from KiloProvider.
|
||||
*
|
||||
* Manages suggestion accept and dismiss flows plus recovery after SSE reconnects.
|
||||
* No vscode dependency.
|
||||
*/
|
||||
|
||||
import type { KiloClient, SuggestionRequest } from "@kilocode/sdk/v2/client"
|
||||
import { recoveryDirs } from "./permission-handler"
|
||||
|
||||
export type RecoverableSuggestion = SuggestionRequest
|
||||
|
||||
export interface SuggestionContext {
|
||||
readonly client: KiloClient | null
|
||||
readonly currentSessionId: string | undefined
|
||||
readonly trackedSessionIds: Set<string>
|
||||
readonly sessionDirectories: ReadonlyMap<string, string>
|
||||
postMessage(msg: unknown): void
|
||||
getWorkspaceDirectory(sessionId?: string): string
|
||||
}
|
||||
|
||||
export function recoverableSuggestions(items: RecoverableSuggestion[], tracked: Set<string>, seen: Set<string>) {
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return tracked.has(item.sessionID)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Route suggestion-related webview messages.
|
||||
* Extracted from the main message handler to stay within the complexity limit.
|
||||
*/
|
||||
export async function routeSuggestionWebviewMessage(
|
||||
ctx: SuggestionContext,
|
||||
message: { type: string; requestID?: string; sessionID?: string; index?: number },
|
||||
): Promise<void> {
|
||||
switch (message.type) {
|
||||
case "suggestionAccept":
|
||||
await handleSuggestionAccept(ctx, message.requestID!, message.index!, message.sessionID)
|
||||
break
|
||||
case "suggestionDismiss":
|
||||
await handleSuggestionDismiss(ctx, message.requestID!, message.sessionID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSuggestionAccept(
|
||||
ctx: SuggestionContext,
|
||||
requestID: string,
|
||||
index: number,
|
||||
sessionID?: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.client) {
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.suggestion.accept(
|
||||
{ requestID, index, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to accept suggestion:", error)
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSuggestionDismiss(
|
||||
ctx: SuggestionContext,
|
||||
requestID: string,
|
||||
sessionID?: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.client) {
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.suggestion.dismiss(
|
||||
{ requestID, directory: ctx.getWorkspaceDirectory(sessionID ?? ctx.currentSessionId) },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to dismiss suggestion:", error)
|
||||
ctx.postMessage({ type: "suggestionError", requestID })
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndSendPendingSuggestions(ctx: SuggestionContext): Promise<void> {
|
||||
if (!ctx.client) return
|
||||
try {
|
||||
const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories)
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const dir of dirs) {
|
||||
const { data } = await ctx.client.suggestion.list({ directory: dir })
|
||||
if (!data) continue
|
||||
for (const suggestion of recoverableSuggestions(data, ctx.trackedSessionIds, seen)) {
|
||||
ctx.postMessage({
|
||||
type: "suggestionRequest",
|
||||
suggestion,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch pending suggestions:", error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Session-aware streaming scheduler.
|
||||
*
|
||||
* Sits between SSE events and the webview `postMessage` path. Coalesces
|
||||
* repeated `partUpdated` events for the same `(sessionID, messageID, partID)`
|
||||
* tuple, prioritizes the focused session, and throttles background sessions
|
||||
* so multi-agent streaming doesn't saturate the renderer main thread.
|
||||
*
|
||||
* See the tuning comment on the default constants below for rationale.
|
||||
*/
|
||||
|
||||
import type { PartBatch, PartUpdate } from "../shared/stream-messages"
|
||||
export type { PartBatch, PartUpdate } from "../shared/stream-messages"
|
||||
|
||||
export type StreamSchedulerStats = {
|
||||
received: number
|
||||
emitted: number
|
||||
batches: number
|
||||
active: number
|
||||
background: number
|
||||
}
|
||||
|
||||
export type StreamSchedulerOptions = {
|
||||
/** Flush cadence for the focused/active session. Defaults to 16ms. */
|
||||
activeMs?: number
|
||||
/** Base background cadence. Defaults to 150ms. */
|
||||
backgroundBaseMs?: number
|
||||
/**
|
||||
* Additional ms per background session above the first 2 (adaptive throttle).
|
||||
* 10 background sessions → base + 8 * step. Defaults to 20ms.
|
||||
*/
|
||||
backgroundStepMs?: number
|
||||
/** Hard cap for the background cadence. Defaults to 400ms. */
|
||||
backgroundMaxMs?: number
|
||||
}
|
||||
|
||||
// Scheduler tuning — rationale:
|
||||
//
|
||||
// These defaults balance perceived streaming smoothness against renderer pressure.
|
||||
// The scheduler sits between SSE (dozens to hundreds of deltas per second per session)
|
||||
// and the webview message loop, which applies updates through Solid `batch()` and
|
||||
// triggers DOM / style / layout work on the renderer main thread.
|
||||
//
|
||||
// - DEFAULT_ACTIVE_MS = 16
|
||||
// One 60Hz animation frame. The focused session should feel indistinguishable
|
||||
// from immediate streaming, so we coalesce within a single paint window but no
|
||||
// longer. Raising this to 32ms visibly stutters live text; lowering below ~8ms
|
||||
// stops coalescing meaningfully because SSE delta arrival is already ~10-20ms
|
||||
// apart at typical model rates.
|
||||
//
|
||||
// - DEFAULT_BG_BASE_MS = 150
|
||||
// Background (non-focused) sessions don't need frame-perfect updates — the user
|
||||
// can't see their content. 150ms keeps tab-status signals (spinner motion,
|
||||
// token counts via related events) feeling alive while collapsing most
|
||||
// per-token deltas into a single batched emission. Under 100ms the coalescing
|
||||
// win shrinks; over ~250ms users start perceiving lag when switching tabs
|
||||
// mid-stream (though `focus()` also immediately flushes, so this is mostly a
|
||||
// concern for users watching tab-level indicators).
|
||||
//
|
||||
// - DEFAULT_BG_STEP_MS = 20
|
||||
// Per-extra-background-session backoff beyond the first 2. Each additional
|
||||
// streaming agent adds ~20ms to the background interval so total background
|
||||
// message throughput stays roughly flat as the agent count grows. Without this,
|
||||
// 10 concurrent agents would put the same ~7 msg/sec pressure per-session on
|
||||
// the renderer as 1 agent does (~70 msg/sec total background).
|
||||
//
|
||||
// - DEFAULT_BG_MAX_MS = 400
|
||||
// Ceiling for the adaptive backoff. Even with 20+ agents streaming we never
|
||||
// stall background emissions longer than 400ms, which keeps tab indicators
|
||||
// recognizably "live" and keeps the `drop()`-on-delete path timely. Above
|
||||
// ~500ms the UI starts feeling disconnected; below 300ms the many-agent
|
||||
// backoff stops providing meaningful throttling.
|
||||
const DEFAULT_ACTIVE_MS = 16
|
||||
const DEFAULT_BG_BASE_MS = 150
|
||||
const DEFAULT_BG_STEP_MS = 20
|
||||
const DEFAULT_BG_MAX_MS = 400
|
||||
|
||||
function partField(part: unknown, key: string): unknown {
|
||||
if (!part || typeof part !== "object") return undefined
|
||||
return (part as Record<string, unknown>)[key]
|
||||
}
|
||||
|
||||
function appendPart(part: unknown, text: string): unknown {
|
||||
if (!part || typeof part !== "object") return part
|
||||
const item = part as Record<string, unknown>
|
||||
if ((item.type !== "text" && item.type !== "reasoning") || typeof item.text !== "string") return part
|
||||
return { ...item, text: item.text + text }
|
||||
}
|
||||
|
||||
function partUpdateKey(msg: PartUpdate): string | undefined {
|
||||
const id = partField(msg.part, "id")
|
||||
const mid = msg.messageID || partField(msg.part, "messageID")
|
||||
if (typeof id !== "string" || !id) return undefined
|
||||
if (typeof mid !== "string" || !mid) return undefined
|
||||
return `${msg.sessionID}:${mid}:${id}`
|
||||
}
|
||||
|
||||
function mergePartUpdate(prev: PartUpdate | undefined, msg: PartUpdate): PartUpdate {
|
||||
if (!prev) return msg
|
||||
const text = msg.delta?.textDelta
|
||||
if (!text) return msg.delta ? prev : msg
|
||||
if (!prev.delta) return { ...prev, part: appendPart(prev.part, text) }
|
||||
return {
|
||||
...prev,
|
||||
part: appendPart(prev.part, text),
|
||||
delta: { type: "text-delta", textDelta: `${prev.delta.textDelta}${text}` },
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionStreamScheduler {
|
||||
private active: string | undefined
|
||||
private atimer: ReturnType<typeof setTimeout> | null = null
|
||||
private btimer: ReturnType<typeof setTimeout> | null = null
|
||||
private bgFirstQueuedAt = 0
|
||||
private readonly queues = new Map<string, Map<string, PartUpdate>>()
|
||||
private readonly activeMs: number
|
||||
private readonly bgBase: number
|
||||
private readonly bgStep: number
|
||||
private readonly bgMax: number
|
||||
private readonly counters: StreamSchedulerStats = {
|
||||
received: 0,
|
||||
emitted: 0,
|
||||
batches: 0,
|
||||
active: 0,
|
||||
background: 0,
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly send: (msg: PartUpdate | PartBatch) => void,
|
||||
opts?: StreamSchedulerOptions,
|
||||
) {
|
||||
this.activeMs = opts?.activeMs ?? DEFAULT_ACTIVE_MS
|
||||
this.bgBase = opts?.backgroundBaseMs ?? DEFAULT_BG_BASE_MS
|
||||
this.bgStep = opts?.backgroundStepMs ?? DEFAULT_BG_STEP_MS
|
||||
this.bgMax = opts?.backgroundMaxMs ?? DEFAULT_BG_MAX_MS
|
||||
}
|
||||
|
||||
focus(sessionID?: string): void {
|
||||
if (this.active === sessionID) return
|
||||
const prev = this.active
|
||||
if (this.atimer) {
|
||||
clearTimeout(this.atimer)
|
||||
this.atimer = null
|
||||
}
|
||||
this.active = sessionID
|
||||
if (prev && this.queues.get(prev)?.size) this.scheduleBackground()
|
||||
if (sessionID) this.flush(sessionID)
|
||||
}
|
||||
|
||||
push(msg: PartUpdate): void {
|
||||
this.counters.received++
|
||||
const key = partUpdateKey(msg)
|
||||
if (!key) {
|
||||
// Non-keyable updates can't be merged. Flush pending first to preserve order.
|
||||
this.flush(msg.sessionID)
|
||||
this.emitOne(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const queue = this.ensureQueue(msg.sessionID)
|
||||
const prev = queue.get(key)
|
||||
// A full-part replacement after buffered deltas would lose information; flush first.
|
||||
if (prev?.delta && !msg.delta) {
|
||||
this.flush(msg.sessionID)
|
||||
this.ensureQueue(msg.sessionID).set(key, msg)
|
||||
} else {
|
||||
queue.set(key, mergePartUpdate(prev, msg))
|
||||
}
|
||||
this.schedule(msg.sessionID)
|
||||
}
|
||||
|
||||
flush(sessionID?: string): void {
|
||||
if (!sessionID) {
|
||||
this.clearTimers()
|
||||
this.emit(this.takeAll())
|
||||
return
|
||||
}
|
||||
|
||||
if (this.active === sessionID && this.atimer) {
|
||||
clearTimeout(this.atimer)
|
||||
this.atimer = null
|
||||
}
|
||||
|
||||
this.emit(this.take(sessionID))
|
||||
|
||||
if (this.btimer && !this.hasBackground()) {
|
||||
clearTimeout(this.btimer)
|
||||
this.btimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any queued updates for a session without emitting them.
|
||||
*
|
||||
* Called when an authoritative snapshot supersedes buffered deltas
|
||||
* (messagesLoaded fetch) or when a session is deleted. Does NOT alter focus
|
||||
* state — callers that also want to clear focus should call `focus(undefined)`
|
||||
* themselves. A pending active-lane timer is left to fire harmlessly
|
||||
* (`take()` returns `[]` for the emptied queue).
|
||||
*/
|
||||
drop(sessionID: string): void {
|
||||
this.queues.delete(sessionID)
|
||||
if (this.btimer && !this.hasBackground()) {
|
||||
clearTimeout(this.btimer)
|
||||
this.btimer = null
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearTimers()
|
||||
this.queues.clear()
|
||||
}
|
||||
|
||||
stats(): Readonly<StreamSchedulerStats> {
|
||||
return this.counters
|
||||
}
|
||||
|
||||
private ensureQueue(sid: string): Map<string, PartUpdate> {
|
||||
const existing = this.queues.get(sid)
|
||||
if (existing) return existing
|
||||
const queue = new Map<string, PartUpdate>()
|
||||
this.queues.set(sid, queue)
|
||||
return queue
|
||||
}
|
||||
|
||||
private schedule(sessionID: string): void {
|
||||
if (!this.queues.get(sessionID)?.size) return
|
||||
if (this.active === sessionID) {
|
||||
if (this.atimer) return
|
||||
this.atimer = setTimeout(() => this.flushActive(), this.activeMs)
|
||||
return
|
||||
}
|
||||
this.scheduleBackground()
|
||||
}
|
||||
|
||||
private scheduleBackground(): void {
|
||||
const count = this.backgroundCount()
|
||||
if (count === 0) return
|
||||
const now = Date.now()
|
||||
if (!this.btimer) this.bgFirstQueuedAt = now
|
||||
const elapsed = now - this.bgFirstQueuedAt
|
||||
const extra = Math.max(0, count - 2) * this.bgStep
|
||||
const target = Math.min(this.bgMax, this.bgBase + extra)
|
||||
// Never defer past backgroundMaxMs from when the first update landed.
|
||||
// Each push can extend the timer up to that hard cap as the session count grows.
|
||||
const remaining = Math.max(0, Math.min(target - elapsed, this.bgMax - elapsed))
|
||||
if (this.btimer) clearTimeout(this.btimer)
|
||||
this.btimer = setTimeout(() => this.flushBackground(), remaining)
|
||||
}
|
||||
|
||||
private flushActive(): void {
|
||||
this.atimer = null
|
||||
if (this.active) this.emit(this.take(this.active))
|
||||
}
|
||||
|
||||
private flushBackground(): void {
|
||||
this.btimer = null
|
||||
this.bgFirstQueuedAt = 0
|
||||
this.emit(this.takeBackground())
|
||||
}
|
||||
|
||||
private take(sessionID: string): PartUpdate[] {
|
||||
const queue = this.queues.get(sessionID)
|
||||
if (!queue) return []
|
||||
this.queues.delete(sessionID)
|
||||
return [...queue.values()]
|
||||
}
|
||||
|
||||
private takeAll(): PartUpdate[] {
|
||||
const updates = [...this.queues.values()].flatMap((queue) => [...queue.values()])
|
||||
this.queues.clear()
|
||||
return updates
|
||||
}
|
||||
|
||||
private takeBackground(): PartUpdate[] {
|
||||
const updates: PartUpdate[] = []
|
||||
for (const [sid, queue] of this.queues) {
|
||||
if (sid === this.active) continue
|
||||
updates.push(...queue.values())
|
||||
this.queues.delete(sid)
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
private backgroundCount(): number {
|
||||
let n = 0
|
||||
for (const [sid, queue] of this.queues) {
|
||||
if (sid !== this.active && queue.size > 0) n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
private hasBackground(): boolean {
|
||||
return this.backgroundCount() > 0
|
||||
}
|
||||
|
||||
private emit(updates: PartUpdate[]): void {
|
||||
if (updates.length === 0) return
|
||||
if (updates.length === 1) {
|
||||
this.emitOne(updates[0]!)
|
||||
return
|
||||
}
|
||||
this.counters.emitted += updates.length
|
||||
this.counters.batches++
|
||||
this.countLane(updates[0]!.sessionID)
|
||||
this.send({ type: "partsUpdated", updates })
|
||||
}
|
||||
|
||||
private emitOne(msg: PartUpdate): void {
|
||||
this.counters.emitted++
|
||||
this.counters.batches++
|
||||
this.countLane(msg.sessionID)
|
||||
this.send(msg)
|
||||
}
|
||||
|
||||
private countLane(sessionID: string): void {
|
||||
if (sessionID === this.active) this.counters.active++
|
||||
else this.counters.background++
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.atimer) clearTimeout(this.atimer)
|
||||
this.atimer = null
|
||||
if (this.btimer) clearTimeout(this.btimer)
|
||||
this.btimer = null
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,7 @@ export class KiloConnectionService {
|
||||
if (error) throw new Error(`Failed to reject question ${q.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
await drainSuggestions(this.client, dir)
|
||||
await drainNetworkWaits(this.client, dir)
|
||||
}
|
||||
for (const listener of this.clearPendingPromptsListeners) {
|
||||
@@ -633,3 +634,14 @@ export class KiloConnectionService {
|
||||
this.startHealthPoll(config.baseUrl, config.password)
|
||||
}
|
||||
}
|
||||
|
||||
async function drainSuggestions(client: KiloClient, directory: string): Promise<void> {
|
||||
const { data, error: err } = await client.suggestion.list({ directory })
|
||||
if (err) throw new Error(`Failed to list suggestions for ${directory}: ${String(err)}`)
|
||||
if (data) {
|
||||
for (const s of data) {
|
||||
const { error } = await client.suggestion.dismiss({ requestID: s.id, directory })
|
||||
if (error) throw new Error(`Failed to dismiss suggestion ${s.id}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ export function resolveEventSessionId(
|
||||
case "question.replied":
|
||||
case "question.rejected":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
return resolveSuggestionSessionId(event)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSuggestionSessionId(event: Event): string | undefined {
|
||||
switch (event.type) {
|
||||
case "suggestion.shown":
|
||||
case "suggestion.accepted":
|
||||
case "suggestion.dismissed":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
// session.network.* events are not yet in the SDK Event type union
|
||||
// (pending SDK regeneration). Handle them via string comparison.
|
||||
|
||||
@@ -21,10 +21,8 @@ export type SSEStateHandler = (state: "connecting" | "connected" | "disconnected
|
||||
*
|
||||
* NOTE on event coalescing:
|
||||
* The app batches rapid events into 16 ms windows before flushing to the UI.
|
||||
* We don't do that here because `postMessage()` to the webview already acts as
|
||||
* an implicit async buffer. If profiling shows the webview is overwhelmed by
|
||||
* high-frequency events, adding a similar coalescing queue here would be a
|
||||
* straightforward improvement.
|
||||
* This adapter preserves raw SSE order for all subscribers; webview providers
|
||||
* coalesce high-frequency part updates before calling `postMessage()`.
|
||||
*/
|
||||
export class SdkSSEAdapter {
|
||||
private readonly handlers = new Set<SSEEventHandler>()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Wire types for the streaming part-update pipeline.
|
||||
*
|
||||
* Single source of truth shared by the extension-side scheduler
|
||||
* (`session-stream-scheduler.ts`) and the webview-side message types
|
||||
* (`webview-ui/src/types/messages.ts`). The generic `P` lets the webview
|
||||
* narrow `part` to its concrete `Part` union while the scheduler stays
|
||||
* payload-agnostic.
|
||||
*/
|
||||
|
||||
export type PartTextDelta = { type: "text-delta"; textDelta: string }
|
||||
|
||||
export type PartUpdate<P = unknown> = {
|
||||
type: "partUpdated"
|
||||
sessionID: string
|
||||
messageID: string
|
||||
part: P
|
||||
delta?: PartTextDelta
|
||||
}
|
||||
|
||||
export type PartBatch<P = unknown> = {
|
||||
type: "partsUpdated"
|
||||
updates: PartUpdate<P>[]
|
||||
}
|
||||
@@ -149,6 +149,30 @@ describe("resolveEventSessionId", () => {
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s11")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.shown", () => {
|
||||
const e = event({
|
||||
type: "suggestion.shown",
|
||||
properties: { id: "sug_1", sessionID: "s12", text: "Review?", actions: [] },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s12")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.accepted", () => {
|
||||
const e = event({
|
||||
type: "suggestion.accepted",
|
||||
properties: { sessionID: "s13", requestID: "sug_1", index: 0, action: { label: "Start", prompt: "x" } },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s13")
|
||||
})
|
||||
|
||||
it("returns sessionID from suggestion.dismissed", () => {
|
||||
const e = event({
|
||||
type: "suggestion.dismissed",
|
||||
properties: { sessionID: "s14", requestID: "sug_2" },
|
||||
})
|
||||
expect(resolveEventSessionId(e, noLookup)).toBe("s14")
|
||||
})
|
||||
|
||||
it("returns undefined for unknown event types (global events)", () => {
|
||||
const e = event({ type: "server.connected", properties: {} })
|
||||
expect(resolveEventSessionId(e, noLookup)).toBeUndefined()
|
||||
|
||||
@@ -50,6 +50,11 @@ describe("buildMentionResults", () => {
|
||||
const result = buildMentionResults("src", ["src/index.ts"])
|
||||
expect(result.map((item) => item.type)).toEqual(["file"])
|
||||
})
|
||||
|
||||
it("includes folder results", () => {
|
||||
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
|
||||
expect(result).toEqual([{ type: "folder", value: "src" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncMentionedPaths", () => {
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
mapCloudSessionMessageToWebviewMessage,
|
||||
MessageConfirmation,
|
||||
mergeFileSearchResults,
|
||||
getErrorMessage,
|
||||
getConfigErrorDetails,
|
||||
type ProviderInfo,
|
||||
} from "../../src/kilo-provider-utils"
|
||||
import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items"
|
||||
import type { CloudSessionMessage } from "../../src/services/cli-backend/types"
|
||||
import type {
|
||||
Session,
|
||||
@@ -26,6 +29,9 @@ import type {
|
||||
EventQuestionAsked,
|
||||
EventQuestionReplied,
|
||||
EventQuestionRejected,
|
||||
EventSuggestionShown,
|
||||
EventSuggestionAccepted,
|
||||
EventSuggestionDismissed,
|
||||
EventSessionCreated,
|
||||
EventSessionUpdated,
|
||||
EventServerConnected,
|
||||
@@ -444,6 +450,43 @@ describe("mapSSEEventToWebviewMessage", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("maps suggestion.shown to suggestionRequest", () => {
|
||||
const event: EventSuggestionShown = {
|
||||
type: "suggestion.shown",
|
||||
properties: {
|
||||
id: "sug-1",
|
||||
sessionID: "sess-1",
|
||||
text: "Review changes?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
},
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionRequest")
|
||||
})
|
||||
|
||||
it("maps suggestion.accepted to suggestionResolved", () => {
|
||||
const event: EventSuggestionAccepted = {
|
||||
type: "suggestion.accepted",
|
||||
properties: {
|
||||
sessionID: "sess-1",
|
||||
requestID: "sug-1",
|
||||
index: 0,
|
||||
action: { label: "Start", prompt: "/local-review-uncommitted" },
|
||||
},
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionResolved")
|
||||
})
|
||||
|
||||
it("maps suggestion.dismissed to suggestionResolved", () => {
|
||||
const event: EventSuggestionDismissed = {
|
||||
type: "suggestion.dismissed",
|
||||
properties: { sessionID: "sess-1", requestID: "sug-2" },
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("suggestionResolved")
|
||||
})
|
||||
|
||||
it("maps session.created to sessionCreated with ISO dates", () => {
|
||||
const event: EventSessionCreated = {
|
||||
type: "session.created",
|
||||
@@ -564,6 +607,47 @@ describe("mapCloudSessionMessage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchItems", () => {
|
||||
it("puts exact folder matches before file matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "script",
|
||||
files: ["script/hooks", "script/release", "script/beta.ts"],
|
||||
folders: ["script/", "script/run-script/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "script/", type: "folder" },
|
||||
{ path: "script/hooks", type: "file" },
|
||||
{ path: "script/release", type: "file" },
|
||||
{ path: "script/beta.ts", type: "file" },
|
||||
{ path: "script/run-script/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps file ordering before non-prefix folder matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "test",
|
||||
files: ["src/test.ts"],
|
||||
folders: ["src/latest/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "src/test.ts", type: "file" },
|
||||
{ path: "src/latest/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("normalizes Windows separators for matching and output", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "kilo-vscode",
|
||||
files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"],
|
||||
folders: ["packages\\kilo-vscode\\"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "packages/kilo-vscode/", type: "folder" },
|
||||
{ path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchResults", () => {
|
||||
it("returns backend results when no open files", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
@@ -657,4 +741,152 @@ describe("mergeFileSearchResults", () => {
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts", "src/index.ts"])
|
||||
})
|
||||
|
||||
it("normalizes backslash paths before filtering and deduping", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "utils/path",
|
||||
backend: ["src\\utils\\path.ts"],
|
||||
open: new Set(["src/utils/path.ts"]),
|
||||
active: "src\\utils\\path.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
it("extracts message from an Error instance", () => {
|
||||
expect(getErrorMessage(new Error("boom"))).toBe("boom")
|
||||
})
|
||||
|
||||
it("returns the string as-is", () => {
|
||||
expect(getErrorMessage("plain text failure")).toBe("plain text failure")
|
||||
})
|
||||
|
||||
it("reads a direct .message field", () => {
|
||||
expect(getErrorMessage({ message: "bad input" })).toBe("bad input")
|
||||
})
|
||||
|
||||
it("reads a direct .error string field", () => {
|
||||
expect(getErrorMessage({ error: "nope" })).toBe("nope")
|
||||
})
|
||||
|
||||
it("reads SDK throwOnError shape { error: { message } }", () => {
|
||||
expect(getErrorMessage({ error: { message: "sdk said no" } })).toBe("sdk said no")
|
||||
})
|
||||
|
||||
it("reads NotFoundError shape { data: { message } }", () => {
|
||||
expect(getErrorMessage({ name: "NotFoundError", data: { message: "not found" } })).toBe("not found")
|
||||
})
|
||||
|
||||
it("reads the first Zod issue message from ConfigInvalidError", () => {
|
||||
const err = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "/Users/me/.config/kilo/kilo.json",
|
||||
issues: [
|
||||
{ code: "unrecognized_keys", keys: ["indexing"], path: [], message: 'Unrecognized key: "indexing"' },
|
||||
{ code: "invalid_type", path: ["timeout"], message: "Expected number" },
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(getErrorMessage(err)).toBe('Unrecognized key: "indexing"')
|
||||
})
|
||||
|
||||
it("reads Hono validator shape { data: { error: [{ message }] } }", () => {
|
||||
const err = { data: { error: [{ message: "required" }] }, success: false }
|
||||
expect(getErrorMessage(err)).toBe("required")
|
||||
})
|
||||
|
||||
it("reads Hono validator shape with string errors", () => {
|
||||
expect(getErrorMessage({ data: { error: ["bad field"] } })).toBe("bad field")
|
||||
})
|
||||
|
||||
it("reads BadRequestError shape { errors: [{ message }] }", () => {
|
||||
expect(getErrorMessage({ errors: [{ message: "first error" }, { message: "second" }] })).toBe("first error")
|
||||
})
|
||||
|
||||
it("reads BadRequestError shape with string errors", () => {
|
||||
expect(getErrorMessage({ errors: ["boom"] })).toBe("boom")
|
||||
})
|
||||
|
||||
it("falls back to JSON for unknown object shapes", () => {
|
||||
expect(getErrorMessage({ weird: true, code: 42 })).toBe('{"weird":true,"code":42}')
|
||||
})
|
||||
|
||||
it("falls back to String() for non-serializable values", () => {
|
||||
expect(getErrorMessage(undefined)).toBe("undefined")
|
||||
expect(getErrorMessage(null)).toBe("null")
|
||||
expect(getErrorMessage(42)).toBe("42")
|
||||
})
|
||||
|
||||
it("skips JSON fallback for empty objects", () => {
|
||||
expect(getErrorMessage({})).toBe("[object Object]")
|
||||
})
|
||||
|
||||
it("prefers .message over nested shapes", () => {
|
||||
const err = { message: "outer", data: { issues: [{ message: "inner" }] } }
|
||||
expect(getErrorMessage(err)).toBe("outer")
|
||||
})
|
||||
|
||||
it("falls through when the first issue has no message", () => {
|
||||
// firstMessage only inspects index 0, so an invalid first entry causes the
|
||||
// branch to skip and the JSON fallback kicks in.
|
||||
const err = { data: { issues: [{ code: "bad" }] } }
|
||||
expect(getErrorMessage(err)).toBe('{"data":{"issues":[{"code":"bad"}]}}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConfigErrorDetails", () => {
|
||||
it("returns undefined for non-object errors", () => {
|
||||
expect(getConfigErrorDetails("oops")).toBeUndefined()
|
||||
expect(getConfigErrorDetails(undefined)).toBeUndefined()
|
||||
expect(getConfigErrorDetails(null)).toBeUndefined()
|
||||
expect(getConfigErrorDetails(42)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when .data is missing", () => {
|
||||
expect(getConfigErrorDetails({ message: "hi" })).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when .data has no path or issues", () => {
|
||||
expect(getConfigErrorDetails({ data: { unrelated: true } })).toBeUndefined()
|
||||
})
|
||||
|
||||
it("formats a single-issue ConfigInvalidError", () => {
|
||||
const err = {
|
||||
data: {
|
||||
path: "/home/me/.config/kilo/kilo.json",
|
||||
issues: [{ code: "unrecognized_keys", keys: ["indexing"], path: [], message: 'Unrecognized key: "indexing"' }],
|
||||
},
|
||||
}
|
||||
expect(getConfigErrorDetails(err)).toBe('File: /home/me/.config/kilo/kilo.json\n\n✖ Unrecognized key: "indexing"')
|
||||
})
|
||||
|
||||
it("formats a multi-issue ConfigInvalidError with paths (including array indices)", () => {
|
||||
const err = {
|
||||
data: {
|
||||
path: "/cfg.json",
|
||||
issues: [
|
||||
{ path: ["timeout"], message: "Expected number" },
|
||||
{ path: ["agents", 0, "name"], message: "Required" },
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(getConfigErrorDetails(err)).toBe(
|
||||
"File: /cfg.json\n\n✖ Expected number\n → at timeout\n✖ Required\n → at agents[0].name",
|
||||
)
|
||||
})
|
||||
|
||||
it("omits the path line when only issues are present", () => {
|
||||
const err = { data: { issues: [{ path: [], message: "something" }] } }
|
||||
expect(getConfigErrorDetails(err)).toBe("✖ something")
|
||||
})
|
||||
|
||||
it("omits the issues section when only the path is present", () => {
|
||||
expect(getConfigErrorDetails({ data: { path: "/cfg.json" } })).toBe("File: /cfg.json")
|
||||
})
|
||||
|
||||
it("returns undefined when issues array is empty and no path", () => {
|
||||
expect(getConfigErrorDetails({ data: { issues: [] } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,11 +17,18 @@ const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const MESSAGES_FILE = path.join(ROOT, "webview-ui/src/types/messages.ts")
|
||||
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
|
||||
const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts")
|
||||
// Some wire types (partUpdated, partsUpdated) live in a file shared by the
|
||||
// extension and webview; the contract checks must include it.
|
||||
const SHARED_STREAM_MESSAGES_FILE = path.join(ROOT, "src/shared/stream-messages.ts")
|
||||
|
||||
function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
}
|
||||
|
||||
function readMessageTypeSources(): string {
|
||||
return readFile(MESSAGES_FILE) + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE)
|
||||
}
|
||||
|
||||
describe("ExtensionMessage type members", () => {
|
||||
it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => {
|
||||
const content = readFile(MESSAGES_FILE)
|
||||
@@ -38,8 +45,9 @@ describe("ExtensionMessage type members", () => {
|
||||
const unionBody = unionMatch[1]!
|
||||
const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!)
|
||||
|
||||
const defined = readMessageTypeSources()
|
||||
const missing = memberNames.filter((name) => {
|
||||
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content)
|
||||
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined)
|
||||
})
|
||||
|
||||
expect(missing, `ExtensionMessage members without definitions: ${missing.join(", ")}`).toEqual([])
|
||||
@@ -57,8 +65,9 @@ describe("ExtensionMessage type members", () => {
|
||||
const unionBody = unionMatch[1]!
|
||||
const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!)
|
||||
|
||||
const defined = readMessageTypeSources()
|
||||
const missing = memberNames.filter((name) => {
|
||||
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content)
|
||||
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined)
|
||||
})
|
||||
|
||||
expect(missing, `WebviewMessage members without definitions: ${missing.join(", ")}`).toEqual([])
|
||||
@@ -68,7 +77,7 @@ describe("ExtensionMessage type members", () => {
|
||||
describe("KiloProvider message handler coverage", () => {
|
||||
it("all WebviewMessage switch cases in KiloProvider exist in WebviewMessage union", () => {
|
||||
const providerContent = readFile(KILO_PROVIDER_FILE)
|
||||
const messagesContent = readFile(MESSAGES_FILE)
|
||||
const messagesContent = readMessageTypeSources()
|
||||
|
||||
// Extract case labels from handleWebviewMessage switch
|
||||
const caseMatches = [...providerContent.matchAll(/case "([a-zA-Z]+)":/g)].map((m) => m[1]!)
|
||||
@@ -89,12 +98,12 @@ describe("KiloProvider message handler coverage", () => {
|
||||
describe("mapSSEEventToWebviewMessage output types", () => {
|
||||
it("all output types from mapSSEEventToWebviewMessage exist in ExtensionMessage", () => {
|
||||
const utilsContent = readFile(KILO_PROVIDER_UTILS_FILE)
|
||||
const messagesContent = readFile(MESSAGES_FILE)
|
||||
const messagesContent = readMessageTypeSources()
|
||||
|
||||
// Extract type literals used in the return values of mapSSEEventToWebviewMessage
|
||||
const typeMatches = [...utilsContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!)
|
||||
|
||||
// Get all type values defined in messages.ts
|
||||
// Get all type values defined across the wire-type sources.
|
||||
const allTypes = [...messagesContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!)
|
||||
const typeSet = new Set(allTypes)
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
dirName,
|
||||
buildHighlightSegments,
|
||||
atEnd,
|
||||
isPromptBlocked,
|
||||
isPromptBusy,
|
||||
isSuggesting,
|
||||
isQuestioning,
|
||||
} from "../../webview-ui/src/components/chat/prompt-input-utils"
|
||||
|
||||
describe("fileName", () => {
|
||||
@@ -145,3 +149,79 @@ describe("atEnd", () => {
|
||||
expect(atEnd(0, 0, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPromptBlocked", () => {
|
||||
it("returns false when zero permissions", () => {
|
||||
expect(isPromptBlocked(0)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when permissions exist", () => {
|
||||
expect(isPromptBlocked(1)).toBe(true)
|
||||
expect(isPromptBlocked(3)).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts exactly one argument (locks the API against regression)", () => {
|
||||
// Prevents a future change from reintroducing the question/blocking coupling.
|
||||
// See prompt-send-contract.test.ts for the source-level complement.
|
||||
expect(isPromptBlocked.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPromptBusy", () => {
|
||||
it("returns true when busy and neither suggesting nor questioning", () => {
|
||||
expect(isPromptBusy("busy", false, false)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when idle regardless of suggesting/questioning", () => {
|
||||
expect(isPromptBusy("idle", false, false)).toBe(false)
|
||||
expect(isPromptBusy("idle", true, false)).toBe(false)
|
||||
expect(isPromptBusy("idle", false, true)).toBe(false)
|
||||
expect(isPromptBusy("idle", true, true)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when busy but suggesting is true (suggestion decoupling)", () => {
|
||||
expect(isPromptBusy("busy", true, false)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when busy but questioning is true (question decoupling)", () => {
|
||||
expect(isPromptBusy("busy", false, true)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when busy and both suggesting and questioning", () => {
|
||||
expect(isPromptBusy("busy", true, true)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for non-idle non-busy status when not suggesting/questioning", () => {
|
||||
expect(isPromptBusy("retry", false, false)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSuggesting", () => {
|
||||
it("returns true when not blocked and suggestions > 0", () => {
|
||||
expect(isSuggesting(false, 1)).toBe(true)
|
||||
expect(isSuggesting(false, 3)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when blocked even with suggestions", () => {
|
||||
expect(isSuggesting(true, 2)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when not blocked but no suggestions", () => {
|
||||
expect(isSuggesting(false, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isQuestioning", () => {
|
||||
it("returns true when not blocked and questions > 0", () => {
|
||||
expect(isQuestioning(false, 1)).toBe(true)
|
||||
expect(isQuestioning(false, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when blocked even with questions", () => {
|
||||
expect(isQuestioning(true, 2)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when not blocked but no questions", () => {
|
||||
expect(isQuestioning(false, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Source contract tests for prompt send paths.
|
||||
*
|
||||
* Static analysis — reads session.tsx source and verifies that sendMessage and
|
||||
* sendCommand still dismiss suggestions and reject questions before dispatching.
|
||||
* Also reads ChatView.tsx and asserts the prompt-block predicate is fed only
|
||||
* permission counts, never question counts — guarantees that a pending question
|
||||
* cannot re-block the prompt input.
|
||||
*
|
||||
* Protects against accidental removal during Kilo development.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx")
|
||||
const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx")
|
||||
const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts")
|
||||
|
||||
function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the body of a named function from the source.
|
||||
* Finds `function <name>(` and returns everything from there to the next
|
||||
* `function ` declaration at the same or lower indentation, or to end of file.
|
||||
*/
|
||||
function extractFunctionBody(source: string, name: string): string {
|
||||
const marker = `function ${name}(`
|
||||
const start = source.indexOf(marker)
|
||||
if (start === -1) return ""
|
||||
|
||||
// Find the next `function ` declaration after the opening one.
|
||||
// We search for a newline followed by ` function ` (2-space indent, matching
|
||||
// the indentation level of sendMessage/sendCommand inside SessionProvider).
|
||||
const rest = source.slice(start + marker.length)
|
||||
const next = rest.search(/\n function /)
|
||||
return next === -1 ? rest : rest.slice(0, next)
|
||||
}
|
||||
|
||||
describe("sendMessage dismisses pending tool requests", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
const body = extractFunctionBody(source, "sendMessage")
|
||||
|
||||
it("function sendMessage exists in session.tsx", () => {
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("dismisses suggestions before sending", () => {
|
||||
expect(body).toContain("dismissSuggestion")
|
||||
})
|
||||
|
||||
it("rejects questions before sending", () => {
|
||||
expect(body).toContain("rejectQuestion")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendCommand dismisses pending tool requests", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
const body = extractFunctionBody(source, "sendCommand")
|
||||
|
||||
it("function sendCommand exists in session.tsx", () => {
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("dismisses suggestions before sending", () => {
|
||||
expect(body).toContain("dismissSuggestion")
|
||||
})
|
||||
|
||||
it("rejects questions before sending", () => {
|
||||
expect(body).toContain("rejectQuestion")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatView prompt-block contract", () => {
|
||||
const source = readFile(CHATVIEW_FILE)
|
||||
|
||||
it("calls isPromptBlocked with exactly one argument (familyPermissions length)", () => {
|
||||
// Exact call shape — prettier formatting is deterministic here, so a strict
|
||||
// match catches both "someone added a second arg" and "someone wrapped it in
|
||||
// a different expression".
|
||||
expect(source).toMatch(/blocked\s*=\s*\(\)\s*=>\s*isPromptBlocked\(familyPermissions\(\)\.length\)/)
|
||||
})
|
||||
|
||||
it("does not pass any second argument to isPromptBlocked", () => {
|
||||
expect(source).not.toMatch(/isPromptBlocked\s*\([^,)]*,[^)]*\)/)
|
||||
})
|
||||
|
||||
it("does not define a blockingQuestions memo", () => {
|
||||
expect(source).not.toContain("blockingQuestions")
|
||||
})
|
||||
|
||||
it("does not reference q.blocking when building the blocked state", () => {
|
||||
expect(source).not.toMatch(/q\.blocking/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPromptBlocked signature contract", () => {
|
||||
const source = readFile(PROMPT_UTILS_FILE)
|
||||
|
||||
it("declares exactly one parameter (source-level guard)", () => {
|
||||
// Complements the runtime `isPromptBlocked.length === 1` check in
|
||||
// prompt-input-utils.test.ts. `Function.prototype.length` counts parameters
|
||||
// before the first default — this regex catches a future regression that
|
||||
// sneaks in a second param with a default value (which would otherwise keep
|
||||
// `.length === 1` and slip past the runtime check).
|
||||
const match = source.match(/export function isPromptBlocked\(([^)]*)\)/)
|
||||
expect(match).not.toBeNull()
|
||||
const params = match![1]
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
expect(params).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,406 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
SessionStreamScheduler,
|
||||
type PartBatch,
|
||||
type PartUpdate,
|
||||
} from "../../src/kilo-provider/session-stream-scheduler"
|
||||
|
||||
type Item = PartUpdate
|
||||
type Sent = PartUpdate | PartBatch
|
||||
|
||||
function update(text: string, delta?: string, sid = "sess-1", partID = "p1") {
|
||||
const msg: Item = {
|
||||
type: "partUpdated",
|
||||
sessionID: sid,
|
||||
messageID: "m1",
|
||||
part: { id: partID, type: "text", messageID: "m1", text },
|
||||
}
|
||||
if (delta === undefined) return msg
|
||||
return { ...msg, delta: { type: "text-delta", textDelta: delta } } as Item
|
||||
}
|
||||
|
||||
function items(sent: Sent[]): Item[] {
|
||||
return sent.flatMap((msg) => (msg.type === "partsUpdated" ? msg.updates : [msg]))
|
||||
}
|
||||
|
||||
function flushSync(...msgs: Item[]): Sent[] {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
for (const msg of msgs) queue.push(msg)
|
||||
queue.flush()
|
||||
return sent
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
function partText(msg: Item): string {
|
||||
return (msg.part as { text: string }).text
|
||||
}
|
||||
|
||||
describe("SessionStreamScheduler / coalescing", () => {
|
||||
it("merges repeated text deltas", () => {
|
||||
const sent = items(flushSync(update("a", "a"), update("b", "b")))
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(partText(sent[0]!)).toBe("ab")
|
||||
expect(sent[0]!.delta?.textDelta).toBe("ab")
|
||||
})
|
||||
|
||||
it("uses part messageID when messageID is blank", () => {
|
||||
const sent = items(flushSync({ ...update("a", "a"), messageID: "" }, { ...update("b", "b"), messageID: "" }))
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(partText(sent[0]!)).toBe("ab")
|
||||
})
|
||||
|
||||
it("appends deltas onto queued full parts", () => {
|
||||
const sent = items(flushSync(update("hello"), update(" world", " world")))
|
||||
expect(partText(sent[0]!)).toBe("hello world")
|
||||
expect(sent[0]!.delta).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps the latest full part", () => {
|
||||
const sent = items(flushSync(update("old"), update("new")))
|
||||
expect(partText(sent[0]!)).toBe("new")
|
||||
})
|
||||
|
||||
it("flushes deltas before later full updates", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.push(update("a", "a"))
|
||||
queue.push(update("done"))
|
||||
queue.flush()
|
||||
const flat = items(sent)
|
||||
expect(flat).toHaveLength(2)
|
||||
expect(partText(flat[0]!)).toBe("a")
|
||||
expect(partText(flat[1]!)).toBe("done")
|
||||
})
|
||||
|
||||
it("emits non-keyable updates immediately and flushes pending first", () => {
|
||||
// A part without an id can't be coalesced. We must flush any queued deltas
|
||||
// for the same session first to preserve ordering, then emit directly.
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.push(update("queued", "queued"))
|
||||
queue.push({
|
||||
type: "partUpdated",
|
||||
sessionID: "sess-1",
|
||||
messageID: "m1",
|
||||
part: { type: "text", messageID: "m1", text: "noId" },
|
||||
})
|
||||
queue.flush()
|
||||
const flat = items(sent)
|
||||
expect(flat).toHaveLength(2)
|
||||
expect(partText(flat[0]!)).toBe("queued")
|
||||
expect(partText(flat[1]!)).toBe("noId")
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionStreamScheduler / session isolation", () => {
|
||||
it("batches multiple sessions into one partsUpdated message", () => {
|
||||
const sent = flushSync(update("a", "a", "sess-1"), update("b", "b", "sess-2"))
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.type).toBe("partsUpdated")
|
||||
expect(items(sent)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("never merges across sessions, messages, or parts", () => {
|
||||
// Identity invariants: provider caching and session isolation depend on
|
||||
// sessionID / messageID / partID never being swapped or combined.
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.push({
|
||||
...update("a", "a", "sess-1"),
|
||||
messageID: "m1",
|
||||
part: { id: "p1", type: "text", messageID: "m1", text: "a" },
|
||||
})
|
||||
queue.push({
|
||||
...update("b", "b", "sess-2"),
|
||||
messageID: "m1",
|
||||
part: { id: "p1", type: "text", messageID: "m1", text: "b" },
|
||||
})
|
||||
queue.push({
|
||||
...update("c", "c", "sess-1"),
|
||||
messageID: "m2",
|
||||
part: { id: "p1", type: "text", messageID: "m2", text: "c" },
|
||||
})
|
||||
queue.push({
|
||||
...update("d", "d", "sess-1"),
|
||||
messageID: "m1",
|
||||
part: { id: "p2", type: "text", messageID: "m1", text: "d" },
|
||||
})
|
||||
queue.flush()
|
||||
const flat = items(sent)
|
||||
expect(flat).toHaveLength(4)
|
||||
const keys = flat.map((msg) => `${msg.sessionID}:${msg.messageID}:${(msg.part as { id: string }).id}`)
|
||||
expect(new Set(keys).size).toBe(4)
|
||||
expect(keys).toEqual(expect.arrayContaining(["sess-1:m1:p1", "sess-2:m1:p1", "sess-1:m2:p1", "sess-1:m1:p2"]))
|
||||
})
|
||||
|
||||
it("preserves all part fields and concatenates deltas in arrival order", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.push({
|
||||
type: "partUpdated",
|
||||
sessionID: "sess-1",
|
||||
messageID: "m1",
|
||||
part: { id: "p1", type: "text", messageID: "m1", text: "hi", extraMetadata: { tokens: 42 } },
|
||||
})
|
||||
queue.push({
|
||||
type: "partUpdated",
|
||||
sessionID: "sess-1",
|
||||
messageID: "m1",
|
||||
part: { id: "p1", type: "text", messageID: "m1", text: "hi world" },
|
||||
delta: { type: "text-delta", textDelta: " world" },
|
||||
})
|
||||
queue.push({
|
||||
type: "partUpdated",
|
||||
sessionID: "sess-1",
|
||||
messageID: "m1",
|
||||
part: { id: "p1", type: "text", messageID: "m1", text: "hi world!" },
|
||||
delta: { type: "text-delta", textDelta: "!" },
|
||||
})
|
||||
queue.flush()
|
||||
const flat = items(sent)
|
||||
expect(flat).toHaveLength(1)
|
||||
const part = flat[0]!.part as { text: string; extraMetadata?: { tokens: number } }
|
||||
expect(part.extraMetadata?.tokens).toBe(42)
|
||||
expect(part.text).toBe("hi world!")
|
||||
// When deltas fold into a queued full part, emit as a full-part replacement
|
||||
// so the webview does an authoritative replace rather than appending.
|
||||
expect(flat[0]!.delta).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionStreamScheduler / focus and lifecycle", () => {
|
||||
it("flushes a newly focused session immediately", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.push(update("a", "a", "sess-2"))
|
||||
queue.focus("sess-2")
|
||||
expect(partText(items(sent)[0]!)).toBe("a")
|
||||
})
|
||||
|
||||
it("drop() discards queued updates for the session", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("a", "a", "sess-2"))
|
||||
queue.drop("sess-2")
|
||||
queue.drop("sess-1")
|
||||
queue.flush()
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("drop() before an authoritative snapshot prevents queued delta duplication", async () => {
|
||||
// Race: a text-delta is queued while the caller is fetching a messages
|
||||
// snapshot. If the snapshot already reflects the delta (server-authoritative),
|
||||
// emitting the queued delta after messagesLoaded would duplicate streamed text
|
||||
// in the webview. drop() must prevent any further emission of queued work.
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 5,
|
||||
backgroundBaseMs: 5,
|
||||
backgroundStepMs: 0,
|
||||
backgroundMaxMs: 5,
|
||||
})
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("hello", "hello", "sess-1"))
|
||||
queue.push(update(" world", " world", "sess-1"))
|
||||
// Snapshot arrives; caller discards queued deltas before posting messagesLoaded.
|
||||
queue.drop("sess-1")
|
||||
await sleep(25)
|
||||
// No queued updates should leak out after the drop.
|
||||
expect(sent).toHaveLength(0)
|
||||
queue.dispose()
|
||||
})
|
||||
|
||||
it("drop() on the active session keeps focus intact", async () => {
|
||||
// drop() is used when an authoritative snapshot supersedes buffered deltas
|
||||
// (e.g. messagesLoaded after a fetch). We just focused this session for the
|
||||
// user and need the NEXT pushes to keep using the low-latency active lane.
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 5,
|
||||
backgroundBaseMs: 500,
|
||||
backgroundStepMs: 0,
|
||||
backgroundMaxMs: 500,
|
||||
})
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.drop("sess-1")
|
||||
// Pending delta discarded; snapshot would now be posted by the caller.
|
||||
// A subsequent push for the same session still lands on the active lane.
|
||||
queue.push(update("b", "b", "sess-1"))
|
||||
await sleep(25)
|
||||
const flat = items(sent)
|
||||
expect(flat).toHaveLength(1)
|
||||
expect(partText(flat[0]!)).toBe("b")
|
||||
expect(queue.stats().active).toBe(1)
|
||||
expect(queue.stats().background).toBe(0)
|
||||
queue.dispose()
|
||||
})
|
||||
|
||||
it("focus(undefined) clears the active session", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.focus("sess-1")
|
||||
queue.focus(undefined)
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.flush()
|
||||
// With no active session, the update lands on the background path but flush
|
||||
// drains everything synchronously.
|
||||
const stats = queue.stats()
|
||||
expect(stats.background).toBe(1)
|
||||
expect(stats.active).toBe(0)
|
||||
})
|
||||
|
||||
it("dispose() stops further emissions from queued work", async () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 5,
|
||||
backgroundBaseMs: 5,
|
||||
backgroundStepMs: 0,
|
||||
backgroundMaxMs: 5,
|
||||
})
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.push(update("b", "b", "sess-2"))
|
||||
queue.dispose()
|
||||
await sleep(25)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("flush() with no argument drains everything across sessions", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.push(update("b", "b", "sess-2"))
|
||||
queue.push(update("c", "c", "sess-3"))
|
||||
queue.flush()
|
||||
expect(items(sent)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("flush(sid) on an empty session is a no-op", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.flush("sess-does-not-exist")
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionStreamScheduler / adaptive scheduling", () => {
|
||||
it("active session flushes on its cadence", async () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 5,
|
||||
backgroundBaseMs: 1000,
|
||||
backgroundStepMs: 0,
|
||||
backgroundMaxMs: 1000,
|
||||
})
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.push(update("b", "b", "sess-1"))
|
||||
await sleep(25)
|
||||
expect(partText(items(sent)[0]!)).toBe("ab")
|
||||
expect(queue.stats().active).toBe(1)
|
||||
expect(queue.stats().background).toBe(0)
|
||||
queue.dispose()
|
||||
})
|
||||
|
||||
it("background lane throttles independently of active", async () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 5,
|
||||
backgroundBaseMs: 20,
|
||||
backgroundStepMs: 0,
|
||||
backgroundMaxMs: 20,
|
||||
})
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.push(update("x", "x", "sess-2"))
|
||||
queue.push(update("y", "y", "sess-3"))
|
||||
await sleep(15)
|
||||
expect(sent).toHaveLength(1)
|
||||
await sleep(25)
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(queue.stats().active).toBe(1)
|
||||
expect(queue.stats().background).toBe(1)
|
||||
queue.dispose()
|
||||
})
|
||||
|
||||
it("focus(A→B) flushes B immediately and schedules A on background", async () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 50,
|
||||
backgroundBaseMs: 10,
|
||||
backgroundStepMs: 0,
|
||||
backgroundMaxMs: 10,
|
||||
})
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.push(update("b", "b", "sess-2"))
|
||||
queue.focus("sess-2")
|
||||
expect(items(sent).some((msg) => partText(msg) === "b")).toBe(true)
|
||||
await sleep(25)
|
||||
expect(items(sent).some((msg) => partText(msg) === "a")).toBe(true)
|
||||
queue.dispose()
|
||||
})
|
||||
|
||||
it("adaptive backoff lengthens background interval with many sessions", async () => {
|
||||
// Base 10ms + 20ms per extra background session beyond 2, capped at 200ms.
|
||||
// 10 background sessions → 10 + 8 * 20 = 170ms.
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 5,
|
||||
backgroundBaseMs: 10,
|
||||
backgroundStepMs: 20,
|
||||
backgroundMaxMs: 200,
|
||||
})
|
||||
for (let i = 1; i <= 10; i++) queue.push(update(`t${i}`, `t${i}`, `sess-${i}`))
|
||||
await sleep(60)
|
||||
// With 10 bg sessions, interval is 170ms — nothing should have flushed at 60ms.
|
||||
expect(sent).toHaveLength(0)
|
||||
await sleep(180)
|
||||
// One batch emitted for all 10 sessions.
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(items(sent)).toHaveLength(10)
|
||||
queue.dispose()
|
||||
})
|
||||
|
||||
it("backoff is capped at backgroundMaxMs", async () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg), {
|
||||
activeMs: 5,
|
||||
backgroundBaseMs: 10,
|
||||
backgroundStepMs: 100,
|
||||
backgroundMaxMs: 50,
|
||||
})
|
||||
// Without the cap, 5 bg sessions would schedule 10 + 3*100 = 310ms.
|
||||
// Cap forces it to 50ms.
|
||||
for (let i = 1; i <= 5; i++) queue.push(update(`t${i}`, `t${i}`, `sess-${i}`))
|
||||
await sleep(80)
|
||||
expect(sent).toHaveLength(1)
|
||||
queue.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionStreamScheduler / stats", () => {
|
||||
it("tracks received / emitted / batches / lane counters", () => {
|
||||
const sent: Sent[] = []
|
||||
const queue = new SessionStreamScheduler((msg) => sent.push(msg))
|
||||
queue.focus("sess-1")
|
||||
queue.push(update("a", "a", "sess-1"))
|
||||
queue.push(update("b", "b", "sess-1"))
|
||||
queue.push(update("x", "x", "sess-2"))
|
||||
queue.flush()
|
||||
const stats = queue.stats()
|
||||
expect(stats.received).toBe(3)
|
||||
// Two deltas merged in sess-1, one update in sess-2 → 2 unique updates emitted.
|
||||
expect(stats.emitted).toBe(2)
|
||||
// Single flush produces one batch message.
|
||||
expect(stats.batches).toBe(1)
|
||||
// Lane counters are incremented once per emission (batch or single).
|
||||
expect(stats.active + stats.background).toBe(stats.batches)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
fetchAndSendPendingSuggestions,
|
||||
recoverableSuggestions,
|
||||
type RecoverableSuggestion,
|
||||
type SuggestionContext,
|
||||
} from "../../src/kilo-provider/handlers/suggestion"
|
||||
|
||||
function pending(id: string, sessionID: string): RecoverableSuggestion {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
text: "Review changes?",
|
||||
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
|
||||
}
|
||||
}
|
||||
|
||||
type Items = Record<string, RecoverableSuggestion[]>
|
||||
|
||||
function suggestionClient(itemsPerDir: Items, queries: string[]) {
|
||||
return {
|
||||
suggestion: {
|
||||
list: async (args?: { directory?: string }) => {
|
||||
const dir = args?.directory ?? ""
|
||||
queries.push(dir)
|
||||
return { data: itemsPerDir[dir] ?? [] }
|
||||
},
|
||||
accept: async () => ({ data: true }),
|
||||
dismiss: async () => ({ data: true }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ctx(opts: { tracked: string[]; dirs?: Map<string, string>; itemsPerDir?: Items }) {
|
||||
const messages: unknown[] = []
|
||||
const queries: string[] = []
|
||||
const sdk = suggestionClient(opts.itemsPerDir ?? {}, queries) as unknown as SuggestionContext["client"]
|
||||
|
||||
const fake: SuggestionContext = {
|
||||
client: sdk,
|
||||
currentSessionId: undefined,
|
||||
trackedSessionIds: new Set(opts.tracked),
|
||||
sessionDirectories: opts.dirs ?? new Map(),
|
||||
postMessage: (msg) => messages.push(msg),
|
||||
getWorkspaceDirectory: () => "/workspace",
|
||||
}
|
||||
|
||||
return { fake, messages, queries }
|
||||
}
|
||||
|
||||
describe("recoverableSuggestions", () => {
|
||||
it("filters out untracked suggestions and deduplicates by id", () => {
|
||||
const seen = new Set<string>()
|
||||
const list = [pending("s1", "tracked"), pending("s1", "tracked"), pending("s2", "other")]
|
||||
expect(recoverableSuggestions(list, new Set(["tracked"]), seen)).toEqual([pending("s1", "tracked")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchAndSendPendingSuggestions", () => {
|
||||
it("forwards suggestions from tracked sessions", async () => {
|
||||
const dirs = new Map([["s1", "/wt"]])
|
||||
const { fake, messages, queries } = ctx({
|
||||
tracked: ["s1"],
|
||||
dirs,
|
||||
itemsPerDir: { "/wt": [pending("sug-1", "s1")] },
|
||||
})
|
||||
|
||||
await fetchAndSendPendingSuggestions(fake)
|
||||
|
||||
expect(queries).toContain("/workspace")
|
||||
expect(queries).toContain("/wt")
|
||||
expect(messages).toEqual([{ type: "suggestionRequest", suggestion: pending("sug-1", "s1") }])
|
||||
})
|
||||
|
||||
it("does nothing when client is null", async () => {
|
||||
const messages: unknown[] = []
|
||||
const fake: SuggestionContext = {
|
||||
client: null,
|
||||
currentSessionId: undefined,
|
||||
trackedSessionIds: new Set(["s1"]),
|
||||
sessionDirectories: new Map(),
|
||||
postMessage: (msg) => messages.push(msg),
|
||||
getWorkspaceDirectory: () => "/workspace",
|
||||
}
|
||||
|
||||
await fetchAndSendPendingSuggestions(fake)
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -150,10 +150,11 @@ describe("WorktreeStateManager", () => {
|
||||
})
|
||||
|
||||
describe("persistence", () => {
|
||||
it("saves and loads state, pruning orphaned sessions", async () => {
|
||||
it("saves and loads state, preserving local sessions and pruning orphaned sessions", async () => {
|
||||
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
manager.addSession("s2", null)
|
||||
manager.addSession("s3", "missing")
|
||||
// Flush fire-and-forget saves from mutations, then do a final save
|
||||
await manager.flush()
|
||||
await manager.save()
|
||||
@@ -163,10 +164,10 @@ describe("WorktreeStateManager", () => {
|
||||
|
||||
expect(loaded.getWorktrees()).toHaveLength(1)
|
||||
expect(loaded.getWorktrees()[0].branch).toBe("fix")
|
||||
// s2 had null worktreeId so it gets pruned on load
|
||||
expect(loaded.getSessions()).toHaveLength(1)
|
||||
expect(loaded.getSessions()).toHaveLength(2)
|
||||
expect(loaded.getSession("s1")?.worktreeId).toBe(wt.id)
|
||||
expect(loaded.getSession("s2")).toBeUndefined()
|
||||
expect(loaded.getSession("s2")?.worktreeId).toBeNull()
|
||||
expect(loaded.getSession("s3")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("load is a no-op when file does not exist", async () => {
|
||||
@@ -312,19 +313,20 @@ describe("WorktreeStateManager", () => {
|
||||
expect(manager.getSession("s1")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("prunes orphaned sessions with null worktreeId on validate", async () => {
|
||||
it("preserves local sessions and prunes missing worktree references on validate", async () => {
|
||||
const existing = path.join(root, "wt-exists")
|
||||
fs.mkdirSync(existing, { recursive: true })
|
||||
|
||||
const wt = manager.addWorktree({ branch: "exists", path: existing, parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
manager.addSession("s2", null)
|
||||
manager.addSession("s3", "missing")
|
||||
|
||||
await manager.validate(root)
|
||||
|
||||
// s1 stays (its worktree exists), s2 is pruned (null worktreeId)
|
||||
expect(manager.getSession("s1")).toBeTruthy()
|
||||
expect(manager.getSession("s2")).toBeUndefined()
|
||||
expect(manager.getSession("s2")?.worktreeId).toBeNull()
|
||||
expect(manager.getSession("s3")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("resolves relative paths against root", async () => {
|
||||
@@ -446,7 +448,7 @@ describe("WorktreeStateManager", () => {
|
||||
expect(manager.getSessions()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("handles partial data with missing worktrees key and prunes orphaned sessions", async () => {
|
||||
it("handles partial data with missing worktrees key and local sessions", async () => {
|
||||
const file = path.join(root, ".kilo", "agent-manager.json")
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
@@ -457,8 +459,7 @@ describe("WorktreeStateManager", () => {
|
||||
await manager.load()
|
||||
|
||||
expect(manager.getWorktrees()).toHaveLength(0)
|
||||
// Orphaned session with null worktreeId is pruned on load
|
||||
expect(manager.getSessions()).toHaveLength(0)
|
||||
expect(manager.getSession("s-1")?.worktreeId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8476ecb49183dbfed9be2924a8aa4622e8b95eb098c50609b986f9b8eb6cfe2d
|
||||
size 14390
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:123f32340a93af21146d08bb40738710bdd84099a27d0310316bb937360ca9f9
|
||||
size 14605
|
||||
oid sha256:07b3bf094c0a82e64fc7cb4e22aa37ada3dd5904a19a313879c6016c2ab55a00
|
||||
size 14586
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eee5f5d61cf2ac9ad0b987a8b220d76c503be0d11447766e6669da4684698c82
|
||||
size 6130
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9dbdd4b71bea71529e8835def452bbb627837d5bb6dbe460d882b20c4add6e98
|
||||
size 17650
|
||||
oid sha256:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9
|
||||
size 17677
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4f50e8ca1c012b64797cf57d5293ac59afefa79c8f1d703b66ac796e5b85fbaf
|
||||
size 6402
|
||||
oid sha256:c583144b11ac9608ec755e9a683dced8ae6ffae9a6f3833e1c0eec6b664f358e
|
||||
size 7720
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3fca4a95e3754b2d1316635fd756fa3fe8895213de7332d130a2f44075f06485
|
||||
size 19072
|
||||
oid sha256:8348db9191e1e616c93bf54ae33702a57e25c092a8f80a24d1da04811e523500
|
||||
size 26519
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f539f72e2c53c6ce84d26397715bb9c4e80c2c107e4584023f2bdbeba0086e62
|
||||
size 6958
|
||||
oid sha256:15116299b4700cceb501114e8fdca8b3ddfa5fb9d793f7cbab44b2647bbe64a4
|
||||
size 8070
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c4ea685888b585c50e8902a3df4a0fc85104a217b41bf6763d8e7e2f113c32ab
|
||||
size 4356
|
||||
oid sha256:ed66186a7aacdd4d31ff250a9c31fb1b244834cbb23e36abc51af706545f4518
|
||||
size 5059
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8e0fecc40d49bdd601a7d1ed6c8d4bb6c3854843c3e67eca408d3cd0a42b9f4f
|
||||
size 8193
|
||||
oid sha256:1d21dc25072b1c52cacaf0a91369f45e89f43ccc181d643e6b65030b829e5b1e
|
||||
size 8591
|
||||
|
||||
@@ -78,6 +78,7 @@ import { NotificationsProvider } from "../src/context/notifications"
|
||||
import { SessionProvider, useSession } from "../src/context/session"
|
||||
import { WorktreeModeProvider } from "../src/context/worktree-mode"
|
||||
import { ChatView } from "../src/components/chat"
|
||||
import HistoryView from "../src/components/history/HistoryView"
|
||||
import { NewWorktreeDialog } from "./NewWorktreeDialog"
|
||||
import { LanguageBridge, DataBridge } from "../src/App"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
@@ -348,6 +349,7 @@ const AgentManagerContent: Component = () => {
|
||||
let diffRaf: number | undefined
|
||||
let pendingDiffWidth: number | undefined
|
||||
|
||||
const [history, setHistory] = createSignal(false)
|
||||
const [sidePanel, setSidePanel] = createSignal<SidePanel>(null)
|
||||
const diffOpen = () => sidePanel() === "diff"
|
||||
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
|
||||
@@ -697,28 +699,23 @@ const AgentManagerContent: Component = () => {
|
||||
const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids))
|
||||
if (valid.length !== prev.length) {
|
||||
const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid))
|
||||
for (const id of removed) {
|
||||
vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id })
|
||||
}
|
||||
for (const id of removed) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id })
|
||||
setLocalSessionIDs(valid)
|
||||
}
|
||||
})
|
||||
// Drop in-memory review state for worktrees that no longer exist.
|
||||
createEffect(() => {
|
||||
const ids = new Set(worktrees().map((wt) => wt.id))
|
||||
|
||||
setReviewOpenByContext((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id)))
|
||||
if (Object.keys(next).length === Object.keys(prev).length) return prev
|
||||
return next
|
||||
})
|
||||
|
||||
setReviewCommentsByContext((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id)))
|
||||
if (Object.keys(next).length === Object.keys(prev).length) return prev
|
||||
return next
|
||||
})
|
||||
|
||||
setApplyStates((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
|
||||
if (Object.keys(next).length === Object.keys(prev).length) return prev
|
||||
@@ -1000,12 +997,8 @@ const AgentManagerContent: Component = () => {
|
||||
if (fallback && !isPending(fallback.id)) {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(fallback.id)
|
||||
} else if (fallback && isPending(fallback.id)) {
|
||||
setActivePendingId(fallback.id)
|
||||
session.clearCurrentSession()
|
||||
vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" })
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
setActivePendingId(fallback && isPending(fallback.id) ? fallback.id : undefined)
|
||||
session.clearCurrentSession()
|
||||
vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" })
|
||||
}
|
||||
@@ -1021,11 +1014,8 @@ const AgentManagerContent: Component = () => {
|
||||
const remembered = tabMemory()[worktreeId]
|
||||
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? sessions[0]
|
||||
if (fallback) {
|
||||
session.selectSession(fallback.id)
|
||||
} else {
|
||||
session.setCurrentSessionID(undefined)
|
||||
}
|
||||
if (fallback) session.selectSession(fallback.id)
|
||||
else session.setCurrentSessionID(undefined)
|
||||
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true)
|
||||
}
|
||||
|
||||
@@ -1048,7 +1038,8 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
onMount(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const msg = event.data as ExtensionMessage
|
||||
const msg = event.data
|
||||
if (msg?.type === "navigate" && msg.view === "history") return setHistory(true)
|
||||
if (msg?.type !== "action") return
|
||||
if (msg.action === "sessionPrevious") navigate("up")
|
||||
else if (msg.action === "sessionNext") navigate("down")
|
||||
@@ -1062,9 +1053,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
setSidePanel("diff")
|
||||
} else {
|
||||
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
}
|
||||
} else setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
|
||||
else if (msg.action === "closeTab") closeActiveTab()
|
||||
else if (msg.action === "newWorktree") handleNewWorktreeOrPromote()
|
||||
@@ -1886,9 +1875,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (pending) {
|
||||
setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? sid : id)))
|
||||
setActivePendingId(undefined)
|
||||
} else {
|
||||
setLocalSessionIDs((prev) => [...prev, sid])
|
||||
}
|
||||
} else setLocalSessionIDs((prev) => [...prev, sid])
|
||||
setSelection(LOCAL)
|
||||
setReviewActive(false)
|
||||
session.selectSession(sid)
|
||||
@@ -1897,20 +1884,14 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const handleAddSession = () => {
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) {
|
||||
addPendingTab()
|
||||
} else if (sel) {
|
||||
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
|
||||
}
|
||||
if (sel === LOCAL) addPendingTab()
|
||||
else if (sel) vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
|
||||
}
|
||||
|
||||
const handleForkSession = (sessionId: string) => {
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) {
|
||||
vscode.postMessage({ type: "agentManager.forkSession", sessionId })
|
||||
} else if (sel) {
|
||||
vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel })
|
||||
}
|
||||
if (sel === LOCAL) vscode.postMessage({ type: "agentManager.forkSession", sessionId })
|
||||
else if (sel) vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel })
|
||||
}
|
||||
|
||||
const handleCloseTab = (sessionId: string) => {
|
||||
@@ -1920,14 +1901,12 @@ const AgentManagerContent: Component = () => {
|
||||
const tabs = activeTabs()
|
||||
const idx = tabs.findIndex((s) => s.id === sessionId)
|
||||
const next = tabs[idx + 1] ?? tabs[idx - 1]
|
||||
if (next) {
|
||||
if (isPending(next.id)) {
|
||||
setActivePendingId(next.id)
|
||||
session.clearCurrentSession()
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(next.id)
|
||||
}
|
||||
if (next && isPending(next.id)) {
|
||||
setActivePendingId(next.id)
|
||||
session.clearCurrentSession()
|
||||
} else if (next) {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(next.id)
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.clearCurrentSession()
|
||||
@@ -1935,9 +1914,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
if (pending || localSet().has(sessionId)) {
|
||||
setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId))
|
||||
if (!pending) {
|
||||
vscode.postMessage({ type: "agentManager.forgetSession", sessionId })
|
||||
}
|
||||
if (!pending) vscode.postMessage({ type: "agentManager.forgetSession", sessionId })
|
||||
} else {
|
||||
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
|
||||
}
|
||||
@@ -2921,7 +2898,29 @@ const AgentManagerContent: Component = () => {
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
<Show when={!contextEmpty()}>
|
||||
<Show when={history()}>
|
||||
<HistoryView
|
||||
onSelectSession={(id) => {
|
||||
setHistory(false)
|
||||
if (localSessionIDs().includes(id)) {
|
||||
saveTabMemory()
|
||||
session.selectSession(id)
|
||||
setSelection(LOCAL)
|
||||
return
|
||||
}
|
||||
const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined
|
||||
if (ms?.worktreeId) {
|
||||
selectWorktree(ms.worktreeId)
|
||||
session.selectSession(id)
|
||||
setReviewActive(false)
|
||||
return
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onBack={() => setHistory(false)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!contextEmpty() && !history()}>
|
||||
{/* Chat + side diff panel (hidden when review tab is active) */}
|
||||
<div
|
||||
class={`am-detail-content ${sidePanel() !== null ? "am-detail-split" : ""}`}
|
||||
@@ -2947,6 +2946,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onShowHistory={() => setHistory(true)}
|
||||
readonly={readOnly()}
|
||||
continueInWorktree={selection() === LOCAL}
|
||||
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { useData } from "@kilocode/kilo-ui/context/data"
|
||||
import { useSession } from "../../context/session"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
|
||||
// Tools that the upstream message-part renderer suppresses (returns null for).
|
||||
// We render these ourselves via ToolRegistry when they complete,
|
||||
@@ -41,6 +42,21 @@ function isRenderable(part: SDKPart): boolean {
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a tool part to an active request (question or suggestion) by tool name
|
||||
* and callID/messageID. Returns the matched request or undefined.
|
||||
*/
|
||||
function matchToolRequest<T extends { tool?: { callID: string; messageID: string } }>(
|
||||
part: SDKPart,
|
||||
name: string,
|
||||
requests: T[],
|
||||
): T | undefined {
|
||||
if (part.type !== "tool") return undefined
|
||||
const tp = part as unknown as ToolPart
|
||||
if (tp.tool !== name) return undefined
|
||||
return requests.find((r) => r.tool?.callID === tp.callID && r.tool?.messageID === tp.messageID)
|
||||
}
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: SDKAssistantMessage
|
||||
showAssistantCopyPartID?: string | null
|
||||
@@ -87,35 +103,40 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool)
|
||||
|
||||
// Active question tool parts render the interactive QuestionDock inline
|
||||
const activeQuestion = createMemo(() => {
|
||||
if (part.type !== "tool") return undefined
|
||||
const tp = part as unknown as ToolPart
|
||||
if (tp.tool !== "question") return undefined
|
||||
return session.questions().find((q) => q.tool?.callID === tp.callID && q.tool?.messageID === tp.messageID)
|
||||
})
|
||||
const activeQuestion = createMemo(() => matchToolRequest(part, "question", session.questions()))
|
||||
|
||||
// Active suggestion tool parts render the interactive SuggestBar inline
|
||||
const activeSuggestion = createMemo(() => matchToolRequest(part, "suggest", session.suggestions()))
|
||||
|
||||
return (
|
||||
<Show when={isUpstreamSuppressed || activeQuestion() || PART_MAPPING[part.type]}>
|
||||
<Show when={isUpstreamSuppressed || activeQuestion() || activeSuggestion() || PART_MAPPING[part.type]}>
|
||||
<div data-component="tool-part-wrapper" data-part-type={part.type}>
|
||||
<Show
|
||||
when={activeQuestion()}
|
||||
fallback={
|
||||
<Show
|
||||
when={isUpstreamSuppressed}
|
||||
when={activeSuggestion()}
|
||||
fallback={
|
||||
<Part
|
||||
part={part}
|
||||
message={props.message as SDKMessage}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
animate={
|
||||
part.type === "tool" &&
|
||||
((part as unknown as ToolPart).state?.status === "pending" ||
|
||||
(part as unknown as ToolPart).state?.status === "running")
|
||||
<Show
|
||||
when={isUpstreamSuppressed}
|
||||
fallback={
|
||||
<Part
|
||||
part={part}
|
||||
message={props.message as SDKMessage}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
animate={
|
||||
part.type === "tool" &&
|
||||
((part as unknown as ToolPart).state?.status === "pending" ||
|
||||
(part as unknown as ToolPart).state?.status === "running")
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<TodoToolCard part={part as unknown as ToolPart} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<TodoToolCard part={part as unknown as ToolPart} />
|
||||
{(req) => <SuggestBar request={req()} />}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useVSCode } from "../../context/vscode"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useWorktreeMode } from "../../context/worktree-mode"
|
||||
import { useServer } from "../../context/server"
|
||||
import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils"
|
||||
|
||||
interface ChatViewProps {
|
||||
onSelectSession?: (id: string) => void
|
||||
@@ -55,13 +56,20 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
// not once per accessor call (questionRequest, permissionRequest, blocked all read these).
|
||||
const familyPermissions = createMemo(() => session.scopedPermissions(id()))
|
||||
const familyQuestions = createMemo(() => session.scopedQuestions(id()))
|
||||
|
||||
const familySuggestions = createMemo(() => session.scopedSuggestions(id()))
|
||||
// Non-tool questions (standalone, not from the question tool) render inline in
|
||||
// the message list since they don't have an associated tool part in the conversation.
|
||||
// Tool-linked questions render inline at their tool part position via AssistantMessage.
|
||||
const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool))
|
||||
const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool))
|
||||
const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0]
|
||||
const blocked = () => familyPermissions().length > 0 || familyQuestions().length > 0
|
||||
// Prompt input is decoupled from questions/suggestions — only permissions block.
|
||||
// Pending questions and suggestions are auto-dismissed in sendMessage/sendCommand.
|
||||
const blocked = () => isPromptBlocked(familyPermissions().length)
|
||||
// Session is busy only because a suggestion tool call is pending — prompt should behave as idle
|
||||
const suggesting = () => isSuggesting(blocked(), familySuggestions().length)
|
||||
// Session is busy only because a question tool call is pending — prompt should behave as idle
|
||||
const questioning = () => isQuestioning(blocked(), familyQuestions().length)
|
||||
const dock = () => !props.readonly || !!permissionRequest()
|
||||
|
||||
// When a bottom-dock permission disappears while the session is busy,
|
||||
@@ -129,6 +137,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
onSelectSession={props.onSelectSession}
|
||||
onShowHistory={props.onShowHistory}
|
||||
questions={standaloneQuestions}
|
||||
suggestions={standaloneSuggestions}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
</div>
|
||||
@@ -214,7 +223,13 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.readonly}>
|
||||
<PromptInput blocked={blocked} boxId={props.promptBoxId} pendingSessionID={props.pendingSessionID} />
|
||||
<PromptInput
|
||||
blocked={blocked}
|
||||
suggesting={suggesting}
|
||||
questioning={questioning}
|
||||
boxId={props.promptBoxId}
|
||||
pendingSessionID={props.pendingSessionID}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -23,8 +23,9 @@ import { AccountSwitcher } from "../shared/AccountSwitcher"
|
||||
import { KiloNotifications } from "./KiloNotifications"
|
||||
import { WorkingIndicator } from "../shared/WorkingIndicator"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue"
|
||||
import type { QuestionRequest } from "../../types/messages"
|
||||
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
|
||||
|
||||
const KiloLogo = (): JSX.Element => {
|
||||
const iconsBaseUri = (window as { ICONS_BASE_URI?: string }).ICONS_BASE_URI || ""
|
||||
@@ -44,6 +45,8 @@ interface MessageListProps {
|
||||
onShowHistory?: () => void
|
||||
/** Non-tool question requests to render inline at the bottom of the message list */
|
||||
questions?: () => QuestionRequest[]
|
||||
/** Non-tool suggestion requests to render inline at the bottom of the message list */
|
||||
suggestions?: () => SuggestionRequest[]
|
||||
/** When true (subagent viewer), replace the welcome screen with an initializing indicator */
|
||||
readonly?: boolean
|
||||
}
|
||||
@@ -173,6 +176,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
</Show>
|
||||
<WorkingIndicator />
|
||||
<For each={props.questions?.()}>{(req) => <QuestionDock request={req} />}</For>
|
||||
<For each={props.suggestions?.()}>{(req) => <SuggestBar request={req} />}</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageA
|
||||
import { convertToMentionPath } from "../../utils/path-mentions"
|
||||
import { usePromptHistory } from "../../hooks/usePromptHistory"
|
||||
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
|
||||
import { fileName, dirName, buildHighlightSegments, atEnd } from "./prompt-input-utils"
|
||||
import { fileName, dirName, buildHighlightSegments, atEnd, isPromptBusy } from "./prompt-input-utils"
|
||||
import type { ReviewComment, TextPart } from "../../types/messages"
|
||||
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
|
||||
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts"
|
||||
@@ -50,6 +50,10 @@ function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]
|
||||
|
||||
interface PromptInputProps {
|
||||
blocked?: () => boolean
|
||||
/** When true, session is busy only because a suggestion is pending — treat as idle for input */
|
||||
suggesting?: () => boolean
|
||||
/** When true, session is busy only because a question is pending — treat as idle for input */
|
||||
questioning?: () => boolean
|
||||
boxId?: string
|
||||
pendingSessionID?: string
|
||||
}
|
||||
@@ -61,10 +65,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const worktree = useWorktreeMode()
|
||||
const dialog = useDialog()
|
||||
const mention = useFileMention(vscode)
|
||||
const mention = useFileMention(
|
||||
vscode,
|
||||
() => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(),
|
||||
)
|
||||
const terminal = useTerminalContext(vscode)
|
||||
const excluded = worktree ? new Set(["sessions"]) : undefined
|
||||
const slash = useSlashCommand(vscode, excluded)
|
||||
const slash = useSlashCommand(vscode)
|
||||
const imageAttach = useImageAttachments()
|
||||
imageAttach.setFilePathDropHandler((paths) => {
|
||||
const cwd = server.workspaceDirectory()
|
||||
@@ -268,7 +274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
window.addEventListener("compactSession", onCompact)
|
||||
onCleanup(() => window.removeEventListener("compactSession", onCompact))
|
||||
|
||||
const isBusy = () => session.status() !== "idle"
|
||||
const isBusy = () => isPromptBusy(session.status(), !!props.suggesting?.(), !!props.questioning?.())
|
||||
const isDisabled = () => !server.isConnected()
|
||||
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
|
||||
const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.()
|
||||
@@ -711,7 +717,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="file-mention-dropdown" ref={dropdownRef}>
|
||||
<Show
|
||||
when={mention.mentionResults().length > 0}
|
||||
fallback={<div class="file-mention-empty">No files found</div>}
|
||||
fallback={<div class="file-mention-empty">No files or folders found</div>}
|
||||
>
|
||||
<For each={mention.mentionResults()}>
|
||||
{(item, index) => (
|
||||
@@ -732,8 +738,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileIcon node={{ path: item.value, type: "file" }} class="file-mention-icon" />
|
||||
<span class="file-mention-name">{fileName(item.value)}</span>
|
||||
<FileIcon
|
||||
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
|
||||
class="file-mention-icon"
|
||||
/>
|
||||
<span class="file-mention-name">
|
||||
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
|
||||
</span>
|
||||
<span class="file-mention-dir">{dirName(item.value)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show } from "solid-js"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useSession } from "../../context/session"
|
||||
import type { SuggestionRequest } from "../../types/messages"
|
||||
|
||||
export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => {
|
||||
const session = useSession()
|
||||
const language = useLanguage()
|
||||
|
||||
const error = () => session.suggestionErrors().has(props.request.id)
|
||||
const responding = () => session.respondingSuggestions().has(props.request.id)
|
||||
|
||||
const accept = (index: number) => {
|
||||
if (responding()) return
|
||||
session.acceptSuggestion(props.request.id, index)
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (responding()) return
|
||||
session.dismissSuggestion(props.request.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="suggest-bar">
|
||||
<div data-slot="suggest-bar-copy">
|
||||
<span data-slot="suggest-bar-icon">
|
||||
<Icon name="brain" size="small" />
|
||||
</span>
|
||||
<span data-slot="suggest-bar-text">{props.request.text}</span>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<div data-slot="suggest-bar-error">
|
||||
<Icon name="close" size="small" />
|
||||
<span>Failed — try again</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="suggest-bar-actions">
|
||||
<For each={props.request.actions}>
|
||||
{(action, index) => (
|
||||
<Button
|
||||
variant={index() === 0 ? "secondary" : "ghost"}
|
||||
size="small"
|
||||
disabled={responding()}
|
||||
onClick={() => accept(index())}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={responding()}
|
||||
label={language.t("common.dismiss")}
|
||||
onClick={dismiss}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,3 +49,47 @@ export function buildHighlightSegments(val: string, paths: Set<string>): { text:
|
||||
export function atEnd(start: number, end: number, len: number): boolean {
|
||||
return start === end && end === len
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the input prompt should be blocked.
|
||||
*
|
||||
* Only permission requests block the prompt in the VS Code webview. Questions
|
||||
* and suggestions never block — they are dismissed automatically when a new
|
||||
* message is sent (see session.tsx sendMessage/sendCommand).
|
||||
*
|
||||
* The single-parameter signature is intentional: taking question-count would
|
||||
* structurally allow a future regression to re-couple the prompt to pending
|
||||
* questions. Keep this function at one argument.
|
||||
*/
|
||||
export function isPromptBlocked(permissions: number): boolean {
|
||||
return permissions > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session is busy from the prompt's perspective.
|
||||
* Returns false (idle-like) when the session is busy only because
|
||||
* a suggestion or question tool call is pending.
|
||||
*/
|
||||
export function isPromptBusy(status: string, suggesting: boolean, questioning: boolean): boolean {
|
||||
return status !== "idle" && !suggesting && !questioning
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session is busy only because a suggestion is pending.
|
||||
* True when no permission request is blocking the prompt and at least one
|
||||
* suggestion is active. The `!blocked` gate keeps the Stop button available
|
||||
* when permissions block input — it does NOT mean suggestions block.
|
||||
*/
|
||||
export function isSuggesting(blocked: boolean, suggestions: number): boolean {
|
||||
return !blocked && suggestions > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session is busy only because a question is pending.
|
||||
* True when no permission request is blocking the prompt and at least one
|
||||
* question is active. The `!blocked` gate keeps the Stop button available
|
||||
* when permissions block input — it does NOT mean questions block.
|
||||
*/
|
||||
export function isQuestioning(blocked: boolean, questions: number): boolean {
|
||||
return !blocked && questions > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { useLanguage } from "../../context/language"
|
||||
|
||||
const REPO_URL = "https://github.com/Kilo-Org/kilo-marketplace"
|
||||
|
||||
export const MarketplaceContribute = () => {
|
||||
const vscode = useVSCode()
|
||||
const { t } = useLanguage()
|
||||
const open = () => vscode.postMessage({ type: "openExternal", url: REPO_URL })
|
||||
return (
|
||||
<div class="marketplace-contribute">
|
||||
<span>{t("marketplace.contribute.prompt")}</span>
|
||||
<Button variant="ghost" size="small" onClick={open}>
|
||||
{t("marketplace.contribute.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { isInstalled } from "./utils"
|
||||
import { ItemCard } from "./ItemCard"
|
||||
import { MarketplaceContribute } from "./MarketplaceContribute"
|
||||
|
||||
interface StatusOption {
|
||||
value: string
|
||||
@@ -124,7 +125,15 @@ export const MarketplaceListView = (props: Props) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={filtered().length > 0} fallback={<p class="marketplace-empty">{props.emptyMessage}</p>}>
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="marketplace-empty">
|
||||
<span class="marketplace-empty-message">{props.emptyMessage}</span>
|
||||
<MarketplaceContribute />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="marketplace-grid">
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
@@ -144,6 +153,7 @@ export const MarketplaceListView = (props: Props) => {
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<MarketplaceContribute />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -77,9 +77,39 @@
|
||||
}
|
||||
|
||||
.marketplace-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-weak);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.marketplace-empty-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Contribute CTA (footer + inside empty state) */
|
||||
|
||||
.marketplace-contribute {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--border-base, var(--vscode-widget-border, transparent));
|
||||
color: var(--text-weak);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Inside the empty state, drop the border — message is already the lead-in. */
|
||||
.marketplace-empty .marketplace-contribute {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
|
||||
@@ -34,9 +34,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
const { isDirty, saveConfig, discardConfig } = useConfig()
|
||||
const { isDirty, saving, saveError, saveConfig, discardConfig } = useConfig()
|
||||
const session = useSession()
|
||||
const [active, setActive] = createSignal(props.tab ?? "models")
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
|
||||
const busyCount = () => Object.values(session.allStatusMap()).filter((s) => s.type === "busy").length
|
||||
|
||||
@@ -222,19 +223,46 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
</Tabs>
|
||||
|
||||
{/* Save bar — slides in when there are unsaved config changes */}
|
||||
<div
|
||||
class={`settings-save-bar${isDirty() ? " settings-save-bar--visible" : ""}`}
|
||||
inert={!isDirty() || undefined}
|
||||
aria-hidden={!isDirty()}
|
||||
>
|
||||
<span class="settings-save-bar-label">{language.t("settings.saveBar.unsavedChanges")}</span>
|
||||
<Button variant="ghost" size="small" onClick={discardConfig}>
|
||||
{language.t("settings.saveBar.discard")}
|
||||
</Button>
|
||||
<Button variant="primary" size="small" onClick={handleSave}>
|
||||
{language.t("settings.saveBar.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={isDirty()}>
|
||||
<div class="settings-save-bar-wrap">
|
||||
<Show when={saveError()}>
|
||||
{(err) => (
|
||||
<div class="settings-save-bar-error">
|
||||
<div
|
||||
class="settings-save-bar-error-header"
|
||||
onClick={() => setErrorExpanded((v) => !v)}
|
||||
role="button"
|
||||
aria-expanded={errorExpanded()}
|
||||
>
|
||||
<span
|
||||
class={`settings-save-bar-error-chevron${
|
||||
errorExpanded() ? " settings-save-bar-error-chevron-expanded" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon name="chevron-right" size="small" />
|
||||
</span>
|
||||
<span class="settings-save-bar-error-title">
|
||||
{language.t("settings.saveBar.saveFailed")}:{" "}
|
||||
<span class="settings-save-bar-error-firstline">{err().message}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Show when={errorExpanded()}>
|
||||
<pre class="settings-save-bar-error-details">{err().details ?? err().message}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="settings-save-bar">
|
||||
<span class="settings-save-bar-label">{language.t("settings.saveBar.unsavedChanges")}</span>
|
||||
<Button variant="ghost" size="small" onClick={discardConfig} disabled={saving()}>
|
||||
{language.t("settings.saveBar.discard")}
|
||||
</Button>
|
||||
<Button variant="primary" size="small" onClick={handleSave} disabled={saving()}>
|
||||
{saving() ? language.t("settings.saveBar.saving") : language.t("settings.saveBar.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ export const WorkingIndicator: Component = () => {
|
||||
.permissions()
|
||||
.filter((p) => p.sessionID === id && !(p.tool && ["todowrite", "todoread"].includes(p.toolName)))
|
||||
const questions = session.questions().filter((q) => q.sessionID === id)
|
||||
return perms.length > 0 || questions.length > 0
|
||||
const suggestions = session.suggestions().filter((s) => s.sessionID === id)
|
||||
return perms.length > 0 || questions.length > 0 || suggestions.length > 0
|
||||
}
|
||||
|
||||
const isRetrying = () => session.statusInfo().type === "retry"
|
||||
|
||||
@@ -14,10 +14,17 @@ import { useVSCode } from "./vscode"
|
||||
import type { Config, ExtensionMessage } from "../types/messages"
|
||||
import { deepMerge, stripNulls, resolveConfig } from "../utils/config-utils"
|
||||
|
||||
export interface SaveError {
|
||||
message: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
interface ConfigContextValue {
|
||||
config: Accessor<Config>
|
||||
loading: Accessor<boolean>
|
||||
isDirty: Accessor<boolean>
|
||||
saving: Accessor<boolean>
|
||||
saveError: Accessor<SaveError | null>
|
||||
updateConfig: (partial: Partial<Config>) => void
|
||||
saveConfig: () => void
|
||||
discardConfig: () => void
|
||||
@@ -36,7 +43,10 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
const [saved, setSaved] = createSignal<Config>({})
|
||||
// True while a saveConfig() write is in-flight — used to clear draft on success
|
||||
// and to guard against stale configLoaded messages overwriting optimistic state.
|
||||
let saving = false
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
// Error from the most recent saveConfig() attempt, or null if no error.
|
||||
// Cleared when the user edits the draft again or starts a new save.
|
||||
const [saveError, setSaveError] = createSignal<SaveError | null>(null)
|
||||
|
||||
// Register handler immediately (not in onMount) so we never miss
|
||||
// a configLoaded message that arrives before the DOM mount.
|
||||
@@ -44,7 +54,7 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
if (message.type === "configLoaded") {
|
||||
// Skip if a save is in-flight — a stale configLoaded must not overwrite
|
||||
// the optimistically-updated state while the write is being confirmed.
|
||||
if (saving) return
|
||||
if (saving()) return
|
||||
// Re-apply the draft on top so pending changes (e.g. a toggled switch the
|
||||
// user hasn't saved yet) stay visible instead of snapping back.
|
||||
setConfig(resolveConfig(message.config, draft(), isDirty()))
|
||||
@@ -53,12 +63,13 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
return
|
||||
}
|
||||
if (message.type === "configUpdated") {
|
||||
if (saving) {
|
||||
if (saving()) {
|
||||
// This configUpdated is the confirmation of our saveConfig() write.
|
||||
// Clear the draft now that the server has confirmed the write.
|
||||
saving = false
|
||||
setSaving(false)
|
||||
setDraft({})
|
||||
setIsDirty(false)
|
||||
setSaveError(null)
|
||||
setConfig(message.config)
|
||||
} else {
|
||||
// configUpdated from a different source (e.g. PermissionDock save).
|
||||
@@ -68,6 +79,13 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
setSaved(message.config)
|
||||
return
|
||||
}
|
||||
if (message.type === "configUpdateFailed") {
|
||||
// The write was rejected (e.g. schema validation) — surface the error
|
||||
// and keep the draft + isDirty so the user can correct and retry.
|
||||
setSaving(false)
|
||||
setSaveError({ message: message.message, details: message.details })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(unsubscribe)
|
||||
@@ -102,6 +120,9 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
// Accumulate in draft — will be sent on saveConfig()
|
||||
setDraft((prev) => deepMerge(prev as Config, partial))
|
||||
setIsDirty(true)
|
||||
// Clear any stale error from a previous failed save — the user is editing
|
||||
// again, so the old error message no longer reflects the current draft.
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
@@ -109,7 +130,8 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
if (Object.keys(changes).length === 0) return
|
||||
// Don't clear draft/isDirty yet — wait for configUpdated confirmation.
|
||||
// If the write fails, the save bar stays visible so the user can retry.
|
||||
saving = true
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
vscode.postMessage({ type: "updateConfig", config: changes })
|
||||
}
|
||||
|
||||
@@ -117,12 +139,15 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
setConfig(saved())
|
||||
setDraft({})
|
||||
setIsDirty(false)
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const value: ConfigContextValue = {
|
||||
config,
|
||||
loading,
|
||||
isDirty,
|
||||
saving,
|
||||
saveError,
|
||||
updateConfig,
|
||||
saveConfig,
|
||||
discardConfig,
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SessionStatusInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SuggestionRequest,
|
||||
TodoItem,
|
||||
ModelSelection,
|
||||
ContextUsage,
|
||||
@@ -114,10 +115,14 @@ interface SessionContextValue {
|
||||
// Pending question requests (unscoped — all tracked sessions)
|
||||
questions: Accessor<QuestionRequest[]>
|
||||
questionErrors: Accessor<Set<string>>
|
||||
suggestions: Accessor<SuggestionRequest[]>
|
||||
suggestionErrors: Accessor<Set<string>>
|
||||
respondingSuggestions: Accessor<Set<string>>
|
||||
|
||||
// Scoped permissions/questions — filtered to a session's family (self + subagents)
|
||||
scopedPermissions: (sessionID: string | undefined) => PermissionRequest[]
|
||||
scopedQuestions: (sessionID: string | undefined) => QuestionRequest[]
|
||||
scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[]
|
||||
|
||||
// Model selection (global, extension-lifetime)
|
||||
selected: Accessor<ModelSelection | null>
|
||||
@@ -192,6 +197,8 @@ interface SessionContextValue {
|
||||
) => void
|
||||
replyToQuestion: (requestID: string, answers: string[][]) => void
|
||||
rejectQuestion: (requestID: string) => void
|
||||
acceptSuggestion: (requestID: string, index: number) => void
|
||||
dismissSuggestion: (requestID: string) => void
|
||||
createSession: () => void
|
||||
clearCurrentSession: () => void
|
||||
loadSessions: () => void
|
||||
@@ -251,6 +258,9 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
// Tracks question IDs that failed so the UI can reset sending state
|
||||
const [questionErrors, setQuestionErrors] = createSignal<Set<string>>(new Set())
|
||||
const [suggestions, setSuggestions] = createSignal<SuggestionRequest[]>([])
|
||||
const [suggestionErrors, setSuggestionErrors] = createSignal<Set<string>>(new Set())
|
||||
const [respondingSuggestions, setRespondingSuggestions] = createSignal<Set<string>>(new Set())
|
||||
|
||||
// Tracks whether the user has explicitly set a model override per agent (to
|
||||
// prevent the default-sync effect from overwriting it).
|
||||
@@ -641,123 +651,144 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
vscode.postMessage({ type: "toggleFavorite", action, providerID, modelID })
|
||||
}
|
||||
|
||||
function handleStreamMessage(message: ExtensionMessage): boolean {
|
||||
if (message.type === "partUpdated") {
|
||||
handlePartUpdated(message.sessionID, message.messageID, message.part, message.delta)
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === "partsUpdated") {
|
||||
batch(() => {
|
||||
for (const update of message.updates) {
|
||||
handlePartUpdated(update.sessionID, update.messageID, update.part, update.delta)
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function handleExtensionMessage(message: ExtensionMessage): void {
|
||||
// Route suggestion messages (extracted to stay within complexity limit)
|
||||
routeSuggestionMessage(message)
|
||||
if (handleStreamMessage(message)) return
|
||||
switch (message.type) {
|
||||
case "sessionCreated":
|
||||
handleSessionCreated(message.session, message.draftID)
|
||||
break
|
||||
|
||||
case "messagesLoaded":
|
||||
handleMessagesLoaded(message.sessionID, message.messages)
|
||||
break
|
||||
|
||||
case "messageCreated":
|
||||
handleMessageCreated(message.message)
|
||||
break
|
||||
|
||||
case "sessionStatus":
|
||||
handleSessionStatus(message.sessionID, message.status, message.attempt, message.message, message.next)
|
||||
break
|
||||
|
||||
case "todoUpdated":
|
||||
handleTodoUpdated(message.sessionID, message.items)
|
||||
break
|
||||
|
||||
case "questionRequest":
|
||||
handleQuestionRequest(message.question)
|
||||
break
|
||||
|
||||
case "questionResolved":
|
||||
handleQuestionResolved(message.requestID)
|
||||
break
|
||||
|
||||
case "questionError":
|
||||
handleQuestionError(message.requestID)
|
||||
break
|
||||
|
||||
case "clearPendingPrompts":
|
||||
setPermissions([])
|
||||
setQuestions([])
|
||||
setSuggestions([])
|
||||
setRespondingPermissions(new Set<string>())
|
||||
setSuggestionErrors(new Set<string>())
|
||||
setRespondingSuggestions(new Set<string>())
|
||||
break
|
||||
|
||||
case "sessionsLoaded":
|
||||
handleSessionsLoaded(message.sessions, message.preserveSessionIds)
|
||||
break
|
||||
|
||||
case "sessionUpdated":
|
||||
setStore("sessions", message.session.id, message.session)
|
||||
break
|
||||
|
||||
case "sessionDeleted":
|
||||
handleSessionDeleted(message.sessionID)
|
||||
break
|
||||
|
||||
case "messageRemoved":
|
||||
handleMessageRemoved(message.sessionID, message.messageID)
|
||||
break
|
||||
|
||||
case "sessionError": {
|
||||
if (message.error?.name === "MessageAbortedError") break
|
||||
const sid = message.sessionID ?? currentSessionID()
|
||||
if (!sid) break
|
||||
// Find the last user message in this session to use as parentID
|
||||
const msgs = store.messages[sid] ?? []
|
||||
const parent = [...msgs].reverse().find((m) => m.role === "user")
|
||||
const errorMsg: Message = {
|
||||
id: Identifier.ascending("message"),
|
||||
sessionID: sid,
|
||||
role: "assistant",
|
||||
createdAt: new Date().toISOString(),
|
||||
parentID: parent?.id,
|
||||
error: message.error,
|
||||
}
|
||||
handleMessageCreated(errorMsg)
|
||||
break
|
||||
}
|
||||
|
||||
case "error":
|
||||
// Only clear loading if the error is for the current session
|
||||
// (or has no sessionID for backwards compatibility)
|
||||
if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false)
|
||||
break
|
||||
|
||||
case "sendMessageFailed":
|
||||
handleSendMessageFailed(message as unknown as SendMessageFailedMessage)
|
||||
break
|
||||
|
||||
case "cloudSessionDataLoaded":
|
||||
handleCloudSessionDataLoaded(message.cloudSessionId, message.title, message.messages)
|
||||
break
|
||||
|
||||
case "cloudSessionImported":
|
||||
handleCloudSessionImported(message.cloudSessionId, message.session)
|
||||
break
|
||||
|
||||
case "cloudSessionImportFailed":
|
||||
setCloudPreviewId(null)
|
||||
setCurrentSessionID(undefined)
|
||||
setLoading(false)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session",
|
||||
description: message.error,
|
||||
})
|
||||
console.error("[Kilo New] Cloud session import failed:", message.error)
|
||||
break
|
||||
|
||||
case "worktreeStatsLoaded":
|
||||
setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Handle messages from extension
|
||||
onMount(() => {
|
||||
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
|
||||
switch (message.type) {
|
||||
case "sessionCreated":
|
||||
handleSessionCreated(message.session, message.draftID)
|
||||
break
|
||||
|
||||
case "messagesLoaded":
|
||||
handleMessagesLoaded(message.sessionID, message.messages)
|
||||
break
|
||||
|
||||
case "messageCreated":
|
||||
handleMessageCreated(message.message)
|
||||
break
|
||||
|
||||
case "partUpdated":
|
||||
handlePartUpdated(message.sessionID, message.messageID, message.part, message.delta)
|
||||
break
|
||||
|
||||
case "sessionStatus":
|
||||
handleSessionStatus(message.sessionID, message.status, message.attempt, message.message, message.next)
|
||||
break
|
||||
|
||||
case "todoUpdated":
|
||||
handleTodoUpdated(message.sessionID, message.items)
|
||||
break
|
||||
|
||||
case "questionRequest":
|
||||
handleQuestionRequest(message.question)
|
||||
break
|
||||
|
||||
case "questionResolved":
|
||||
handleQuestionResolved(message.requestID)
|
||||
break
|
||||
|
||||
case "questionError":
|
||||
handleQuestionError(message.requestID)
|
||||
break
|
||||
|
||||
case "clearPendingPrompts":
|
||||
setPermissions([])
|
||||
setQuestions([])
|
||||
setRespondingPermissions(new Set<string>())
|
||||
break
|
||||
|
||||
case "sessionsLoaded":
|
||||
handleSessionsLoaded(message.sessions, message.preserveSessionIds)
|
||||
break
|
||||
|
||||
case "sessionUpdated":
|
||||
setStore("sessions", message.session.id, message.session)
|
||||
break
|
||||
|
||||
case "sessionDeleted":
|
||||
handleSessionDeleted(message.sessionID)
|
||||
break
|
||||
|
||||
case "messageRemoved":
|
||||
handleMessageRemoved(message.sessionID, message.messageID)
|
||||
break
|
||||
|
||||
case "sessionError": {
|
||||
if (message.error?.name === "MessageAbortedError") break
|
||||
const sid = message.sessionID ?? currentSessionID()
|
||||
if (!sid) break
|
||||
// Find the last user message in this session to use as parentID
|
||||
const msgs = store.messages[sid] ?? []
|
||||
const parent = [...msgs].reverse().find((m) => m.role === "user")
|
||||
const errorMsg: Message = {
|
||||
id: Identifier.ascending("message"),
|
||||
sessionID: sid,
|
||||
role: "assistant",
|
||||
createdAt: new Date().toISOString(),
|
||||
parentID: parent?.id,
|
||||
error: message.error,
|
||||
}
|
||||
handleMessageCreated(errorMsg)
|
||||
break
|
||||
}
|
||||
|
||||
case "error":
|
||||
// Only clear loading if the error is for the current session
|
||||
// (or has no sessionID for backwards compatibility)
|
||||
if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false)
|
||||
break
|
||||
|
||||
case "sendMessageFailed":
|
||||
handleSendMessageFailed(message as unknown as SendMessageFailedMessage)
|
||||
break
|
||||
|
||||
case "cloudSessionDataLoaded":
|
||||
handleCloudSessionDataLoaded(message.cloudSessionId, message.title, message.messages)
|
||||
break
|
||||
|
||||
case "cloudSessionImported":
|
||||
handleCloudSessionImported(message.cloudSessionId, message.session)
|
||||
break
|
||||
|
||||
case "cloudSessionImportFailed":
|
||||
setCloudPreviewId(null)
|
||||
setCurrentSessionID(undefined)
|
||||
setLoading(false)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session",
|
||||
description: message.error,
|
||||
})
|
||||
console.error("[Kilo New] Cloud session import failed:", message.error)
|
||||
break
|
||||
|
||||
case "worktreeStatsLoaded":
|
||||
setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions })
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribe = vscode.onMessage(handleExtensionMessage)
|
||||
onCleanup(unsubscribe)
|
||||
})
|
||||
|
||||
@@ -1001,6 +1032,60 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
setQuestionErrors((prev) => new Set(prev).add(requestID))
|
||||
}
|
||||
|
||||
function handleSuggestionRequest(suggestion: SuggestionRequest) {
|
||||
setSuggestions((prev) => {
|
||||
const idx = prev.findIndex((item) => item.id === suggestion.id)
|
||||
if (idx === -1) return [...prev, suggestion]
|
||||
const next = prev.slice()
|
||||
next[idx] = suggestion
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleSuggestionResolved(requestID: string) {
|
||||
setSuggestions((prev) => prev.filter((item) => item.id !== requestID))
|
||||
setRespondingSuggestions((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
setSuggestionErrors((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleSuggestionError(requestID: string) {
|
||||
setRespondingSuggestions((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
setSuggestionErrors((prev) => new Set(prev).add(requestID))
|
||||
}
|
||||
|
||||
/**
|
||||
* Route suggestion-related extension messages.
|
||||
* Extracted from the main message handler to stay within the complexity limit.
|
||||
*/
|
||||
function routeSuggestionMessage(message: ExtensionMessage) {
|
||||
switch (message.type) {
|
||||
case "suggestionRequest":
|
||||
handleSuggestionRequest(message.suggestion)
|
||||
break
|
||||
case "suggestionResolved":
|
||||
handleSuggestionResolved(message.requestID)
|
||||
break
|
||||
case "suggestionError":
|
||||
handleSuggestionError(message.requestID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed send: remove the optimistic message from the store
|
||||
* and show a toast. The PromptInput restores the draft text separately
|
||||
@@ -1117,6 +1202,12 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return questions().filter((q) => family.has(q.sessionID))
|
||||
}
|
||||
|
||||
function scopedSuggestions(sessionID: string | undefined): SuggestionRequest[] {
|
||||
if (!sessionID) return []
|
||||
const family = sessionFamily(sessionID)
|
||||
return suggestions().filter((item) => family.has(item.sessionID))
|
||||
}
|
||||
|
||||
function handleTodoUpdated(sessionID: string, items: TodoItem[]) {
|
||||
setStore("todos", sessionID, items)
|
||||
}
|
||||
@@ -1197,6 +1288,24 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return next
|
||||
})
|
||||
}
|
||||
const gone = suggestions()
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.map((item) => item.id)
|
||||
if (gone.length > 0) {
|
||||
setSuggestions((prev) => prev.filter((item) => item.sessionID !== sessionID))
|
||||
setSuggestionErrors((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
setRespondingSuggestions((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of gone) next.delete(id)
|
||||
if (next.size === prev.size) return prev
|
||||
return next
|
||||
})
|
||||
}
|
||||
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
|
||||
setStatusMap(
|
||||
produce((map) => {
|
||||
@@ -1409,6 +1518,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
const sid = currentSessionID()
|
||||
const suggestion = scopedSuggestions(sid)[0]
|
||||
if (suggestion) dismissSuggestion(suggestion.id)
|
||||
for (const q of scopedQuestions(sid)) {
|
||||
rejectQuestion(q.id)
|
||||
}
|
||||
if (sid) addOptimistic(sid, messageID, text, files)
|
||||
|
||||
const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined
|
||||
@@ -1462,6 +1576,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const sid = currentSessionID()
|
||||
const suggestion = scopedSuggestions(sid)[0]
|
||||
if (suggestion) dismissSuggestion(suggestion.id)
|
||||
for (const q of scopedQuestions(sid)) {
|
||||
rejectQuestion(q.id)
|
||||
}
|
||||
|
||||
if (sid) addOptimistic(sid, messageID, `/${command} ${args}`.trim(), files)
|
||||
|
||||
@@ -1552,6 +1671,15 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
function clearSuggestionError(requestID: string) {
|
||||
setSuggestionErrors((prev) => {
|
||||
if (!prev.has(requestID)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(requestID)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function replyToQuestion(requestID: string, answers: string[][]) {
|
||||
clearQuestionError(requestID)
|
||||
const question = questions().find((item) => item.id === requestID)
|
||||
@@ -1575,6 +1703,29 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
function acceptSuggestion(requestID: string, index: number) {
|
||||
clearSuggestionError(requestID)
|
||||
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
|
||||
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
|
||||
vscode.postMessage({
|
||||
type: "suggestionAccept",
|
||||
requestID,
|
||||
sessionID: sid,
|
||||
index,
|
||||
})
|
||||
}
|
||||
|
||||
function dismissSuggestion(requestID: string) {
|
||||
clearSuggestionError(requestID)
|
||||
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
|
||||
const sid = suggestions().find((s) => s.id === requestID)?.sessionID ?? currentSessionID() ?? ""
|
||||
vscode.postMessage({
|
||||
type: "suggestionDismiss",
|
||||
requestID,
|
||||
sessionID: sid,
|
||||
})
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
if (!server.isConnected()) {
|
||||
console.warn("[Kilo New] Cannot create session: not connected")
|
||||
@@ -1819,8 +1970,12 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
respondingPermissions,
|
||||
questions,
|
||||
questionErrors,
|
||||
suggestions,
|
||||
suggestionErrors,
|
||||
respondingSuggestions,
|
||||
scopedPermissions,
|
||||
scopedQuestions,
|
||||
scopedSuggestions,
|
||||
selected,
|
||||
selectModel,
|
||||
hasModelOverride,
|
||||
@@ -1882,6 +2037,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
respondToPermission,
|
||||
replyToQuestion,
|
||||
rejectQuestion,
|
||||
acceptSuggestion,
|
||||
dismissSuggestion,
|
||||
createSession,
|
||||
clearCurrentSession,
|
||||
loadSessions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileAttachment } from "../types/messages"
|
||||
import type { FileAttachment, FileSearchItem } from "../types/messages"
|
||||
import { TERMINAL_MENTION } from "./terminal-context-utils"
|
||||
|
||||
export const AT_PATTERN = /(?:^|\s)@(\S*)$/
|
||||
@@ -6,6 +6,7 @@ export const AT_PATTERN = /(?:^|\s)@(\S*)$/
|
||||
export type MentionResult =
|
||||
| { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string }
|
||||
| { type: "file"; value: string }
|
||||
| { type: "folder"; value: string }
|
||||
|
||||
export const TERMINAL_RESULT: MentionResult = {
|
||||
type: "terminal",
|
||||
@@ -27,8 +28,13 @@ export function getTerminalMentionResult(query: string): MentionResult[] {
|
||||
return [TERMINAL_RESULT]
|
||||
}
|
||||
|
||||
export function buildMentionResults(query: string, paths: string[]): MentionResult[] {
|
||||
return [...getTerminalMentionResult(query), ...paths.map((path) => ({ type: "file" as const, value: path }))]
|
||||
export function buildMentionResults(query: string, items: Array<FileSearchItem | string>): MentionResult[] {
|
||||
const results: MentionResult[] = items.map((item) => {
|
||||
if (typeof item === "string") return { type: "file", value: item }
|
||||
if (item.type === "folder") return { type: "folder", value: item.path }
|
||||
return { type: "file", value: item.path }
|
||||
})
|
||||
return [...getTerminalMentionResult(query), ...results]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +56,7 @@ export function syncMentionedPaths(prev: Set<string>, text: string): Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the @mention pattern before the cursor with the selected file path.
|
||||
* Replace the @mention pattern before the cursor with the selected path.
|
||||
* Returns the new text string.
|
||||
*/
|
||||
export function buildTextAfterMentionSelect(before: string, after: string, path: string): string {
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface FileMention {
|
||||
addPaths: (paths: string[], cwd: string) => void
|
||||
}
|
||||
|
||||
export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor<string | undefined>): FileMention {
|
||||
const [mentionedPaths, setMentionedPaths] = createSignal<Set<string>>(new Set())
|
||||
const [mentionQuery, setMentionQuery] = createSignal<string | null>(null)
|
||||
const [mentionResults, setMentionResults] = createSignal<MentionResult[]>([])
|
||||
@@ -60,10 +60,10 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (message.type !== "fileSearchResult") return
|
||||
const result = message as { type: "fileSearchResult"; paths: string[]; dir: string; requestId: string }
|
||||
if (result.requestId === `file-search-${fileSearchCounter}`) {
|
||||
workspaceDir = result.dir
|
||||
setMentionResults(buildMentionResults(mentionQuery() ?? "", result.paths))
|
||||
if (message.requestId === `file-search-${fileSearchCounter}`) {
|
||||
const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const }))
|
||||
workspaceDir = message.dir
|
||||
setMentionResults(buildMentionResults(mentionQuery() ?? "", items))
|
||||
setMentionIndex(0)
|
||||
}
|
||||
})
|
||||
@@ -77,7 +77,13 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
if (fileSearchTimer) clearTimeout(fileSearchTimer)
|
||||
fileSearchTimer = setTimeout(() => {
|
||||
fileSearchCounter++
|
||||
vscode.postMessage({ type: "requestFileSearch", query, requestId: `file-search-${fileSearchCounter}` })
|
||||
const id = sessionID?.()
|
||||
vscode.postMessage({
|
||||
type: "requestFileSearch",
|
||||
query,
|
||||
requestId: `file-search-${fileSearchCounter}`,
|
||||
...(id ? { sessionID: id } : {}),
|
||||
})
|
||||
}, FILE_SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
@@ -110,7 +116,8 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
|
||||
textarea.setSelectionRange(pos, pos)
|
||||
textarea.focus()
|
||||
|
||||
if (result.type === "file") setMentionedPaths((prev) => new Set([...prev, result.value]))
|
||||
if (result.type === "file" || result.type === "folder")
|
||||
setMentionedPaths((prev) => new Set([...prev, result.value]))
|
||||
closeMention()
|
||||
onSelect?.()
|
||||
}
|
||||
|
||||
+2
@@ -1387,6 +1387,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "عدة جلسات تعمل وستتوقف",
|
||||
"settings.saveBar.saveAnyway": "حفظ على أي حال",
|
||||
"settings.saveBar.cancel": "إلغاء",
|
||||
"settings.saveBar.saving": "جارٍ الحفظ…",
|
||||
"settings.saveBar.saveFailed": "تعذر حفظ الإعدادات",
|
||||
"notifications.action.next": "التالي",
|
||||
"notifications.action.close": "إغلاق",
|
||||
"notifications.action.tryModel": "جرّب {{model}}",
|
||||
|
||||
+2
@@ -1416,6 +1416,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Várias sessões estão em execução e serão interrompidas",
|
||||
"settings.saveBar.saveAnyway": "Salvar mesmo assim",
|
||||
"settings.saveBar.cancel": "Cancelar",
|
||||
"settings.saveBar.saving": "Salvando…",
|
||||
"settings.saveBar.saveFailed": "Não foi possível salvar as configurações",
|
||||
"notifications.action.next": "Próximo",
|
||||
"notifications.action.close": "Fechar",
|
||||
"notifications.action.tryModel": "Experimentar {{model}}",
|
||||
|
||||
+2
@@ -1412,6 +1412,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Nekoliko sesija je pokrenuto i bit će prekinuto",
|
||||
"settings.saveBar.saveAnyway": "Spremi svejedno",
|
||||
"settings.saveBar.cancel": "Otkaži",
|
||||
"settings.saveBar.saving": "Spremanje…",
|
||||
"settings.saveBar.saveFailed": "Postavke nije moguće spremiti",
|
||||
"notifications.action.next": "Sljedeći",
|
||||
"notifications.action.close": "Zatvori",
|
||||
"notifications.action.tryModel": "Probaj {{model}}",
|
||||
|
||||
+2
@@ -1403,6 +1403,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Flere sessioner kører og vil blive afbrudt",
|
||||
"settings.saveBar.saveAnyway": "Gem alligevel",
|
||||
"settings.saveBar.cancel": "Annuller",
|
||||
"settings.saveBar.saving": "Gemmer…",
|
||||
"settings.saveBar.saveFailed": "Kunne ikke gemme indstillinger",
|
||||
"notifications.action.next": "Næste",
|
||||
"notifications.action.close": "Luk",
|
||||
"notifications.action.tryModel": "Prøv {{model}}",
|
||||
|
||||
+2
@@ -1430,6 +1430,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Mehrere Sitzungen laufen und werden unterbrochen",
|
||||
"settings.saveBar.saveAnyway": "Trotzdem speichern",
|
||||
"settings.saveBar.cancel": "Abbrechen",
|
||||
"settings.saveBar.saving": "Speichern…",
|
||||
"settings.saveBar.saveFailed": "Einstellungen konnten nicht gespeichert werden",
|
||||
"notifications.action.next": "Weiter",
|
||||
"notifications.action.close": "Schließen",
|
||||
"notifications.action.tryModel": "{{model}} ausprobieren",
|
||||
|
||||
@@ -1408,10 +1408,12 @@ export const dict = {
|
||||
"settings.saveBar.unsavedChanges": "Unsaved changes",
|
||||
"settings.saveBar.discard": "Discard",
|
||||
"settings.saveBar.save": "Save",
|
||||
"settings.saveBar.saving": "Saving…",
|
||||
"settings.saveBar.warning.one": "One session is running and will be interrupted",
|
||||
"settings.saveBar.warning.many": "Several sessions are running and will be interrupted",
|
||||
"settings.saveBar.saveAnyway": "Save anyway",
|
||||
"settings.saveBar.cancel": "Cancel",
|
||||
"settings.saveBar.saveFailed": "Couldn't save settings",
|
||||
|
||||
"notifications.action.next": "Next",
|
||||
"notifications.action.close": "Close",
|
||||
|
||||
+2
@@ -1419,6 +1419,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.many": "Varias sesiones están en ejecución y se interrumpirán",
|
||||
"settings.saveBar.saveAnyway": "Guardar de todas formas",
|
||||
"settings.saveBar.cancel": "Cancelar",
|
||||
"settings.saveBar.saving": "Guardando…",
|
||||
"settings.saveBar.saveFailed": "No se pudieron guardar los ajustes",
|
||||
"notifications.action.next": "Siguiente",
|
||||
"notifications.action.close": "Cerrar",
|
||||
"notifications.action.tryModel": "Probar {{model}}",
|
||||
|
||||
+2
@@ -1434,6 +1434,8 @@ export const dict = {
|
||||
"settings.saveBar.warning.one": "Une session est en cours et sera interrompue",
|
||||
"settings.saveBar.warning.many": "Plusieurs sessions sont en cours et seront interrompues",
|
||||
"settings.saveBar.saveAnyway": "Enregistrer quand même",
|
||||
"settings.saveBar.saving": "Enregistrement…",
|
||||
"settings.saveBar.saveFailed": "Impossible d'enregistrer les paramètres",
|
||||
"settings.saveBar.cancel": "Annuler",
|
||||
"notifications.action.next": "Suivant",
|
||||
"notifications.action.close": "Fermer",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user