Merge branch 'main' into mark/remove-oc-desktop-apps

This commit is contained in:
Mark IJbema
2026-05-04 17:29:27 +02:00
committed by GitHub
64 changed files with 2200 additions and 408 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Pin the VS Code sidebar question dock above the prompt input so standalone follow-up questions no longer float in the middle of the viewport when the message list is short.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Support rendering Markdown files in diff viewers with a persisted toggle.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show an "Initializing snapshot…" line in the chat while the initial snapshot is running on very large repositories, and add an interactive prompt when it stalls. After 10 seconds (configurable via `KILO_SNAPSHOT_TRACK_TIMEOUT_MS`) the prompt asks whether to keep waiting or disable snapshots for the project; choosing to disable writes `"snapshot": false` to `.kilo/kilo.json` so future sessions skip snapshots entirely.
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4hLG583KyBLxAOYvIiVMh+xLtVAdR3QgWFEqnGlZLZw=",
"aarch64-linux": "sha256-+60DxQ/GKAAoMFuAQRFoYM0HlZMzpLpLrTD7qmweUUA=",
"aarch64-darwin": "sha256-3WCWVqaxu+lCIc51N7wOttM/hoXbbnmtw7EP2eGstQg=",
"x86_64-darwin": "sha256-K8iG+OHkgC/lwud6hQ91sXF3c7UbdQQYRU/KK2P+B/4="
"x86_64-linux": "sha256-uJCsRBzfrhiUZWjBWAZDpNVnDFfDGzlKFF7qrFSO6TY=",
"aarch64-linux": "sha256-7aQyGNG/HMnKIX8fHd59aTLYytbYz2Ds1jkNOQPBRpk=",
"aarch64-darwin": "sha256-JB5jfoz5Jmj3cQL5TA1OqZHUSKjqCjsh1dNwwWZYeX8=",
"x86_64-darwin": "sha256-fMNP+N+mrDPVGFpZZyKc6wdkL6mSb1z5XSWbhAx2bEQ="
}
}
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4edbb48beaf828c898636124e4d225490e70fe2e16bcb15a3ae40a5dbe2e7167
size 29458
oid sha256:0ac3951c6ce87f25da24a146dd5420528392720e081688646b67f33632bb6152
size 28951
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:84fc57aa26819d69d570d07faeb77030d146158a6b1d4cfb45b79f1f317c30ba
size 18278
oid sha256:381b72a63cc256f520e671460431f9dc9950d27fd727d0779d1a6b558c7593cd
size 18195
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12643a0a583f41595bd8651d92d31d5be424ea77242843b0017364ce3c280b1f
size 24611
oid sha256:5a0460abb79716b44ec7e9eb42c8d364c2e6737fc79042ca6b715dffe33b1a74
size 24548
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "المتابعة هنا",
"plan.followup.answer.continue.description": "نفّذ الخطة في هذه الجلسة",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "اللقطة بطيئة",
"snapshot.slowRepo.question":
"يستغرق تهيئة نظام اللقطات وقتًا طويلاً، غالبًا بسبب حجم المستودع.\n\nهل تريد تعطيل اللقطات لهذا المستودع؟",
"snapshot.slowRepo.answer.continue": "المتابعة مع اللقطات",
"snapshot.slowRepo.answer.continue.description":
"استمر في الانتظار حتى تكتمل اللقطة. تكون الدورات اللاحقة سريعة بعد إنشاء اللقطة الأولى.",
"snapshot.slowRepo.answer.disable": "تعطيل لهذا المشروع",
"snapshot.slowRepo.answer.disable.description":
"أوقف لقطات Kilo لهذا المشروع. ستفقد ميزة التراجع/الإعادة لتعديلات Kilo، لكن git سيستمر في تتبع كل شيء.",
"ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Continuar aqui",
"plan.followup.answer.continue.description": "Implementar o plano nesta sessão",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Snapshot está lento",
"snapshot.slowRepo.question":
"Está demorando muito para inicializar o sistema de snapshots, provavelmente por causa do tamanho do repositório.\n\nDeseja desativar os snapshots para este repositório?",
"snapshot.slowRepo.answer.continue": "Continuar com snapshots",
"snapshot.slowRepo.answer.continue.description":
"Aguarde a conclusão do snapshot. Os próximos turnos serão rápidos depois que o snapshot inicial for criado.",
"snapshot.slowRepo.answer.disable": "Desativar para este projeto",
"snapshot.slowRepo.answer.disable.description":
"Desligue os snapshots do Kilo para este projeto. Você perde desfazer/refazer das mudanças feitas pelo Kilo, mas o git continua rastreando tudo.",
"ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças",
}
+11
View File
@@ -85,5 +85,16 @@ export const dict = {
"plan.followup.answer.continue": "Nastavi ovdje",
"plan.followup.answer.continue.description": "Implementiraj plan u ovoj sesiji",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Snapshot je spor",
"snapshot.slowRepo.question":
"Inicijalizacija sistema snapshotova traje dugo, vjerovatno zbog veličine repozitorija.\n\nŽelite li onemogućiti snapshotove za ovaj repozitorij?",
"snapshot.slowRepo.answer.continue": "Nastavi sa snapshotovima",
"snapshot.slowRepo.answer.continue.description":
"Sačekaj da se snapshot završi. Naredni potezi su brzi kada se početni snapshot jednom napravi.",
"snapshot.slowRepo.answer.disable": "Onemogući za ovaj projekat",
"snapshot.slowRepo.answer.disable.description":
"Isključi Kilo snapshotove za ovaj projekat. Izgubićete poništi/vrati za izmjene koje napravi Kilo, ali git i dalje prati sve.",
"ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Fortsæt her",
"plan.followup.answer.continue.description": "Implementér planen i denne session",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Snapshot er langsomt",
"snapshot.slowRepo.question":
"Det tager lang tid at initialisere snapshot-systemet, sandsynligvis på grund af størrelsen på repositoryet.\n\nVil du deaktivere snapshots for dette repository?",
"snapshot.slowRepo.answer.continue": "Fortsæt med snapshots",
"snapshot.slowRepo.answer.continue.description":
"Vent, indtil snapshot'et er færdigt. Efterfølgende ture er hurtige, når det indledende snapshot er bygget.",
"snapshot.slowRepo.answer.disable": "Deaktivér for dette projekt",
"snapshot.slowRepo.answer.disable.description":
"Slå Kilos snapshots fra for dette projekt. Du mister fortryd/gentag for Kilo-ændringer, men git sporer stadig alt.",
"ui.messagePart.openInDiffViewer": "Åbn i diff-viser",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Hier fortfahren",
"plan.followup.answer.continue.description": "Den Plan in dieser Sitzung umsetzen",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Snapshot ist langsam",
"snapshot.slowRepo.question":
"Die Initialisierung des Snapshot-Systems dauert lange, wahrscheinlich aufgrund der Größe des Repositorys.\n\nMöchtest du Snapshots für dieses Repository deaktivieren?",
"snapshot.slowRepo.answer.continue": "Snapshots beibehalten",
"snapshot.slowRepo.answer.continue.description":
"Auf den Abschluss des Snapshots warten. Nachfolgende Runden sind schnell, sobald der initiale Snapshot erstellt ist.",
"snapshot.slowRepo.answer.disable": "Für dieses Projekt deaktivieren",
"snapshot.slowRepo.answer.disable.description":
"Kilo-Snapshots für dieses Projekt ausschalten. Rückgängig/Wiederherstellen für Kilo-Änderungen ist nicht mehr möglich, aber git verfolgt weiterhin alles.",
"ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen",
}
+13
View File
@@ -84,6 +84,19 @@ export const dict = {
"plan.followup.answer.continue": "Continue here",
"plan.followup.answer.continue.description": "Implement the plan in this session",
// Slow-repo snapshot prompt. The English strings here are the canonical
// labels sent by the backend and must stay in sync with
// packages/opencode/src/kilocode/snapshot/track.ts.
"snapshot.slowRepo.header": "Snapshot is slow",
"snapshot.slowRepo.question":
"It is taking a long time to initialize the snapshot system, likely due to the size of the repository.\n\nDo you want to disable Snapshots for this repository?",
"snapshot.slowRepo.answer.continue": "Continue with snapshots",
"snapshot.slowRepo.answer.continue.description":
"Keep waiting for the snapshot to complete. Subsequent turns are fast once the initial snapshot is built.",
"snapshot.slowRepo.answer.disable": "Disable for this project",
"snapshot.slowRepo.answer.disable.description":
"Turn off Kilo's snapshots for this project. You will lose undo/redo of Kilo file changes, but git still tracks everything.",
// Edit-tool header: hover-revealed action opening the diff in a full tab.
"ui.messagePart.openInDiffViewer": "Open in Diff Viewer",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Continuar aquí",
"plan.followup.answer.continue.description": "Implementar el plan en esta sesión",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "La instantánea es lenta",
"snapshot.slowRepo.question":
"Está tardando mucho en inicializar el sistema de instantáneas, probablemente por el tamaño del repositorio.\n\n¿Quieres desactivar las instantáneas para este repositorio?",
"snapshot.slowRepo.answer.continue": "Continuar con instantáneas",
"snapshot.slowRepo.answer.continue.description":
"Sigue esperando hasta que termine la instantánea. Los turnos siguientes serán rápidos una vez creada la instantánea inicial.",
"snapshot.slowRepo.answer.disable": "Desactivar para este proyecto",
"snapshot.slowRepo.answer.disable.description":
"Apaga las instantáneas de Kilo para este proyecto. Perderás deshacer/rehacer de los cambios de Kilo, pero git seguirá rastreando todo.",
"ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Continuer ici",
"plan.followup.answer.continue.description": "Implémenter le plan dans cette session",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Instantané lent",
"snapshot.slowRepo.question":
"L'initialisation du système d'instantanés prend beaucoup de temps, probablement en raison de la taille du dépôt.\n\nVoulez-vous désactiver les instantanés pour ce dépôt ?",
"snapshot.slowRepo.answer.continue": "Continuer avec les instantanés",
"snapshot.slowRepo.answer.continue.description":
"Attendez la fin de l'instantané. Les tours suivants sont rapides une fois l'instantané initial créé.",
"snapshot.slowRepo.answer.disable": "Désactiver pour ce projet",
"snapshot.slowRepo.answer.disable.description":
"Désactivez les instantanés Kilo pour ce projet. Vous perdez l'annulation/restauration des modifications faites par Kilo, mais git continue de tout suivre.",
"ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences",
}
+11
View File
@@ -78,5 +78,16 @@ export const dict = {
"plan.followup.answer.continue": "ここで続行",
"plan.followup.answer.continue.description": "このセッションで計画を実装する",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "スナップショットが遅い",
"snapshot.slowRepo.question":
"リポジトリのサイズのためか、スナップショットシステムの初期化に時間がかかっています。\n\nこのリポジトリのスナップショットを無効にしますか?",
"snapshot.slowRepo.answer.continue": "スナップショットを続行",
"snapshot.slowRepo.answer.continue.description":
"スナップショットが完了するまで待機します。初回のスナップショットが作成された後は、以降のターンは高速になります。",
"snapshot.slowRepo.answer.disable": "このプロジェクトで無効化",
"snapshot.slowRepo.answer.disable.description":
"このプロジェクトでは Kilo のスナップショットを無効にします。Kilo による変更の取り消し/やり直しはできなくなりますが、git は引き続きすべてを追跡します。",
"ui.messagePart.openInDiffViewer": "差分ビューアで開く",
}
+11
View File
@@ -78,5 +78,16 @@ export const dict = {
"plan.followup.answer.continue": "여기서 계속하기",
"plan.followup.answer.continue.description": "이 세션에서 계획 구현",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "스냅샷이 느립니다",
"snapshot.slowRepo.question":
"리포지토리 크기 때문인지 스냅샷 시스템 초기화에 시간이 오래 걸리고 있습니다.\n\n이 리포지토리에서 스냅샷을 비활성화하시겠습니까?",
"snapshot.slowRepo.answer.continue": "스냅샷 계속 사용",
"snapshot.slowRepo.answer.continue.description":
"스냅샷이 완료될 때까지 기다리세요. 초기 스냅샷이 만들어지면 이후 턴은 빠릅니다.",
"snapshot.slowRepo.answer.disable": "이 프로젝트에서 비활성화",
"snapshot.slowRepo.answer.disable.description":
"이 프로젝트의 Kilo 스냅샷을 끕니다. Kilo 변경에 대한 실행 취소/다시 실행은 사용할 수 없지만 git은 여전히 모든 것을 추적합니다.",
"ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기",
}
+11
View File
@@ -82,5 +82,16 @@ export const dict = {
"plan.followup.answer.continue": "Hier doorgaan",
"plan.followup.answer.continue.description": "Het plan in deze sessie implementeren",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Snapshot is traag",
"snapshot.slowRepo.question":
"Het initialiseren van het snapshot-systeem duurt lang, waarschijnlijk vanwege de grootte van de repository.\n\nWil je snapshots voor deze repository uitschakelen?",
"snapshot.slowRepo.answer.continue": "Doorgaan met snapshots",
"snapshot.slowRepo.answer.continue.description":
"Wacht tot de snapshot klaar is. Volgende beurten zijn snel zodra de eerste snapshot is gemaakt.",
"snapshot.slowRepo.answer.disable": "Uitschakelen voor dit project",
"snapshot.slowRepo.answer.disable.description":
"Zet Kilo-snapshots uit voor dit project. Je verliest ongedaan maken/opnieuw doen van Kilo-wijzigingen, maar git blijft alles volgen.",
"ui.messagePart.openInDiffViewer": "Openen in diff-viewer",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Fortsett her",
"plan.followup.answer.continue.description": "Implementer planen i denne økten",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Snapshot er tregt",
"snapshot.slowRepo.question":
"Det tar lang tid å initialisere snapshot-systemet, sannsynligvis på grunn av størrelsen på depotet.\n\nVil du deaktivere snapshots for dette depotet?",
"snapshot.slowRepo.answer.continue": "Fortsett med snapshots",
"snapshot.slowRepo.answer.continue.description":
"Vent til snapshotet er ferdig. Påfølgende runder er raske når det første snapshotet er bygget.",
"snapshot.slowRepo.answer.disable": "Deaktiver for dette prosjektet",
"snapshot.slowRepo.answer.disable.description":
"Slå av Kilos snapshots for dette prosjektet. Du mister angre/gjør om for Kilo-endringer, men git fortsetter å spore alt.",
"ui.messagePart.openInDiffViewer": "Åpne i diff-viser",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Kontynuuj tutaj",
"plan.followup.answer.continue.description": "Wdróż plan w tej sesji",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Snapshot jest wolny",
"snapshot.slowRepo.question":
"Inicjalizacja systemu snapshotów trwa długo, prawdopodobnie ze względu na rozmiar repozytorium.\n\nCzy chcesz wyłączyć snapshoty dla tego repozytorium?",
"snapshot.slowRepo.answer.continue": "Kontynuuj ze snapshotami",
"snapshot.slowRepo.answer.continue.description":
"Poczekaj, aż snapshot się zakończy. Kolejne tury są szybkie, gdy początkowy snapshot jest już zbudowany.",
"snapshot.slowRepo.answer.disable": "Wyłącz dla tego projektu",
"snapshot.slowRepo.answer.disable.description":
"Wyłącz snapshoty Kilo dla tego projektu. Stracisz cofanie/przywracanie zmian Kilo, ale git nadal śledzi wszystko.",
"ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Продолжить здесь",
"plan.followup.answer.continue.description": "Реализовать план в этой сессии",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Снимок выполняется медленно",
"snapshot.slowRepo.question":
"Инициализация системы снимков занимает много времени, вероятно, из-за размера репозитория.\n\nОтключить снимки для этого репозитория?",
"snapshot.slowRepo.answer.continue": "Продолжить со снимками",
"snapshot.slowRepo.answer.continue.description":
"Подождите, пока снимок не завершится. Последующие ходы выполняются быстро после создания первоначального снимка.",
"snapshot.slowRepo.answer.disable": "Отключить для этого проекта",
"snapshot.slowRepo.answer.disable.description":
"Выключите снимки Kilo для этого проекта. Вы потеряете отмену/повтор изменений Kilo, но git по-прежнему отслеживает всё.",
"ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "ดำเนินการต่อที่นี่",
"plan.followup.answer.continue.description": "ดำเนินการตามแผนในเซสชันนี้",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "สแน็ปช็อตช้า",
"snapshot.slowRepo.question":
"การเริ่มต้นระบบสแน็ปช็อตใช้เวลานาน น่าจะเป็นเพราะขนาดของที่เก็บข้อมูล\n\nต้องการปิดสแน็ปช็อตสำหรับที่เก็บข้อมูลนี้หรือไม่?",
"snapshot.slowRepo.answer.continue": "ใช้สแน็ปช็อตต่อ",
"snapshot.slowRepo.answer.continue.description":
"รอจนกว่าสแน็ปช็อตจะเสร็จ เทิร์นถัดไปจะเร็วขึ้นเมื่อสร้างสแน็ปช็อตแรกเสร็จแล้ว",
"snapshot.slowRepo.answer.disable": "ปิดใช้งานสำหรับโปรเจกต์นี้",
"snapshot.slowRepo.answer.disable.description":
"ปิดสแน็ปช็อตของ Kilo สำหรับโปรเจกต์นี้ คุณจะสูญเสียการยกเลิก/ทำซ้ำสำหรับการเปลี่ยนแปลงของ Kilo แต่ git ยังคงติดตามทุกอย่าง",
"ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Burada devam et",
"plan.followup.answer.continue.description": "Planı bu oturumda uygula",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Anlık görüntü yavaş",
"snapshot.slowRepo.question":
"Anlık görüntü sisteminin başlatılması uzun sürüyor, muhtemelen deponun boyutu nedeniyle.\n\nBu depo için anlık görüntüleri devre dışı bırakmak istiyor musunuz?",
"snapshot.slowRepo.answer.continue": "Anlık görüntülerle devam et",
"snapshot.slowRepo.answer.continue.description":
"Anlık görüntü tamamlanana kadar bekleyin. İlk anlık görüntü oluşturulduktan sonra sonraki turlar hızlıdır.",
"snapshot.slowRepo.answer.disable": "Bu proje için devre dışı bırak",
"snapshot.slowRepo.answer.disable.description":
"Bu proje için Kilo anlık görüntülerini kapat. Kilo değişiklikleri için geri alma/yeniden yapmayı kaybedersiniz, ancak git her şeyi izlemeye devam eder.",
"ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç",
}
+11
View File
@@ -80,5 +80,16 @@ export const dict = {
"plan.followup.answer.continue": "Продовжити тут",
"plan.followup.answer.continue.description": "Реалізувати план у цій сесії",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "Знімок виконується повільно",
"snapshot.slowRepo.question":
"Ініціалізація системи знімків займає багато часу, ймовірно, через розмір репозиторію.\n\nВимкнути знімки для цього репозиторію?",
"snapshot.slowRepo.answer.continue": "Продовжити зі знімками",
"snapshot.slowRepo.answer.continue.description":
"Зачекайте, поки знімок завершиться. Наступні ходи будуть швидкими, щойно початковий знімок буде створений.",
"snapshot.slowRepo.answer.disable": "Вимкнути для цього проєкту",
"snapshot.slowRepo.answer.disable.description":
"Вимкніть знімки Kilo для цього проєкту. Ви втратите скасування/повторення для змін Kilo, але git продовжить відстежувати все.",
"ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей",
}
+9
View File
@@ -77,5 +77,14 @@ export const dict = {
"plan.followup.answer.continue": "在此继续",
"plan.followup.answer.continue.description": "在本会话中实现计划",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "快照速度较慢",
"snapshot.slowRepo.question": "初始化快照系统耗时较长,可能是由于仓库的大小。\n\n是否要为此仓库禁用快照?",
"snapshot.slowRepo.answer.continue": "继续使用快照",
"snapshot.slowRepo.answer.continue.description": "等待快照完成。初始快照构建好之后,后续回合会很快。",
"snapshot.slowRepo.answer.disable": "为此项目禁用",
"snapshot.slowRepo.answer.disable.description":
"关闭本项目的 Kilo 快照。你将失去对 Kilo 更改的撤销/重做,但 git 仍会追踪所有内容。",
"ui.messagePart.openInDiffViewer": "在差异查看器中打开",
}
+9
View File
@@ -77,5 +77,14 @@ export const dict = {
"plan.followup.answer.continue": "在此繼續",
"plan.followup.answer.continue.description": "在本工作階段中實作計畫",
// Slow-repo snapshot prompt
"snapshot.slowRepo.header": "快照速度較慢",
"snapshot.slowRepo.question": "初始化快照系統耗時較長,可能是由於儲存庫的大小。\n\n是否要為此儲存庫停用快照?",
"snapshot.slowRepo.answer.continue": "繼續使用快照",
"snapshot.slowRepo.answer.continue.description": "等待快照完成。初始快照建立後,後續回合會很快。",
"snapshot.slowRepo.answer.disable": "為此專案停用",
"snapshot.slowRepo.answer.disable.description":
"關閉本專案的 Kilo 快照。你將失去對 Kilo 變更的撤銷/重做,但 git 仍會追蹤所有內容。",
"ui.messagePart.openInDiffViewer": "在差異檢視器中開啟",
}
@@ -1260,7 +1260,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return props.turnDiffSummary
})
// Assistant message is still in-flight when `time.completed` hasn't been set.
// Used as a render guard for synthetic status parts so stale ones don't
// linger in the scrollback after a hard-kill of the host process.
const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
// Synthetic text parts (e.g. "Initializing snapshot…" from the slow-repo
// guard) are transient status indicators. Hide them once the owning message
// stops streaming so a hard-killed turn doesn't leave a stuck spinner line
// in the chat history on the next reload.
const showSyntheticPart = createMemo(() => !part().synthetic || streaming())
const showCopy = createMemo(() => {
// Synthetic text parts (e.g. "Initializing snapshot…" from the slow-repo
// guard) are transient status indicators, not assistant output — they
// must never carry the copy button.
if (part().synthetic) return false
if (props.message.role !== "assistant") return false
if (props.showAssistantCopyPartID === null) return false
return props.showAssistantCopyPartID === part().id
@@ -1304,7 +1321,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
}
return (
<Show when={throttledText()}>
<Show when={throttledText() && showSyntheticPart()}>
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part().id} onClick={handleMarkdownClick} />
+5
View File
@@ -847,6 +847,11 @@
"type": "boolean",
"default": true,
"description": "Show the task timeline graph in the chat header"
},
"kilo-code.new.diff.renderMarkdown": {
"type": "boolean",
"default": false,
"description": "Render Markdown files in Kilo diff viewers by default. Toggle this from a Markdown file header in the diff viewer."
}
}
}
@@ -10,6 +10,7 @@ import {
openWorkspaceRelativeFile,
resolveLocalDiffTarget,
} from "./review-utils"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings"
/**
* DiffViewerProvider opens a full-screen diff viewer in an editor tab.
@@ -90,6 +91,7 @@ export class DiffViewerProvider implements vscode.Disposable {
languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get<string>("language"),
workspaceDirectory: getWorkspaceRoot(),
})
this.post({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() })
this.startDiffPolling()
return
}
@@ -108,6 +110,11 @@ export class DiffViewerProvider implements vscode.Disposable {
return
}
if (type === "diffViewer.setMarkdownRender" && typeof msg.render === "boolean") {
void setDiffMarkdownRender(msg.render)
return
}
if (type === "diffViewer.revertFile" && typeof msg.file === "string") {
void this.revertFile(msg.file)
return
@@ -1,6 +1,7 @@
import * as vscode from "vscode"
import { buildWebviewHtml } from "./utils"
import { appendOutput, getWorkspaceRoot } from "./review-utils"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings"
export interface DiffVirtualFile {
file: string
@@ -79,12 +80,22 @@ export class DiffVirtualProvider implements vscode.Disposable {
if (type === "diffVirtual.close") {
this.panel?.dispose()
return
}
if (type === "diffVirtual.setMarkdownRender" && typeof msg.render === "boolean") {
void setDiffMarkdownRender(msg.render)
}
}
private pushData(): void {
if (!this.pending) return
this.post({ type: "diffVirtual.data", diff: this.pending, initialDiffStyle: this.pending.initialDiffStyle })
this.post({
type: "diffVirtual.data",
diff: this.pending,
initialDiffStyle: this.pending.initialDiffStyle,
markdownRender: getDiffMarkdownRender(),
})
}
private post(message: Record<string, unknown>): void {
+8 -8
View File
@@ -140,6 +140,10 @@ const mapAgent = (a: Agent) => ({
model: a.model,
})
// message.part.* events are always session-scoped; drop them when the session is unknown.
const SESSION_SCOPED_PART_EVENTS = new Set(["message.part.updated", "message.part.delta", "message.part.removed"])
const isSessionScopedPartEvent = (type: string) => SESSION_SCOPED_PART_EVENTS.has(type)
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
private readonly instanceId = crypto.randomUUID()
@@ -1144,10 +1148,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (event.type === "kilo-sessions.remote-status-changed") return true
const sessionId = this.connectionService.resolveEventSessionId(event)
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
if (!sessionId) {
return event.type !== "message.part.updated" && event.type !== "message.part.delta"
}
// message.part.* events are always session-scoped; drop if session unknown.
if (!sessionId) return !isSessionScopedPartEvent(event.type)
if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) {
return true
@@ -3008,10 +3010,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Events without sessionID (server.connected, server.heartbeat, indexing.status) → always forward
// Events with sessionID → only forward if this webview tracks that session
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
if (!sessionID && (event.type === "message.part.updated" || event.type === "message.part.delta")) {
return
}
// message.part.* events are always session-scoped; drop if session unknown.
if (!sessionID && isSessionScopedPartEvent(event.type)) return
if (event.type !== "indexing.status" && sessionID && !this.trackedSessionIds.has(sessionID)) {
return
}
@@ -4,6 +4,7 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../services/cli-backend"
import { getErrorMessage } from "../kilo-provider-utils"
import { resolveLocalDiffTarget } from "../review-utils"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { remoteRef, WorktreeStateManager } from "./WorktreeStateManager"
@@ -499,6 +500,10 @@ export class AgentManagerProvider implements Disposable {
this.state?.setReviewDiffStyle(m.style)
return null
}
if (m.type === "agentManager.setReviewMarkdownRender") {
void setDiffMarkdownRender(m.render).then(() => this.pushState())
return null
}
if (m.type === "agentManager.setDefaultBaseBranch") {
this.state?.setDefaultBaseBranch(normalizeBaseBranch(m.branch))
this.pushState()
@@ -1401,6 +1406,7 @@ export class AgentManagerProvider implements Disposable {
worktreeOrder: state.getWorktreeOrder(),
sessionsCollapsed: state.getSessionsCollapsed(),
reviewDiffStyle: state.getReviewDiffStyle(),
reviewMarkdownRender: getDiffMarkdownRender(),
isGitRepo: true,
defaultBaseBranch: state.getDefaultBaseBranch(),
...run,
@@ -1422,6 +1428,7 @@ export class AgentManagerProvider implements Disposable {
sessions: [],
staleWorktreeIds: [],
reviewDiffStyle: "unified",
reviewMarkdownRender: getDiffMarkdownRender(),
isGitRepo: false,
runStatuses: [],
runScriptConfigured: false,
@@ -127,6 +127,7 @@ interface StateMessage {
worktreeOrder?: string[]
sessionsCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
reviewMarkdownRender?: boolean
isGitRepo?: boolean
defaultBaseBranch?: string
runStatuses?: RunStatus[]
@@ -460,6 +461,11 @@ interface SetReviewDiffStyleIn {
style: "unified" | "split"
}
interface SetReviewMarkdownRenderIn {
type: "agentManager.setReviewMarkdownRender"
render: boolean
}
interface SetDefaultBaseBranchIn {
type: "agentManager.setDefaultBaseBranch"
branch?: string
@@ -725,6 +731,7 @@ export type AgentManagerInMessage =
| SetWorktreeOrderIn
| SetSessionsCollapsedIn
| SetReviewDiffStyleIn
| SetReviewMarkdownRenderIn
| SetDefaultBaseBranchIn
| RequestExternalWorktreesIn
| ImportFromBranchIn
+41 -21
View File
@@ -2,6 +2,7 @@ import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/
import { prettifyError } from "zod/v4"
import type { CloudSessionMessage, IndexingStatus } from "./services/cli-backend/types"
import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler"
import type { PartRemove } from "./shared/stream-messages"
export { SessionStreamScheduler } from "./kilo-provider/session-stream-scheduler"
@@ -361,6 +362,7 @@ export function resolveNewSessionDirectory(input: {
export type WebviewMessage =
| PartUpdate
| PartBatch
| PartRemove
| {
type: "indexingStatusLoaded"
status: IndexingStatus
@@ -410,29 +412,47 @@ export type WebviewMessage =
| { type: "sessionError"; sessionID?: string; error?: unknown }
| null
type PartEvent = Extract<Event, { type: "message.part.updated" | "message.part.delta" | "message.part.removed" }>
function mapPartEvent(event: PartEvent, sessionID: string | undefined): WebviewMessage {
if (!sessionID) return null
if (event.type === "message.part.updated") {
const part = event.properties.part as { messageID?: string; sessionID?: string }
return {
type: "partUpdated",
sessionID,
messageID: part.messageID || "",
part: event.properties.part,
}
}
if (event.type === "message.part.delta") {
const props = event.properties
return {
type: "partUpdated",
sessionID: props.sessionID,
messageID: props.messageID,
part: { id: props.partID, type: "text", messageID: props.messageID, text: props.delta },
delta: { type: "text-delta", textDelta: props.delta },
}
}
const props = event.properties
return {
type: "partRemoved",
sessionID: props.sessionID,
messageID: props.messageID,
partID: props.partID,
}
}
export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | undefined): WebviewMessage {
if (
event.type === "message.part.updated" ||
event.type === "message.part.delta" ||
event.type === "message.part.removed"
) {
return mapPartEvent(event, sessionID)
}
switch (event.type) {
case "message.part.updated": {
const part = event.properties.part as { messageID?: string; sessionID?: string }
if (!sessionID) return null
return {
type: "partUpdated",
sessionID,
messageID: part.messageID || "",
part: event.properties.part,
}
}
case "message.part.delta": {
const props = event.properties
if (!sessionID) return null
return {
type: "partUpdated",
sessionID: props.sessionID,
messageID: props.messageID,
part: { id: props.partID, type: "text", messageID: props.messageID, text: props.delta },
delta: { type: "text-delta", textDelta: props.delta },
}
}
case "message.updated": {
const info = event.properties.info
return {
@@ -0,0 +1,12 @@
import * as vscode from "vscode"
const CONFIG = "kilo-code.new"
const KEY = "diff.renderMarkdown"
export function getDiffMarkdownRender(): boolean {
return vscode.workspace.getConfiguration(CONFIG).get<boolean>(KEY, false)
}
export async function setDiffMarkdownRender(value: boolean): Promise<void> {
await vscode.workspace.getConfiguration(CONFIG).update(KEY, value, vscode.ConfigurationTarget.Global)
}
@@ -35,6 +35,8 @@ export function resolveEventSessionId(
}
case "message.part.delta":
return event.properties.sessionID
case "message.part.removed":
return event.properties.sessionID
case "permission.asked":
case "permission.replied":
case "question.asked":
@@ -22,3 +22,10 @@ export type PartBatch<P = unknown> = {
type: "partsUpdated"
updates: PartUpdate<P>[]
}
export type PartRemove = {
type: "partRemoved"
sessionID: string
messageID: string
partID: string
}
@@ -24,6 +24,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx"),
path.join(ROOT, "webview-ui/agent-manager/MarkdownDiffView.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffEndMarker.tsx"),
path.join(ROOT, "webview-ui/agent-manager/FileTree.tsx"),
path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"),
@@ -114,6 +114,7 @@ import {
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { mergeWorktreeDiffs } from "./diff-state"
import { createMarkdownRender } from "./review-preferences"
import "./agent-manager.css"
import "./agent-manager-review.css"
@@ -358,13 +359,11 @@ const AgentManagerContent: Component = () => {
const [diffFileLoading, setDiffFileLoading] = createSignal<Record<string, Record<string, true>>>({})
const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5))
// Full-screen review state (in-memory, per sidebar context: local/worktree)
const [reviewOpenByContext, setReviewOpenByContext] = createSignal<Record<string, boolean>>({})
const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal<Record<string, ReviewComment[]>>({})
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
// reviewOpen (memo below) controls tab presence for selected context.
const markdown = createMarkdownRender(vscode)
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const [worktreeStats, setWorktreeStats] = createSignal<Record<string, WorktreeGitStats>>({})
@@ -1326,6 +1325,7 @@ const AgentManagerContent: Component = () => {
if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") {
setReviewDiffStyle(state.reviewDiffStyle)
}
markdown.setRender(state.reviewMarkdownRender === true)
if ("defaultBaseBranch" in state) setDefaultBaseBranch(state.defaultBaseBranch || undefined)
setRunScriptConfigured(state.runScriptConfigured === true)
syncRunStatuses(state.runStatuses)
@@ -3102,6 +3102,8 @@ const AgentManagerContent: Component = () => {
sessionKey={diffSessionKey()}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
onClose={() => setSidePanel(null)}
@@ -3135,6 +3137,8 @@ const AgentManagerContent: Component = () => {
onSendAll={closeReviewTab}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
onRequestDiff={requestDiffFile}
onOpenFile={(file, line) => {
const id = currentDiffSessionId()
@@ -24,6 +24,7 @@ import {
import { LONG_DIFF_MARKER_FILE_COUNT, initialOpenFiles, isLargeDiffFile } from "./diff-open-policy"
import { DiffEndMarker } from "./DiffEndMarker"
import { treeOrder } from "./file-tree-utils"
import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView"
// --- Data model ---
@@ -35,6 +36,8 @@ interface DiffPanelProps {
sessionKey?: string
diffStyle?: "unified" | "split"
onDiffStyleChange?: (style: "unified" | "split") => void
markdownRender?: boolean
onMarkdownRenderChange?: (render: boolean) => void
comments: ReviewComment[]
onCommentsChange: (comments: ReviewComment[]) => void
onSendAll?: () => void
@@ -499,6 +502,23 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
/>
</Tooltip>
</Show>
<Show when={isMarkdownFile(diff.file) && props.onMarkdownRenderChange}>
<Tooltip
value={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
placement="top"
>
<IconButton
icon={props.markdownRender ? "code" : "eye"}
size="small"
variant="ghost"
label={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
onClick={(e: MouseEvent) => {
e.stopPropagation()
props.onMarkdownRenderChange?.(!props.markdownRender)
}}
/>
</Tooltip>
</Show>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
@@ -521,19 +541,26 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
</div>
}
>
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
diffStyle={props.diffStyle ?? "unified"}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
onLineNumberClick={(event) => {
if (event.annotationSide === "deletions") return
props.onOpenFile?.(diff.file, event.lineNumber)
}}
/>
<Show
when={props.markdownRender && isMarkdownFile(diff.file)}
fallback={
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
diffStyle={props.diffStyle ?? "unified"}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
onLineNumberClick={(event) => {
if (event.annotationSide === "deletions") return
props.onOpenFile?.(diff.file, event.lineNumber)
}}
/>
}
>
<MarkdownDiffView diff={diff} />
</Show>
</Show>
</Show>
</Accordion.Content>
@@ -31,6 +31,7 @@ import {
} from "./review-annotations"
import { LONG_DIFF_MARKER_FILE_COUNT, initialOpenFiles, isLargeDiffFile } from "./diff-open-policy"
import { DiffEndMarker } from "./DiffEndMarker"
import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView"
type DiffStyle = "unified" | "split"
@@ -45,6 +46,8 @@ interface FullScreenDiffViewProps {
onSendAll?: () => void
diffStyle: DiffStyle
onDiffStyleChange: (style: DiffStyle) => void
markdownRender?: boolean
onMarkdownRenderChange?: (render: boolean) => void
onRequestDiff?: (file: string) => void
onOpenFile?: (relativePath: string, line?: number) => void
onRevertFile?: (file: string) => void
@@ -580,6 +583,23 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
/>
</Tooltip>
</Show>
<Show when={isMarkdownFile(diff.file) && props.onMarkdownRenderChange}>
<Tooltip
value={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
placement="top"
>
<IconButton
icon={props.markdownRender ? "code" : "eye"}
size="small"
variant="ghost"
label={props.markdownRender ? "Show raw Markdown" : "Render Markdown"}
onClick={(e: MouseEvent) => {
e.stopPropagation()
props.onMarkdownRenderChange?.(!props.markdownRender)
}}
/>
</Tooltip>
</Show>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
@@ -602,19 +622,26 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
</div>
}
>
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
diffStyle={props.diffStyle}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
onLineNumberClick={(event) => {
if (event.annotationSide === "deletions") return
props.onOpenFile?.(diff.file, event.lineNumber)
}}
/>
<Show
when={props.markdownRender && isMarkdownFile(diff.file)}
fallback={
<Diff<AnnotationMeta>
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
diffStyle={props.diffStyle}
annotations={annotationsForFile(diff.file)}
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
onLineNumberClick={(event) => {
if (event.annotationSide === "deletions") return
props.onOpenFile?.(diff.file, event.lineNumber)
}}
/>
}
>
<MarkdownDiffView diff={diff} />
</Show>
</Show>
</Show>
</Accordion.Content>
@@ -0,0 +1,47 @@
import { type Component, Show } from "solid-js"
import { Markdown } from "@kilocode/kilo-ui/markdown"
interface MarkdownDiffFile {
file: string
before: string
after: string
status?: "added" | "deleted" | "modified"
}
interface MarkdownDiffViewProps {
diff: MarkdownDiffFile
}
export function isMarkdownFile(file: string): boolean {
return /\.(md|mdx|markdown)$/i.test(file)
}
export const MarkdownDiffView: Component<MarkdownDiffViewProps> = (props) => {
const before = () => (props.diff.status === "added" ? "" : props.diff.before)
const after = () => (props.diff.status === "deleted" ? "" : props.diff.after)
const split = () => before().length > 0 && after().length > 0 && before() !== after()
return (
<div class="am-markdown-diff" data-split={split() ? "true" : undefined}>
<Show
when={split()}
fallback={
<section class="am-markdown-pane">
<Markdown text={after() || before()} cacheKey={`${props.diff.file}:rendered`} />
</section>
}
>
<>
<section class="am-markdown-pane">
<div class="am-markdown-pane-title">Before</div>
<Markdown text={before()} cacheKey={`${props.diff.file}:before`} />
</section>
<section class="am-markdown-pane">
<div class="am-markdown-pane-title">After</div>
<Markdown text={after()} cacheKey={`${props.diff.file}:after`} />
</section>
</>
</Show>
</div>
)
}
@@ -1661,6 +1661,46 @@ body.am-wt-dragging-active * {
gap: 8px;
}
.am-markdown-diff {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1px;
background: var(--border-weak-base);
border-top: 1px solid var(--border-weak-base);
cursor: auto;
}
.am-markdown-diff[data-split="true"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.am-markdown-pane {
min-width: 0;
padding: 16px 20px 24px;
background: var(--vscode-editor-background, var(--surface-base));
color: var(--vscode-editor-foreground, var(--text-base));
overflow-x: auto;
}
.am-markdown-pane-title {
margin: -4px 0 12px;
color: var(--text-weak);
font-size: var(--font-size-small);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.am-markdown-pane [data-component="markdown"] {
max-width: 900px;
}
@media (max-width: 840px) {
.am-markdown-diff[data-split="true"] {
grid-template-columns: minmax(0, 1fr);
}
}
/* Keep +N/-N indicators at consistent size across side/fullscreen views */
.am-diff-panel [data-component="diff-changes"] [data-slot="diff-changes-additions"],
.am-diff-panel [data-component="diff-changes"] [data-slot="diff-changes-deletions"],
@@ -0,0 +1,16 @@
import { createSignal } from "solid-js"
interface VscodeLike {
postMessage: (message: { type: "agentManager.setReviewMarkdownRender"; render: boolean }) => void
}
export function createMarkdownRender(vscode: VscodeLike) {
const [render, setRender] = createSignal(false)
const update = (value: boolean) => {
if (render() === value) return
setRender(value)
vscode.postMessage({ type: "agentManager.setReviewMarkdownRender", render: value })
}
return { render, setRender, update }
}
@@ -14,11 +14,11 @@ import { FullScreenDiffView } from "../agent-manager/FullScreenDiffView"
import { LanguageProvider } from "../src/context/language"
import { ServerProvider, useServer } from "../src/context/server"
import { getVSCodeAPI, VSCodeProvider, useVSCode } from "../src/context/vscode"
import type { ReviewComment, WorktreeFileDiff } from "../src/types/messages"
import type { ReviewComment, WebviewMessage, WorktreeFileDiff } from "../src/types/messages"
type DiffStyle = "unified" | "split"
const post = (message: Record<string, unknown>) => getVSCodeAPI().postMessage(message as never)
const post = (message: WebviewMessage) => getVSCodeAPI().postMessage(message)
const DiffViewerContent: Component = () => {
const vscode = useVSCode()
@@ -26,6 +26,7 @@ const DiffViewerContent: Component = () => {
const [loading, setLoading] = createSignal(true)
const [comments, setComments] = createSignal<ReviewComment[]>([])
const [diffStyle, setDiffStyle] = createSignal<DiffStyle>("unified")
const [markdown, setMarkdown] = createSignal(false)
const [reverting, setReverting] = createSignal<Set<string>>(new Set())
const markReverting = (file: string, active: boolean) => {
@@ -52,6 +53,11 @@ const DiffViewerContent: Component = () => {
markReverting(msg.file, false)
return
}
if (msg.type === "diffViewer.markdownRender") {
setMarkdown(msg.render)
return
}
})
const handler = (event: MessageEvent) => {
@@ -79,6 +85,11 @@ const DiffViewerContent: Component = () => {
setDiffStyle(style)
post({ type: "diffViewer.setDiffStyle", style })
}}
markdownRender={markdown()}
onMarkdownRenderChange={(render) => {
setMarkdown(render)
post({ type: "diffViewer.setMarkdownRender", render })
}}
onOpenFile={(relativePath) => {
post({ type: "openFile", filePath: relativePath })
}}
@@ -8,12 +8,15 @@ import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { File } from "@kilocode/kilo-ui/file"
import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { RadioGroup } from "@kilocode/kilo-ui/radio-group"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { normalize, text } from "@kilocode/kilo-ui/session-diff"
import { LanguageProvider, useLanguage } from "../src/context/language"
import { ServerProvider, useServer } from "../src/context/server"
import { VSCodeProvider } from "../src/context/vscode"
import { getVSCodeAPI, VSCodeProvider } from "../src/context/vscode"
import { isMarkdownFile, MarkdownDiffView } from "../agent-manager/MarkdownDiffView"
type DiffStyle = "unified" | "split"
@@ -30,12 +33,19 @@ const DiffVirtualContent: Component = () => {
const { t } = useLanguage()
const [diff, setDiff] = createSignal<DiffVirtualFile | null>(null)
const [style, setStyle] = createSignal<DiffStyle>("unified")
const [markdown, setMarkdown] = createSignal(false)
const handler = (event: MessageEvent) => {
const msg = event.data as { type: string; diff?: DiffVirtualFile; initialDiffStyle?: DiffStyle }
const msg = event.data as {
type: string
diff?: DiffVirtualFile
initialDiffStyle?: DiffStyle
markdownRender?: boolean
}
if (msg?.type === "diffVirtual.data" && msg.diff) {
setDiff(msg.diff)
setStyle(msg.initialDiffStyle ?? "unified")
setMarkdown(msg.markdownRender === true)
}
}
@@ -93,13 +103,35 @@ const DiffVirtualContent: Component = () => {
<span class="am-review-toolbar-dels">-{d().deletions}</span>
</span>
</div>
<Show when={isMarkdownFile(d().file)}>
<Tooltip value={markdown() ? "Show raw Markdown" : "Render Markdown"} placement="bottom">
<IconButton
icon={markdown() ? "code" : "eye"}
size="small"
variant="ghost"
label={markdown() ? "Show raw Markdown" : "Render Markdown"}
onClick={() => {
const next = !markdown()
setMarkdown(next)
getVSCodeAPI().postMessage({ type: "diffVirtual.setMarkdownRender", render: next })
}}
/>
</Tooltip>
</Show>
</div>
<div class="am-review-diff" style={{ width: "100%" }}>
<Diff
before={{ name: d().file, contents: resolved().before }}
after={{ name: d().file, contents: resolved().after }}
diffStyle={style()}
/>
<Show
when={markdown() && isMarkdownFile(d().file)}
fallback={
<Diff
before={{ name: d().file, contents: resolved().before }}
after={{ name: d().file, contents: resolved().after }}
diffStyle={style()}
/>
}
>
<MarkdownDiffView diff={{ file: d().file, before: resolved().before, after: resolved().after }} />
</Show>
</div>
</>
)}
@@ -119,7 +119,11 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
),
)
// Copy part ID — the last text part from the last assistant message
// Copy part ID — the last text part from the last assistant message.
// Synthetic parts (e.g. "Initializing snapshot…" from the slow-repo guard)
// are transient status lines, not assistant output: they must never win
// this lookup, otherwise the copy button renders beside the spinner
// instead of the real response.
const showAssistantCopyPartID = createMemo(() => {
const msgs = assistantMessages()
for (let i = msgs.length - 1; i >= 0; i--) {
@@ -129,6 +133,7 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
for (let j = msgParts.length - 1; j >= 0; j--) {
const part = msgParts[j]
if (!part || part.type !== "text") continue
if ((part as SDKPart & { synthetic?: boolean }).synthetic) continue
if ((part as SDKPart & { text: string }).text?.trim()) return part.id
}
}
@@ -762,6 +762,11 @@ export const SessionProvider: ParentComponent = (props) => {
return true
}
if (message.type === "partRemoved") {
handlePartRemoved(message.sessionID, message.messageID, message.partID)
return true
}
return false
}
@@ -1152,6 +1157,21 @@ export const SessionProvider: ParentComponent = (props) => {
)
}
function handlePartRemoved(sessionID: string | undefined, messageID: string, partID: string) {
if (sessionID) patchPage(sessionID, { lastMutation: "update" })
setStore(
"parts",
produce((parts) => {
const list = parts[messageID]
if (!list) return
const idx = list.findIndex((p) => p.id === partID)
if (idx < 0) return
list.splice(idx, 1)
}),
)
}
function handleSessionStatus(
sessionID: string,
newStatus: SessionStatus,
@@ -142,7 +142,7 @@
[data-slot="question-options"] {
display: flex;
flex-direction: column;
gap: 1px;
gap: 4px;
padding: 2px 4px;
max-height: 40vh;
overflow-y: auto;
@@ -164,6 +164,7 @@
text-align: left;
width: 100%;
cursor: pointer;
outline: none;
transition: background-color 0.15s ease;
&:hover:not([data-picked="true"]) {
@@ -174,6 +175,11 @@
background-color: var(--surface-interactive-weak);
}
&:focus-visible {
outline: 1px solid var(--border-focus);
outline-offset: 1px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
@@ -1,5 +1,5 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client"
import type { PartBatch, PartUpdate } from "../../../../src/shared/stream-messages"
import type { PartBatch, PartRemove, PartUpdate } from "../../../../src/shared/stream-messages"
import type { SessionMode } from "../../context/worktree-mode"
import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../marketplace"
import type { ConnectionState, ServerInfo, SessionStatus } from "./connection"
@@ -93,6 +93,7 @@ export interface SendMessageFailedMessage {
// webview's concrete union.
export type PartUpdatedMessage = PartUpdate<Part>
export type PartsUpdatedMessage = PartBatch<Part>
export type PartRemovedMessage = PartRemove
export interface SessionStatusMessage {
type: "sessionStatus"
@@ -498,6 +499,7 @@ export interface AgentManagerStateMessage {
worktreeOrder?: string[]
sessionsCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
reviewMarkdownRender?: boolean
isGitRepo?: boolean
defaultBaseBranch?: string
runStatuses?: RunStatus[]
@@ -717,6 +719,11 @@ export interface DiffViewerRevertFileResultMessage {
message: string
}
export interface DiffViewerMarkdownRenderMessage {
type: "diffViewer.markdownRender"
render: boolean
}
export interface ClearPendingPromptsMessage {
type: "clearPendingPrompts"
}
@@ -818,6 +825,7 @@ export type ExtensionMessage =
| SendMessageFailedMessage
| PartUpdatedMessage
| PartsUpdatedMessage
| PartRemovedMessage
| SessionStatusMessage
| SessionErrorMessage
| PermissionRequestMessage
@@ -917,6 +925,7 @@ export type ExtensionMessage =
| DiffViewerDiffsMessage
| DiffViewerLoadingMessage
| DiffViewerRevertFileResultMessage
| DiffViewerMarkdownRenderMessage
| MarketplaceDataMessage
| MarketplaceInstallResultMessage
| MarketplaceRemoveResultMessage
@@ -4,7 +4,7 @@ import type { MessageLoadMode } from "./sessions"
import type { PermissionFileDiff } from "./permissions"
import type { ModelSelection, ProviderConfig } from "./providers"
import type { Config } from "./config"
import type { ModelAllocation } from "./agent-manager"
import type { ModelAllocation, ReviewComment } from "./agent-manager"
import type {
ClearLegacyDataMessage,
FinalizeLegacyMigrationMessage,
@@ -638,6 +638,12 @@ export interface SetReviewDiffStyleRequest {
style: "unified" | "split"
}
// Persist Markdown render preference in diff viewers
export interface SetReviewMarkdownRenderRequest {
type: "agentManager.setReviewMarkdownRender"
render: boolean
}
export interface RequestBranchesMessage {
type: "agentManager.requestBranches"
}
@@ -744,6 +750,36 @@ export interface OpenDiffVirtualRequest {
initialDiffStyle: "unified" | "split"
}
export interface DiffViewerSendCommentsRequest {
type: "diffViewer.sendComments"
comments: ReviewComment[]
autoSend: boolean
}
export interface DiffViewerSetDiffStyleRequest {
type: "diffViewer.setDiffStyle"
style: "unified" | "split"
}
export interface DiffViewerSetMarkdownRenderRequest {
type: "diffViewer.setMarkdownRender"
render: boolean
}
export interface DiffViewerRevertFileRequest {
type: "diffViewer.revertFile"
file: string
}
export interface DiffViewerCloseRequest {
type: "diffViewer.close"
}
export interface DiffVirtualSetMarkdownRenderRequest {
type: "diffVirtual.setMarkdownRender"
render: boolean
}
export interface RetryConnectionRequest {
type: "retryConnection"
}
@@ -1045,6 +1081,7 @@ export type WebviewMessage =
| SetWorktreeOrderRequest
| SetSessionsCollapsedRequest
| SetReviewDiffStyleRequest
| SetReviewMarkdownRenderRequest
| PersistVariantRequest
| RequestVariantsMessage
| RequestCloudSessionDataMessage
@@ -1073,6 +1110,12 @@ export type WebviewMessage =
| EnhancePromptRequest
| OpenChangesRequest
| OpenDiffVirtualRequest
| DiffViewerSendCommentsRequest
| DiffViewerSetDiffStyleRequest
| DiffViewerSetMarkdownRenderRequest
| DiffViewerRevertFileRequest
| DiffViewerCloseRequest
| DiffVirtualSetMarkdownRenderRequest
| RetryConnectionRequest
| OpenSubAgentViewerRequest
| PreviewImageRequest
+19 -8
View File
@@ -280,9 +280,11 @@ async function merge() {
const extracted = extract(content)
if (extracted) {
suites.push(extracted)
counts.tests += attr(extracted, "tests")
counts.failures += attr(extracted, "failures")
counts.errors += attr(extracted, "errors")
// kilocode_change start - count nested suites without matching the root testsuites tag.
counts.tests += sum(extracted, "tests")
counts.failures += sum(extracted, "failures")
counts.errors += sum(extracted, "errors")
// kilocode_change end
continue
}
}
@@ -320,9 +322,8 @@ async function merge() {
}
function extract(content: string, from = 0): string {
const open = "<testsuite"
const close = "</testsuite>"
const s = content.indexOf(open, from)
const s = open(content, from) // kilocode_change
if (s === -1) return ""
const e = content.indexOf(close, s)
if (e === -1) return ""
@@ -331,11 +332,21 @@ function extract(content: string, from = 0): string {
return rest ? suite + "\n" + rest : suite
}
function attr(content: string, name: string): number {
const match = content.match(new RegExp(`${name}="(\\d+)"`))
return match ? Number(match[1]) : 0
// kilocode_change start
function open(content: string, from: number): number {
const tag = "<testsuite"
const s = content.indexOf(tag, from)
if (s === -1) return -1
const ch = content[s + tag.length]
if (ch === ">" || /\s/.test(ch)) return s
return open(content, s + 1)
}
function sum(content: string, name: string): number {
return Array.from(content.matchAll(new RegExp(`${name}="(\\d+)"`, "g"))).reduce((n, m) => n + Number(m[1]), 0)
}
// kilocode_change end
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
@@ -0,0 +1,3 @@
// kilocode_change - new file
export * from "./track"
export * from "./diff-full"
@@ -0,0 +1,544 @@
// kilocode_change - new file
//
// Slow-repo guard for Snapshot.track.
//
// On huge repositories (e.g. 270k+ untracked files in intellij-community)
// the initial `git add --pathspec-from-file=-` staged by the snapshot system
// can take tens of seconds — long enough to wedge the turn before the LLM
// is even called. This wrapper:
//
// 1. Runs the real `track()` in a forked fiber.
// 2. Waits up to `TIMEOUT_MS` for it to complete.
// 3. If it times out AND we have a sessionID to target, asks the user:
// - "Continue with snapshots": keep waiting on this turn; snapshot
// finishes eventually and undo/redo stays functional. Future turns
// are fast because the snapshot index is built.
// - "Disable for this project": interrupt the in-flight snapshot,
// persist `"snapshot": false` to `.kilo/kilo.json`, and skip. All
// future sessions on this project load with snapshots off.
// - Dismissed / no sessionID: interrupt and skip. Mark the instance
// so we don't prompt again until the instance reloads.
//
// While the snapshot is running, we inject a synthetic text part into the
// live assistant message so the user sees an "Initializing snapshot…" line
// in the chat — the same place bash/edit tool calls render. The part is
// removed when the snapshot finishes, so the chat history stays clean.
//
// Design notes:
// - The question is asked once per instance — `state.asked` guards follow-up
// prompts so a slow repo doesn't spam the user every turn.
// - We do NOT call `Config.update()` when the user picks "Disable" because
// that finalizer runs `Instance.dispose()` and tears down the live turn.
// Instead we write the file directly via `KilocodeConfig.updateProjectConfig`
// without touching the active Config service.
// - If the user picks "Continue", the fiber keeps running; we just `join` it
// and return its value. Any error during the in-flight snapshot is logged
// and swallowed so the turn can proceed.
//
// All of this is Kilo-specific — the upstream snapshot module remains a thin
// shim that calls into here.
import { Duration, Effect, Fiber } from "effect"
import { applyEdits, modify } from "jsonc-parser"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Question } from "@/question"
import type { MessageID, PartID, SessionID } from "@/session/schema"
import { PartID as PartIDSchema } from "@/session/schema"
import type { MessageV2 } from "@/session/message-v2"
import { KilocodeConfig } from "@/kilocode/config/config"
import { ConfigParse } from "@/config/parse"
import * as Log from "@opencode-ai/core/util/log"
import { iife } from "@/util/iife"
import { makeRuntime } from "@/effect/run-service"
import type { Config } from "@/config/config"
// Avoid an eager `import { Session }` here: session/index.ts indirectly
// re-exports this module (via Snapshot.Service), so resolving
// `Session.Service` at module load races with our own initialization and
// throws "Cannot access 'Service' before initialization". The session
// runtime is built lazily on first use inside the default hooks.
//
// The type-only import of `MessageV2` above is erased at compile time, so it
// doesn't participate in the runtime cycle — it just lets us type the narrow
// part-API shim below without `as any`.
/**
* Narrow typed view of the `Session.Service` methods we actually call from
* `defaultHooks`. Keeping this local instead of importing `Session.Interface`
* avoids the value-level cycle described above, while still giving us
* compile-time checking on payload shape. If `Session.Service` ever renames
* these methods the cast in `sessionRuntime()` below will fail typecheck
* instead of blowing up at runtime.
*/
interface SessionPartAPI {
readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect<PartID>
}
type SessionRuntime = {
runPromise: <A>(fn: (svc: SessionPartAPI) => Effect.Effect<A>) => Promise<A>
}
export namespace KiloSnapshotTrack {
const log = Log.create({ service: "snapshot.track" })
export const TIMEOUT_MS = iife(() => {
const raw = process.env["KILO_SNAPSHOT_TRACK_TIMEOUT_MS"]
if (raw) {
const parsed = Number(raw)
if (Number.isFinite(parsed) && parsed > 0) return parsed
}
return 10_000
})
// Wire values — also function as i18n keys via `labelKey`/`headerKey`.
// The backend matches replies on `label`, so the canonical English strings
// stay stable even when clients render a translated `labelKey` variant.
export const ANSWER_CONTINUE = "Continue with snapshots"
export const ANSWER_DISABLE = "Disable for this project"
/**
* User-visible progress label injected into the live assistant message
* while snapshot.track() is running. The leading `{spinner}` placeholder
* is replaced at publish time with a rotating braille frame this matches
* the animated progress indicator that running task/tool parts render
* elsewhere, without needing a dedicated part renderer in the TUI or
* the webview.
*
* The same label is shown whether the snapshot is just starting or has
* already blown past the slow-repo timeout; the accompanying dialog
* carries the "why" for the wait, so the in-chat indicator stays short
* and stable.
*/
export const PROGRESS_INITIALIZING = "{spinner} Initializing snapshot…"
/**
* Braille spinner frames used to animate the `{spinner}` placeholder.
* Same set as the other CLI spinners / running task indicators.
*/
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
/** Interval between spinner frame updates, in ms. */
export const SPINNER_INTERVAL_MS = 120
/** Replace the `{spinner}` placeholder in `template` with the given frame. */
export const formatProgress = (template: string, frame: string): string => template.replace("{spinner}", frame)
/** Per-instance state. Lives as long as the Snapshot.Service scope. */
export interface State {
/** Skip every future track call once this flips. Resets when the instance reloads. */
disabledForSession: boolean
/** One-shot guard so we don't prompt the user every turn. */
asked: boolean
}
export const makeState = (): State => ({
disabledForSession: false,
asked: false,
})
/** Answer shape returned by `askUser`. Three-valued because dismiss !== disable. */
export type Answer = "continue" | "disable" | "dismissed"
/**
* Hooks injected by the snapshot layer. Split out so the unit tests can
* substitute fakes without reaching for the real Question/filesystem stack.
*/
export interface Hooks {
/** Ask the user. Returns "dismissed" if the question is rejected. */
readonly ask: (input: { sessionID: SessionID }) => Promise<Answer>
/** Persist `"snapshot": false` to the project config without disposing the instance. */
readonly persistDisable: () => Promise<void>
/**
* Publish a synthetic "initializing snapshot…" message part on the given
* assistant message so the UI (TUI + webview) renders it in the chat
* scrollback. Returns the opaque handle the caller passes to `updateProgress`
* / `endProgress`. Returning `undefined` means "no target" the caller
* should then skip the progress indicator entirely.
*/
readonly startProgress: (input: {
sessionID: SessionID
messageID: MessageID
text: string
}) => Promise<ProgressHandle | undefined>
/** Update the visible text on the in-flight progress part. */
readonly updateProgress: (input: { handle: ProgressHandle; text: string }) => Promise<void>
/** Remove the progress part so the chat stays clean once the snapshot is done. */
readonly endProgress: (input: { handle: ProgressHandle }) => Promise<void>
}
/**
* Opaque handle returned by `startProgress`. The production hook stores the
* published text-part coordinates; tests store whatever they need to verify
* the lifecycle.
*/
export type ProgressHandle = {
readonly sessionID: SessionID
readonly messageID: MessageID
readonly partID: PartID
}
export interface WrapInput {
readonly inner: Effect.Effect<string | undefined>
readonly state: State
readonly sessionID?: SessionID
/**
* When provided, the wrapper injects an in-message "initializing snapshot…"
* indicator onto this assistant message. Omitted on callers that don't
* have an assistant message yet (e.g. background refreshes); those skip
* the indicator and fall back to the silent timeout behaviour.
*/
readonly messageID?: MessageID
readonly hooks?: Hooks
/** Override the 10s default for tests. */
readonly timeoutMs?: number
/**
* Override the 500ms delay before the indicator appears. Tests set a
* tiny value so the delay is actually observable within a test run.
*/
readonly progressDelayMs?: number
}
/**
* Delay in ms before we inject the "Initializing snapshot…" part. Snapshots
* on normal-sized repos finish well under this, so the chat stays clean
* without a one-frame flash of the indicator. Big repos blow past this and
* the user gets a clear in-chat reason for the wait.
*/
const PROGRESS_DELAY_MS = 500
/**
* Runs `inner` with a timeout + slow-repo prompt flow. Returns the snapshot
* hash on success, undefined on timeout/skip/error.
*/
export const wrap = (input: WrapInput): Effect.Effect<string | undefined> =>
Effect.gen(function* () {
if (input.state.disabledForSession) return undefined
const hooks = input.hooks ?? defaultHooks
const timeoutMs = input.timeoutMs ?? TIMEOUT_MS
const progressDelayMs = input.progressDelayMs ?? PROGRESS_DELAY_MS
// The progress part is only published when we have both a session and
// a target message. Background/non-turn callers skip the indicator.
const canShowProgress = !!(input.sessionID && input.messageID)
let handle: ProgressHandle | undefined
let frameIdx = 0
const nextFrameText = () => {
const frame = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]
frameIdx += 1
return formatProgress(PROGRESS_INITIALIZING, frame)
}
const clearProgress = () =>
Effect.gen(function* () {
if (!handle) return
const h = handle
handle = undefined
yield* Effect.promise(() =>
hooks.endProgress({ handle: h }).catch((err) => {
log.warn("failed to clear snapshot progress part", { err })
}),
)
})
// Delay the "Initializing snapshot…" indicator so fast snapshots never
// flash a misleading line in the chat. The fiber:
// 1. Sleeps until the delay expires.
// 2. Publishes the first spinner frame.
// 3. Enters an animation loop that advances a frame every
// SPINNER_INTERVAL_MS. The loop runs in the same fiber so
// interrupting `progressFiber` stops everything.
const progressFiber = canShowProgress
? yield* Effect.forkChild(
Effect.gen(function* () {
yield* Effect.sleep(Duration.millis(Math.min(progressDelayMs, timeoutMs)))
if (!input.sessionID || !input.messageID) return
const started = yield* Effect.promise(async () => {
try {
return await hooks.startProgress({
sessionID: input.sessionID!,
messageID: input.messageID!,
text: nextFrameText(),
})
} catch (err) {
log.warn("failed to publish snapshot progress part", { err })
return undefined
}
})
if (!started) return
handle = started
while (true) {
yield* Effect.sleep(Duration.millis(SPINNER_INTERVAL_MS))
if (!handle) return
const text = nextFrameText()
const h = handle
yield* Effect.promise(() =>
hooks.updateProgress({ handle: h, text }).catch((err) => {
log.warn("failed to advance snapshot spinner frame", { err })
}),
)
}
}),
)
: undefined
const fiber = yield* Effect.forkChild(input.inner)
const quick = yield* Fiber.join(fiber).pipe(
Effect.timeoutOption(Duration.millis(timeoutMs)),
Effect.catch((err) => {
log.warn("snapshot track failed", { err })
return Effect.succeed({ _tag: "Some" as const, value: undefined as string | undefined })
}),
)
if (quick._tag === "Some") {
if (progressFiber) yield* Fiber.interrupt(progressFiber)
yield* clearProgress()
return quick.value
}
// Timed out. Keep the existing "Initializing snapshot…" indicator
// animating so the user still sees live progress while the slow-repo
// dialog is visible. The progress fiber is only torn down once we've
// decided whether to keep waiting, disable, or skip below.
// Slow path. No target session to prompt against, or we've already
// prompted on this instance — skip silently.
if (!input.sessionID || input.state.asked) {
log.warn("snapshot track slow; skipping for this instance", { timeoutMs })
input.state.disabledForSession = true
yield* Fiber.interrupt(fiber)
if (progressFiber) yield* Fiber.interrupt(progressFiber)
yield* clearProgress()
return undefined
}
input.state.asked = true
const sessionID = input.sessionID
const answer = yield* Effect.promise(() => hooks.ask({ sessionID }))
if (answer === "continue") {
log.info("user chose to keep waiting for snapshot; joining fiber")
const finished = yield* Fiber.join(fiber).pipe(
Effect.catch((err) => {
log.warn("snapshot track failed after user continue", { err })
return Effect.succeed(undefined as string | undefined)
}),
)
if (progressFiber) yield* Fiber.interrupt(progressFiber)
yield* clearProgress()
// Reset `asked` only when the snapshot actually succeeded. That way a
// future slow turn (e.g. a new massive worktree gets added) still
// surfaces the dialog instead of silently disabling snapshots on a
// user who just explicitly said "keep them on". If the fiber failed
// (finished === undefined), we leave `asked` sticky to avoid prompt
// spam on a repo that repeatedly errors.
if (finished) input.state.asked = false
return finished
}
yield* Fiber.interrupt(fiber)
if (progressFiber) yield* Fiber.interrupt(progressFiber)
input.state.disabledForSession = true
if (answer === "disable") {
log.info("user chose to disable snapshot for this project")
yield* Effect.promise(() =>
hooks.persistDisable().catch((err) => {
log.error("failed to persist snapshot:false to project config", { err })
}),
)
} else {
log.info("user dismissed snapshot prompt; disabling for this instance only")
}
yield* clearProgress()
return undefined
})
// ── Default hooks (production wiring) ──────────────────────────────────
const questionRt = makeRuntime(Question.Service, Question.defaultLayer)
const fsRt = makeRuntime(AppFileSystem.Service, AppFileSystem.defaultLayer)
// Lazy to break a module-load cycle with @/session/index.ts. The single
// cast on the `makeRuntime(...)` result narrows the fully generic runtime
// to the small `SessionPartAPI` surface defined above.
let cachedSessionRt: SessionRuntime | undefined
async function sessionRuntime(): Promise<SessionRuntime> {
if (cachedSessionRt) return cachedSessionRt
const mod = await import("@/session/session")
cachedSessionRt = makeRuntime(mod.Session.Service, mod.Session.defaultLayer) as unknown as SessionRuntime
return cachedSessionRt
}
/** Build the synthetic progress part payload so both start/update share one shape. */
const progressPart = (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
text: string
}): MessageV2.TextPart => ({
id: input.partID,
messageID: input.messageID,
sessionID: input.sessionID,
type: "text",
text: input.text,
synthetic: true,
})
export const defaultHooks: Hooks = {
async startProgress(input) {
const partID = PartIDSchema.ascending()
try {
const rt = await sessionRuntime()
await rt.runPromise((svc) => svc.updatePart(progressPart({ ...input, partID })))
return { sessionID: input.sessionID, messageID: input.messageID, partID }
} catch (err) {
log.warn("failed to publish snapshot progress part", { err })
return undefined
}
},
async updateProgress(input) {
try {
const rt = await sessionRuntime()
await rt.runPromise((svc) =>
svc.updatePart(
progressPart({
sessionID: input.handle.sessionID,
messageID: input.handle.messageID,
partID: input.handle.partID,
text: input.text,
}),
),
)
} catch (err) {
log.warn("failed to update snapshot progress part", { err })
}
},
async endProgress(input) {
try {
const rt = await sessionRuntime()
await rt.runPromise((svc) =>
svc.removePart({
sessionID: input.handle.sessionID,
messageID: input.handle.messageID,
partID: input.handle.partID,
}),
)
} catch (err) {
log.warn("failed to remove snapshot progress part", { err })
}
},
async ask(input) {
const answers = await questionRt
.runPromise((svc) =>
svc.ask({
sessionID: input.sessionID,
blocking: true,
questions: [
{
header: "Snapshot is slow",
headerKey: "snapshot.slowRepo.header",
question:
"It is taking a long time to initialize the snapshot system, likely due to the size of the repository.\n\n" +
"Do you want to disable Snapshots for this repository?",
questionKey: "snapshot.slowRepo.question",
custom: false,
options: [
{
label: ANSWER_CONTINUE,
labelKey: "snapshot.slowRepo.answer.continue",
description:
"Keep waiting for the snapshot to complete. Subsequent turns are fast once the initial snapshot is built.",
descriptionKey: "snapshot.slowRepo.answer.continue.description",
},
{
label: ANSWER_DISABLE,
labelKey: "snapshot.slowRepo.answer.disable",
description:
"Turn off Kilo's snapshots for this project. You will lose undo/redo of Kilo file changes, but git still tracks everything.",
descriptionKey: "snapshot.slowRepo.answer.disable.description",
},
],
},
],
}),
)
.catch(() => undefined)
const pick = answers?.[0]?.[0]
if (pick === ANSWER_CONTINUE) return "continue"
if (pick === ANSWER_DISABLE) return "disable"
return "dismissed"
},
async persistDisable() {
const directory = await currentDirectory()
if (!directory) return
// Every field on Config.Info is Schema.optional(...), so a single-key
// object is structurally a valid Config.Info — no cast needed.
const patch: Config.Info = { snapshot: false }
await fsRt.runPromise((fs) =>
Effect.gen(function* () {
yield* KilocodeConfig.updateProjectConfig({
fs,
directory: directory.directory,
worktree: directory.worktree,
config: patch,
read: (file) =>
fs.readFileString(file).pipe(
Effect.map((s) => s as string | undefined),
Effect.catch(() => Effect.succeed<string | undefined>(undefined)),
),
parse: (input, file) => ConfigParse.jsonc(input, file) as Config.Info,
patch: patchTopLevelJsonc,
writable: (c) => c,
})
}),
)
},
}
/**
* Minimal JSONC patcher kept local to this module so it does not depend on
* the (unexported) helper inside `config/config.ts`.
*
* This writes each top-level key in `patch` as a single `modify()` edit,
* which means nested object values are replaced wholesale rather than
* merged key-by-key. That's fine for the `{ snapshot: false }` payload we
* currently send, but any future caller that needs deep merging should
* promote `config/config.ts::patchJsonc` to a shared helper and use that
* instead. We assert on non-primitive values so a misuse fails loudly in
* development rather than silently clobbering a user's nested config.
*/
function patchTopLevelJsonc(input: string, patch: Config.Info): string {
return Object.entries(patch).reduce((out, [key, value]) => {
if (value === undefined) return out
if (value !== null && typeof value === "object") {
log.warn("patchTopLevelJsonc called with a non-scalar value; nested keys will be replaced wholesale", { key })
}
const edits = modify(out, [key], value, {
formattingOptions: { insertSpaces: true, tabSize: 2 },
})
return applyEdits(out, edits)
}, input)
}
/**
* Resolve the active instance directory/worktree. Runs via `Instance.current`
* when available; returns undefined outside of an instance context (e.g. in
* tests that bypass the runtime).
*/
async function currentDirectory(): Promise<{ directory: string; worktree?: string } | undefined> {
const { Instance } = await import("@/project/instance")
try {
return { directory: Instance.directory, worktree: Instance.worktree }
} catch {
return undefined
}
}
}
+31 -4
View File
@@ -742,7 +742,7 @@ export const layer = Layer.effect(
return mcpConfig
})
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, opts?: { callback?: boolean }) { // kilocode_change
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
@@ -751,8 +751,11 @@ export const layer = Layer.effect(
// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
// Start the callback server with custom redirectUri if configured
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
// kilocode_change start - authenticate() defers binding the callback port until a redirect is needed
if (opts?.callback !== false) {
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
}
// kilocode_change end
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
@@ -798,7 +801,7 @@ export const layer = Layer.effect(
})
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
const result = yield* startAuth(mcpName)
const result = yield* startAuth(mcpName, { callback: false }) // kilocode_change
if (!result.authorizationUrl) {
const client = "client" in result ? result.client : undefined
const mcpConfig = yield* getMcpConfig(mcpName)
@@ -820,6 +823,30 @@ export const layer = Layer.effect(
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState })
// kilocode_change start - bind only after redirect exists, and clean up if binding fails
const mcpConfig = yield* getMcpConfig(mcpName)
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
if (mcpConfig.type !== "remote") return { status: "failed", error: `MCP server ${mcpName} is not a remote server` } as Status
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
const err = yield* Effect.tryPromise({
try: () => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.match({
onFailure: (err) => err,
onSuccess: () => undefined,
}),
)
if (err) {
const transport = pendingOAuthTransports.get(mcpName)
pendingOAuthTransports.delete(mcpName)
yield* auth.clearOAuthState(mcpName)
yield* auth.clearCodeVerifier(mcpName)
yield* Effect.tryPromise(() => transport?.close() ?? Promise.resolve()).pipe(Effect.ignore)
return { status: "failed", error: err.message } as Status
}
// kilocode_change end
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
+3 -1
View File
@@ -916,7 +916,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
parts: [],
}
for (const part of msg.parts) {
if (part.type === "text" && !part.ignored) // kilocode_change - keep local UI warnings out of future prompts
// kilocode_change start - keep local UI warnings out of future prompts
if (part.type === "text" && !part.ignored)
// kilocode_change end
assistantMessage.parts.push({
type: "text",
text: part.text,
+20 -5
View File
@@ -114,7 +114,12 @@ export const layer: Layer.Layer<
// Pre-capture snapshot before the LLM stream starts. The AI SDK
// may execute tools internally before emitting start-step events,
// so capturing inside the event handler can be too late.
const initialSnapshot = yield* snapshot.track()
// kilocode_change start - pass sessionID + messageID so the slow-repo prompt/progress indicator can attach
const initialSnapshot = yield* snapshot.track({
sessionID: input.sessionID,
messageID: input.assistantMessage.id,
})
// kilocode_change end
const ctx: ProcessorContext = {
assistantMessage: input.assistantMessage,
sessionID: input.sessionID,
@@ -407,7 +412,10 @@ export const layer: Layer.Layer<
case "start-step":
ctx.stepStart = performance.now() // kilocode_change
ctx.step = { reasoning: false, text: false, tool: false } // kilocode_change
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
// kilocode_change start - pass sessionID + messageID so the slow-repo prompt/progress indicator can attach
if (!ctx.snapshot)
ctx.snapshot = yield* snapshot.track({ sessionID: ctx.sessionID, messageID: ctx.assistantMessage.id })
// kilocode_change end
yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
@@ -423,13 +431,15 @@ export const layer: Layer.Layer<
usage: value.usage,
metadata: value.providerMetadata,
})
// kilocode_change start
// kilocode_change start - guard against finish-step without start-step:
// ctx.stepStart is 0 until `start-step` fires, which would feed a
// huge bogus `elapsed` into telemetry. Fall back to now().
KiloSessionProcessor.trackStep({
sessionID: ctx.sessionID,
model: ctx.model,
tokens: usage.tokens,
cost: usage.cost,
elapsed: Math.round(performance.now() - ctx.stepStart),
elapsed: Math.round(performance.now() - (ctx.stepStart || performance.now())),
})
// kilocode_change end
ctx.assistantMessage.finish = value.finishReason
@@ -441,7 +451,12 @@ export const layer: Layer.Layer<
yield* session.updatePart({
id: PartID.ascending(),
reason: value.finishReason,
snapshot: yield* snapshot.track(),
// kilocode_change start - pass sessionID + messageID
snapshot: yield* snapshot.track({
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.id,
}),
// kilocode_change end
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "step-finish",
+19 -3
View File
@@ -13,6 +13,8 @@ import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change
import { DiffFull } from "../kilocode/snapshot/diff-full" // kilocode_change
import { KiloSnapshotTrack } from "../kilocode/snapshot/track" // kilocode_change
import type { MessageID, SessionID } from "../session/schema" // kilocode_change
import { withStatics } from "@/util/schema"
import { zod } from "@/util/effect-zod"
@@ -63,7 +65,10 @@ type State = Omit<Interface, "init">
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
// kilocode_change start - accept optional sessionID/messageID so the slow-repo prompt can target
// a client and the in-message "initializing snapshot" indicator can attach to the live turn
readonly track: (opts?: { sessionID?: SessionID; messageID?: MessageID }) => Effect.Effect<string | undefined>
// kilocode_change end
readonly patch: (hash: string) => Effect.Effect<Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Patch[]) => Effect.Effect<void>
@@ -782,6 +787,10 @@ export const layer: Layer.Layer<
}),
)
// kilocode_change start - per-instance state for the slow-repo track wrapper
const trackState = KiloSnapshotTrack.makeState()
// kilocode_change end
return Service.of({
init: Effect.fn("Snapshot.init")(function* () {
yield* InstanceState.get(state)
@@ -789,8 +798,15 @@ export const layer: Layer.Layer<
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
}),
track: Effect.fn("Snapshot.track")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.track())
// kilocode_change start - timeout + interactive "disable for this project" prompt
track: Effect.fn("Snapshot.track")(function* (opts) {
return yield* KiloSnapshotTrack.wrap({
inner: InstanceState.useEffect(state, (s) => s.track()),
state: trackState,
sessionID: opts?.sessionID,
messageID: opts?.messageID,
})
// kilocode_change end
}),
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
@@ -0,0 +1,520 @@
// kilocode_change - new file
//
// Unit tests for KiloSnapshotTrack.wrap — the slow-repo guard that sits
// on top of Snapshot.track(). These tests inject fake hooks so we don't
// touch the real Question module or write to the filesystem.
import { describe, expect, test } from "bun:test"
import { Duration, Effect } from "effect"
import type { MessageID, SessionID, PartID } from "../../src/session/schema"
import { KiloSnapshotTrack } from "../../src/kilocode/snapshot/track"
const SESSION = "ses_test" as SessionID
const MESSAGE = "msg_test" as MessageID
// Build a fast inner snapshot that resolves immediately with a hash.
const fastInner = (hash = "deadbeef") => Effect.succeed<string | undefined>(hash)
// Build a slow inner snapshot that resolves after `ms` milliseconds.
const slowInner = (ms: number, hash = "slowhash") =>
Effect.promise(() => new Promise<string | undefined>((resolve) => setTimeout(() => resolve(hash), ms)))
// Build an inner snapshot that never completes unless interrupted.
const hangInner = () => Effect.promise(() => new Promise<string | undefined>(() => {}))
// Build an inner snapshot that fails with a typed Effect error. The double
// cast mirrors how the real `Snapshot.track` Effect is shaped: callers see
// `Effect.Effect<string | undefined>` (error channel `never`), but failures
// can still flow through because the production code path uses `Effect.catch`
// to absorb them. Centralizing the cast here keeps per-test code readable.
const failingInner = (err: Error) =>
Effect.fail(err as unknown as never) as unknown as Effect.Effect<string | undefined>
type Event = { kind: "start"; text: string } | { kind: "update"; text: string } | { kind: "end" }
interface Calls {
ask: number
persist: number
progress: Event[]
}
const makeHooks = (
answer: KiloSnapshotTrack.Answer | Promise<KiloSnapshotTrack.Answer>,
): { hooks: KiloSnapshotTrack.Hooks; calls: Calls } => {
const calls: Calls = { ask: 0, persist: 0, progress: [] }
let counter = 0
const hooks: KiloSnapshotTrack.Hooks = {
async ask() {
calls.ask += 1
return answer
},
async persistDisable() {
calls.persist += 1
},
async startProgress(input) {
calls.progress.push({ kind: "start", text: input.text })
counter += 1
return {
sessionID: input.sessionID,
messageID: input.messageID,
partID: `prt_${counter}` as PartID,
}
},
async updateProgress(input) {
calls.progress.push({ kind: "update", text: input.text })
},
async endProgress() {
calls.progress.push({ kind: "end" })
},
}
return { hooks, calls }
}
describe("KiloSnapshotTrack.wrap", () => {
test("returns the hash when inner resolves before the timeout", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
const result = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: fastInner("fast-hash"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 1000,
}),
)
expect(result).toBe("fast-hash")
expect(calls.ask).toBe(0)
expect(calls.persist).toBe(0)
expect(state.disabledForSession).toBe(false)
expect(state.asked).toBe(false)
})
test("returns undefined immediately when already disabled", async () => {
const state = KiloSnapshotTrack.makeState()
state.disabledForSession = true
const { hooks, calls } = makeHooks("continue")
// We pass a hang inner to prove it's never started — if the guard
// didn't short-circuit, this test would time out.
const result = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: hangInner(),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 5,
}),
)
expect(result).toBeUndefined()
expect(calls.ask).toBe(0)
})
test('timeout + user answer "continue" joins the fiber and returns its value', async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
const result = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: slowInner(80, "finished-late"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 20,
progressDelayMs: 5,
}),
)
expect(result).toBe("finished-late")
expect(calls.ask).toBe(1)
expect(calls.persist).toBe(0)
// After a successful "continue" the guard resets `asked` so a subsequent
// slow turn still gets the dialog instead of being silently disabled.
expect(state.asked).toBe(false)
expect(state.disabledForSession).toBe(false)
})
test('timeout + "disable" interrupts, persists, and flips disabledForSession', async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("disable")
const result = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: hangInner(),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 10,
progressDelayMs: 2,
}),
)
expect(result).toBeUndefined()
expect(calls.ask).toBe(1)
expect(calls.persist).toBe(1)
expect(state.disabledForSession).toBe(true)
expect(state.asked).toBe(true)
})
test('timeout + "dismissed" interrupts and disables, but does NOT persist', async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("dismissed")
const result = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: hangInner(),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 10,
progressDelayMs: 2,
}),
)
expect(result).toBeUndefined()
expect(calls.ask).toBe(1)
expect(calls.persist).toBe(0)
expect(state.disabledForSession).toBe(true)
expect(state.asked).toBe(true)
})
test("timeout without sessionID skips the prompt and disables silently", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
const result = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: hangInner(),
state,
hooks,
timeoutMs: 10,
progressDelayMs: 2,
}),
)
expect(result).toBeUndefined()
expect(calls.ask).toBe(0)
expect(calls.persist).toBe(0)
expect(state.disabledForSession).toBe(true)
expect(state.asked).toBe(false)
// No messageID either → progress indicator is suppressed entirely.
expect(calls.progress).toEqual([])
})
test("subsequent call after disable returns undefined without starting the inner", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks: firstHooks } = makeHooks("disable")
await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: hangInner(),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks: firstHooks,
timeoutMs: 10,
progressDelayMs: 2,
}),
)
expect(state.disabledForSession).toBe(true)
let innerStarted = false
const spyingInner = Effect.sync(() => {
innerStarted = true
return "should-not-run" as string | undefined
})
const { hooks: secondHooks, calls: secondCalls } = makeHooks("continue")
const secondResult = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: spyingInner,
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks: secondHooks,
timeoutMs: 10,
}),
)
expect(secondResult).toBeUndefined()
expect(innerStarted).toBe(false)
expect(secondCalls.ask).toBe(0)
})
test("second slow call after a successful continue re-asks instead of silently disabling", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
// First call: slow → ask → continue → finishes
const first = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: slowInner(80, "hash-1"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 20,
progressDelayMs: 5,
}),
)
expect(first).toBe("hash-1")
expect(calls.ask).toBe(1)
// Reset semantics: successful continue clears `asked` so a future slow
// turn gets the dialog again instead of being silently disabled.
expect(state.asked).toBe(false)
expect(state.disabledForSession).toBe(false)
// Second call: still slow → dialog again → user picks continue again → finishes
const second = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: slowInner(80, "hash-2"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 20,
progressDelayMs: 5,
}),
)
expect(second).toBe("hash-2")
expect(calls.ask).toBe(2) // re-asked
expect(state.disabledForSession).toBe(false)
})
test("continue path keeps `asked` sticky when the fiber finished with no hash", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
// Simulate the fiber eventually completing but with no hash (e.g.
// snapshot disabled mid-flight or non-git repo). The continue path waits
// for it, so we get undefined back — and we must not reset `asked`,
// otherwise repeated failures would keep re-prompting the user every
// turn.
const noHashInner = Effect.promise(
() => new Promise<string | undefined>((resolve) => setTimeout(() => resolve(undefined), 80)),
)
const first = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: noHashInner,
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 20,
progressDelayMs: 5,
}),
)
expect(first).toBeUndefined()
expect(calls.ask).toBe(1)
expect(state.asked).toBe(true)
})
test("inner typed failure is caught and returned as undefined", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
// Typed failure — mirrors how the real inner `track()` fails, which is
// what Effect.catch inside wrap() is designed to handle. Untyped defects
// (e.g. rejected Promises without Effect.tryPromise) are NOT caught; they
// propagate and surface as test failures.
const result = await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: failingInner(new Error("boom")),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 100,
}),
)
expect(result).toBeUndefined()
expect(calls.ask).toBe(0)
expect(state.disabledForSession).toBe(false)
})
})
describe("KiloSnapshotTrack progress indicator", () => {
// Strip the braille spinner frame (first Unicode codepoint, plus the
// trailing space) so tests can assert on the stable descriptive text
// without caring which animation frame landed.
const withoutFrame = (text: string) => text.replace(/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s/, "")
test("fast path does NOT publish a progress message (avoids UI flash)", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: fastInner("hash"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 1000,
}),
)
// The 500ms default delay means fast snapshots never emit a start.
expect(calls.progress).toEqual([])
})
test("slow-but-succeeding path (under timeout) starts then ends the indicator", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: slowInner(200, "late-hash"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 5_000,
progressDelayMs: 5,
}),
)
const events = calls.progress
expect(events.at(0)?.kind).toBe("start")
expect(events.at(-1)?.kind).toBe("end")
const firstText = events.at(0) as Extract<(typeof events)[number], { text: string }>
expect(withoutFrame(firstText.text)).toBe(
KiloSnapshotTrack.formatProgress(KiloSnapshotTrack.PROGRESS_INITIALIZING, "").trim(),
)
// Every "update" event is just an animation tick of the same label — we
// intentionally do NOT escalate the text after the timeout; the dialog
// carries the "why", and the in-chat indicator stays short and stable.
for (const evt of events) {
if (evt.kind !== "update") continue
expect(withoutFrame(evt.text)).toBe(
KiloSnapshotTrack.formatProgress(KiloSnapshotTrack.PROGRESS_INITIALIZING, "").trim(),
)
}
})
test("timed-out path keeps the initializing label (no text escalation)", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: slowInner(800, "late-hash"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 200,
progressDelayMs: 5,
}),
)
const events = calls.progress
expect(events.at(0)?.kind).toBe("start")
expect(events.at(-1)?.kind).toBe("end")
// After the timeout trips, the label should stay on PROGRESS_INITIALIZING.
// Every emitted event carries the same descriptive text modulo spinner
// frame, proving we never escalated to a second template.
const base = KiloSnapshotTrack.formatProgress(KiloSnapshotTrack.PROGRESS_INITIALIZING, "").trim()
for (const evt of events) {
if (!("text" in evt)) continue
expect(withoutFrame(evt.text)).toBe(base)
}
})
test("disable path removes the indicator before returning", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("disable")
await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: hangInner(),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 200,
progressDelayMs: 5,
}),
)
expect(calls.progress.at(-1)).toEqual({ kind: "end" })
})
test("missing messageID suppresses the indicator even when slow", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: slowInner(150, "hash"),
state,
sessionID: SESSION,
hooks,
timeoutMs: 5_000,
progressDelayMs: 5,
}),
)
// No messageID → skip the progress indicator entirely.
expect(calls.progress).toEqual([])
})
test("frames cycle through the braille spinner set while animating", async () => {
const state = KiloSnapshotTrack.makeState()
const { hooks, calls } = makeHooks("continue")
// Run long enough to get multiple animation ticks.
await Effect.runPromise(
KiloSnapshotTrack.wrap({
inner: slowInner(500, "hash"),
state,
sessionID: SESSION,
messageID: MESSAGE,
hooks,
timeoutMs: 5_000,
progressDelayMs: 5,
}),
)
const textEvents = calls.progress.filter(
(e): e is Extract<(typeof calls.progress)[number], { text: string }> => "text" in e,
)
const frames = new Set<string>()
for (const evt of textEvents) {
const m = evt.text.match(/^([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])/)
if (m) frames.add(m[1])
}
// At least two different frames should have been rendered during the run.
expect(frames.size).toBeGreaterThanOrEqual(2)
})
})
describe("KiloSnapshotTrack constants", () => {
test("TIMEOUT_MS defaults to 10s and respects env override", () => {
// The constant is evaluated once at module load, so we can only assert
// on the default in this run. The env override is exercised by running
// with KILO_SNAPSHOT_TRACK_TIMEOUT_MS, which this test suite does not set.
expect(KiloSnapshotTrack.TIMEOUT_MS).toBe(10_000)
})
test("exposes stable answer labels", () => {
expect(KiloSnapshotTrack.ANSWER_CONTINUE).toBe("Continue with snapshots")
expect(KiloSnapshotTrack.ANSWER_DISABLE).toBe("Disable for this project")
})
})
// Small guard: if Duration is ever swapped out for an incompatible version,
// this will catch it at compile time.
void Duration.millis
@@ -1,5 +1,6 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import { createServer } from "http" // kilocode_change
// Mock UnauthorizedError to match the SDK's class
class MockUnauthorizedError extends Error {
@@ -109,10 +110,40 @@ beforeEach(() => {
connectSucceedsImmediately = false
})
// kilocode_change start
async function occupy(port: number) {
const srv = createServer()
const bound = await new Promise<boolean>((resolve, reject) => {
const fail = (err: Error & { code?: string }) => {
srv.off("error", fail)
if (err.code === "EADDRINUSE") {
resolve(false)
return
}
reject(err)
}
srv.once("error", fail)
srv.listen(port, "127.0.0.1", () => {
srv.off("error", fail)
resolve(true)
})
})
return {
async [Symbol.asyncDispose]() {
if (!bound) return
await new Promise<void>((resolve) => srv.close(() => resolve()))
},
}
}
// kilocode_change end
// Import modules after mocking
const { MCP } = await import("../../src/mcp/index")
const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture")
const { OAUTH_CALLBACK_PORT } = await import("../../src/mcp/oauth-provider") // kilocode_change
test("first connect to OAuth server shows needs_auth instead of failed", async () => {
await using tmp = await tmpdir({
@@ -251,6 +282,10 @@ test("authenticate() stores a connected client when auth completes without redir
)
},
})
// kilocode_change start
await using port = await occupy(OAUTH_CALLBACK_PORT)
void port
// kilocode_change end
await Instance.provide({
directory: tmp.path,
@@ -1585,9 +1585,8 @@ unixSkip(
),
30_000,
)
// kilocode_change end
unix(
unixSkip(
"shell rejects when another shell is already running",
() =>
withSh(() =>
@@ -1617,6 +1616,7 @@ unix(
),
30_000,
)
// kilocode_change end
// Abort signal propagation tests for inline tool execution
+103 -103
View File
@@ -76,6 +76,103 @@ export type EventFileWatcherUpdated = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
/**
* Optional i18n key for the label; clients translate and still reply with `label`
*/
labelKey?: string
/**
* Optional i18n key for the description
*/
descriptionKey?: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Optional i18n key for the question text; clients fall back to `question` when missing
*/
questionKey?: string
/**
* Optional i18n key for the header; clients fall back to `header` when missing
*/
headerKey?: string
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionTool = {
messageID: string
callID: string
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
/**
* Whether this question blocks prompt input (default: true)
*/
blocking?: boolean
tool?: QuestionTool
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type QuestionReplied = {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
export type EventQuestionReplied = {
type: "question.replied"
properties: QuestionReplied
}
export type QuestionRejected = {
sessionID: string
requestID: string
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: QuestionRejected
}
export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics"
properties: {
@@ -360,103 +457,6 @@ export type EventInstallationUpdateAvailable = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
/**
* Optional i18n key for the label; clients translate and still reply with `label`
*/
labelKey?: string
/**
* Optional i18n key for the description
*/
descriptionKey?: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Optional i18n key for the question text; clients fall back to `question` when missing
*/
questionKey?: string
/**
* Optional i18n key for the header; clients fall back to `header` when missing
*/
headerKey?: string
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionTool = {
messageID: string
callID: string
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
/**
* Whether this question blocks prompt input (default: true)
*/
blocking?: boolean
tool?: QuestionTool
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type QuestionReplied = {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
export type EventQuestionReplied = {
type: "question.replied"
properties: QuestionReplied
}
export type QuestionRejected = {
sessionID: string
requestID: string
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: QuestionRejected
}
export type Todo = {
/**
* Brief description of the task
@@ -1325,6 +1325,9 @@ export type GlobalEvent = {
| EventGlobalConfigUpdated
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
@@ -1346,9 +1349,6 @@ export type GlobalEvent = {
| EventSessionError
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
@@ -2463,6 +2463,9 @@ export type Event =
| EventGlobalConfigUpdated
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
@@ -2484,9 +2487,6 @@ export type Event =
| EventSessionError
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
+197 -197
View File
@@ -11028,6 +11028,185 @@
},
"required": ["type", "properties"]
},
"QuestionOption": {
"type": "object",
"properties": {
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string"
},
"description": {
"description": "Explanation of choice",
"type": "string"
},
"labelKey": {
"description": "Optional i18n key for the label; clients translate and still reply with `label`",
"type": "string"
},
"descriptionKey": {
"description": "Optional i18n key for the description",
"type": "string"
}
},
"required": ["label", "description"]
},
"QuestionInfo": {
"type": "object",
"properties": {
"question": {
"description": "Complete question",
"type": "string"
},
"header": {
"description": "Very short label (max 30 chars)",
"type": "string"
},
"options": {
"description": "Available choices",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionOption"
}
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean"
},
"questionKey": {
"description": "Optional i18n key for the question text; clients fall back to `question` when missing",
"type": "string"
},
"headerKey": {
"description": "Optional i18n key for the header; clients fall back to `header` when missing",
"type": "string"
},
"custom": {
"description": "Allow typing a custom answer (default: true)",
"type": "boolean"
}
},
"required": ["question", "header", "options"]
},
"QuestionTool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
},
"QuestionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^que.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"questions": {
"description": "Questions to ask",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionInfo"
}
},
"blocking": {
"description": "Whether this question blocks prompt input (default: true)",
"type": "boolean"
},
"tool": {
"$ref": "#/components/schemas/QuestionTool"
}
},
"required": ["id", "sessionID", "questions"]
},
"Event.question.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.asked"
},
"properties": {
"$ref": "#/components/schemas/QuestionRequest"
}
},
"required": ["type", "properties"]
},
"QuestionAnswer": {
"type": "array",
"items": {
"type": "string"
}
},
"QuestionReplied": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["sessionID", "requestID", "answers"]
},
"Event.question.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.replied"
},
"properties": {
"$ref": "#/components/schemas/QuestionReplied"
}
},
"required": ["type", "properties"]
},
"QuestionRejected": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
}
},
"required": ["sessionID", "requestID"]
},
"Event.question.rejected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.rejected"
},
"properties": {
"$ref": "#/components/schemas/QuestionRejected"
}
},
"required": ["type", "properties"]
},
"Event.lsp.client.diagnostics": {
"type": "object",
"properties": {
@@ -11795,185 +11974,6 @@
},
"required": ["type", "properties"]
},
"QuestionOption": {
"type": "object",
"properties": {
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string"
},
"description": {
"description": "Explanation of choice",
"type": "string"
},
"labelKey": {
"description": "Optional i18n key for the label; clients translate and still reply with `label`",
"type": "string"
},
"descriptionKey": {
"description": "Optional i18n key for the description",
"type": "string"
}
},
"required": ["label", "description"]
},
"QuestionInfo": {
"type": "object",
"properties": {
"question": {
"description": "Complete question",
"type": "string"
},
"header": {
"description": "Very short label (max 30 chars)",
"type": "string"
},
"options": {
"description": "Available choices",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionOption"
}
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean"
},
"questionKey": {
"description": "Optional i18n key for the question text; clients fall back to `question` when missing",
"type": "string"
},
"headerKey": {
"description": "Optional i18n key for the header; clients fall back to `header` when missing",
"type": "string"
},
"custom": {
"description": "Allow typing a custom answer (default: true)",
"type": "boolean"
}
},
"required": ["question", "header", "options"]
},
"QuestionTool": {
"type": "object",
"properties": {
"messageID": {
"type": "string",
"pattern": "^msg.*"
},
"callID": {
"type": "string"
}
},
"required": ["messageID", "callID"]
},
"QuestionRequest": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^que.*"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"questions": {
"description": "Questions to ask",
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionInfo"
}
},
"blocking": {
"description": "Whether this question blocks prompt input (default: true)",
"type": "boolean"
},
"tool": {
"$ref": "#/components/schemas/QuestionTool"
}
},
"required": ["id", "sessionID", "questions"]
},
"Event.question.asked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.asked"
},
"properties": {
"$ref": "#/components/schemas/QuestionRequest"
}
},
"required": ["type", "properties"]
},
"QuestionAnswer": {
"type": "array",
"items": {
"type": "string"
}
},
"QuestionReplied": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/QuestionAnswer"
}
}
},
"required": ["sessionID", "requestID", "answers"]
},
"Event.question.replied": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.replied"
},
"properties": {
"$ref": "#/components/schemas/QuestionReplied"
}
},
"required": ["type", "properties"]
},
"QuestionRejected": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"requestID": {
"type": "string",
"pattern": "^que.*"
}
},
"required": ["sessionID", "requestID"]
},
"Event.question.rejected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "question.rejected"
},
"properties": {
"$ref": "#/components/schemas/QuestionRejected"
}
},
"required": ["type", "properties"]
},
"Todo": {
"type": "object",
"properties": {
@@ -14626,6 +14626,15 @@
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
},
@@ -14689,15 +14698,6 @@
{
"$ref": "#/components/schemas/Event.installation.update-available"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
@@ -17288,6 +17288,15 @@
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
},
@@ -17351,15 +17360,6 @@
{
"$ref": "#/components/schemas/Event.installation.update-available"
},
{
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
+16 -2
View File
@@ -1471,13 +1471,27 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
const text = () => (part().text ?? "").trim()
// kilocode_change start
// Synthetic text parts (e.g. "Initializing snapshot…" from the slow-repo guard)
// are transient status indicators, not assistant output — they must never
// carry the copy button, and they must not "steal" last-part status from
// the actual assistant response that precedes them.
//
// On a clean turn the backend removes the part via `removePart` once the
// snapshot finishes, so it never reaches this branch. But if the host
// process is hard-killed mid-snapshot the part stays in storage and
// re-appears on session reload; hiding it when the owning message is no
// longer streaming keeps the scrollback clean in that edge case.
const showSyntheticPart = createMemo(() => !part().synthetic || streaming())
// kilocode_change end
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim() && !item.synthetic) // kilocode_change
.at(-1)
return last?.id === part().id
})
const showCopy = createMemo(() => {
if (part().synthetic) return false // kilocode_change
if (props.message.role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID === null) return false
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
@@ -1494,7 +1508,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
}
return (
<Show when={text()}>
<Show when={text() && showSyntheticPart() /* kilocode_change */}>
<div data-component="text-part">
<div data-slot="text-part-body">
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>