diff --git a/.changeset/alibaba-provider-options.md b/.changeset/alibaba-provider-options.md new file mode 100644 index 00000000000..5b46c9e003a --- /dev/null +++ b/.changeset/alibaba-provider-options.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add Alibaba to `kiloProviderOptions` so thinking is enabled correctly when routing through the Kilo gateway with `ai_sdk_provider: "alibaba"`. diff --git a/.changeset/plan-followup-translations.md b/.changeset/plan-followup-translations.md new file mode 100644 index 00000000000..922a75c83f9 --- /dev/null +++ b/.changeset/plan-followup-translations.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Translate the follow-up prompt shown after finishing a plan. The "Ready to implement?" question, "Start new session" and "Continue here" buttons, and their descriptions now respect the language configured in the VS Code sidebar. diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index ee2a0ffdaf2..3a6374008bc 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "إلغاء", "marketplace.contribute.prompt": "هل تفتقد مهارة أو وضعًا أو خادم MCP؟", "marketplace.contribute.cta": "ساهم على GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "نفّذ", + "plan.followup.question": "جاهز للتنفيذ؟", + "plan.followup.answer.newSession": "بدء جلسة جديدة", + "plan.followup.answer.newSession.description": "نفّذ في جلسة جديدة بسياق نظيف", + "plan.followup.answer.continue": "المتابعة هنا", + "plan.followup.answer.continue.description": "نفّذ الخطة في هذه الجلسة", } diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 38bfb6ae825..5a9db6c39d0 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Cancelar", "marketplace.contribute.prompt": "Está faltando uma skill, modo ou servidor MCP?", "marketplace.contribute.cta": "Contribuir no GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementar", + "plan.followup.question": "Pronto para implementar?", + "plan.followup.answer.newSession": "Iniciar nova sessão", + "plan.followup.answer.newSession.description": "Implementar em uma nova sessão com contexto limpo", + "plan.followup.answer.continue": "Continuar aqui", + "plan.followup.answer.continue.description": "Implementar o plano nesta sessão", } diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 63aad6b807e..1b88c607dbc 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -76,4 +76,12 @@ export const dict = { "marketplace.warning.cancel": "Otkaži", "marketplace.contribute.prompt": "Nedostaje vještina, način rada ili MCP server?", "marketplace.contribute.cta": "Doprinesi na GitHub-u", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementiraj", + "plan.followup.question": "Spreman za implementaciju?", + "plan.followup.answer.newSession": "Pokreni novu sesiju", + "plan.followup.answer.newSession.description": "Implementiraj u novoj sesiji s čistim kontekstom", + "plan.followup.answer.continue": "Nastavi ovdje", + "plan.followup.answer.continue.description": "Implementiraj plan u ovoj sesiji", } diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 53442d3736b..912ecaaad72 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Annuller", "marketplace.contribute.prompt": "Mangler du en skill, tilstand eller MCP-server?", "marketplace.contribute.cta": "Bidrag på GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementér", + "plan.followup.question": "Klar til at implementere?", + "plan.followup.answer.newSession": "Start ny session", + "plan.followup.answer.newSession.description": "Implementér i en ny session med ren kontekst", + "plan.followup.answer.continue": "Fortsæt her", + "plan.followup.answer.continue.description": "Implementér planen i denne session", } diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 190ceff2d67..2e08b98b273 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Abbrechen", "marketplace.contribute.prompt": "Fehlt ein Skill, Modus oder MCP-Server?", "marketplace.contribute.cta": "Auf GitHub beitragen", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Umsetzen", + "plan.followup.question": "Bereit zur Umsetzung?", + "plan.followup.answer.newSession": "Neue Sitzung starten", + "plan.followup.answer.newSession.description": "In einer neuen Sitzung mit leerem Kontext umsetzen", + "plan.followup.answer.continue": "Hier fortfahren", + "plan.followup.answer.continue.description": "Den Plan in dieser Sitzung umsetzen", } diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 2e1f38828c5..cacebb54f97 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -73,4 +73,14 @@ export const dict = { "marketplace.warning.cancel": "Cancel", "marketplace.contribute.prompt": "Missing a skill, mode, or MCP server?", "marketplace.contribute.cta": "Contribute on GitHub", + + // Plan follow-up question shown after plan_exit. The English strings here must match + // the canonical `label`/`header`/`question` sent by the backend — those canonical labels + // are still what the backend matches on (see packages/opencode/src/kilocode/plan-followup.ts). + "plan.followup.header": "Implement", + "plan.followup.question": "Ready to implement?", + "plan.followup.answer.newSession": "Start new session", + "plan.followup.answer.newSession.description": "Implement in a fresh session with a clean context", + "plan.followup.answer.continue": "Continue here", + "plan.followup.answer.continue.description": "Implement the plan in this session", } diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 62fa5ec0ac2..9325ae29a42 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Cancelar", "marketplace.contribute.prompt": "¿Falta una skill, modo o servidor MCP?", "marketplace.contribute.cta": "Contribuir en GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementar", + "plan.followup.question": "¿Listo para implementar?", + "plan.followup.answer.newSession": "Iniciar nueva sesión", + "plan.followup.answer.newSession.description": "Implementar en una sesión nueva con contexto limpio", + "plan.followup.answer.continue": "Continuar aquí", + "plan.followup.answer.continue.description": "Implementar el plan en esta sesión", } diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 43e34edc4dd..51b5735b3e2 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Annuler", "marketplace.contribute.prompt": "Il manque une skill, un mode ou un serveur MCP ?", "marketplace.contribute.cta": "Contribuer sur GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implémenter", + "plan.followup.question": "Prêt à implémenter ?", + "plan.followup.answer.newSession": "Démarrer une nouvelle session", + "plan.followup.answer.newSession.description": "Implémenter dans une nouvelle session avec un contexte vierge", + "plan.followup.answer.continue": "Continuer ici", + "plan.followup.answer.continue.description": "Implémenter le plan dans cette session", } diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index f75cccfb4ae..bf1ae85ff55 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -69,4 +69,12 @@ export const dict = { "marketplace.warning.cancel": "キャンセル", "marketplace.contribute.prompt": "スキル、モード、MCP サーバーが見つかりませんか?", "marketplace.contribute.cta": "GitHub で貢献する", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "実装", + "plan.followup.question": "実装する準備はできましたか?", + "plan.followup.answer.newSession": "新しいセッションを開始", + "plan.followup.answer.newSession.description": "クリーンなコンテキストの新しいセッションで実装する", + "plan.followup.answer.continue": "ここで続行", + "plan.followup.answer.continue.description": "このセッションで計画を実装する", } diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 23c9fe1740f..f1260654972 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -69,4 +69,12 @@ export const dict = { "marketplace.warning.cancel": "취소", "marketplace.contribute.prompt": "스킬, 모드 또는 MCP 서버가 없나요?", "marketplace.contribute.cta": "GitHub에서 기여하기", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "구현", + "plan.followup.question": "구현할 준비가 되셨나요?", + "plan.followup.answer.newSession": "새 세션 시작", + "plan.followup.answer.newSession.description": "깨끗한 컨텍스트의 새 세션에서 구현", + "plan.followup.answer.continue": "여기서 계속하기", + "plan.followup.answer.continue.description": "이 세션에서 계획 구현", } diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 00072f19847..20e2dd6d1c9 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -73,4 +73,12 @@ export const dict = { "marketplace.warning.cancel": "Annuleren", "marketplace.contribute.prompt": "Mist er een skill, modus of MCP-server?", "marketplace.contribute.cta": "Bijdragen op GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementeren", + "plan.followup.question": "Klaar om te implementeren?", + "plan.followup.answer.newSession": "Nieuwe sessie starten", + "plan.followup.answer.newSession.description": "Implementeren in een nieuwe sessie met een lege context", + "plan.followup.answer.continue": "Hier doorgaan", + "plan.followup.answer.continue.description": "Het plan in deze sessie implementeren", } diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 85cfce2679e..e1c1ac61bcf 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Avbryt", "marketplace.contribute.prompt": "Mangler du en skill, modus eller MCP-server?", "marketplace.contribute.cta": "Bidra på GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementer", + "plan.followup.question": "Klar til å implementere?", + "plan.followup.answer.newSession": "Start ny økt", + "plan.followup.answer.newSession.description": "Implementer i en ny økt med ren kontekst", + "plan.followup.answer.continue": "Fortsett her", + "plan.followup.answer.continue.description": "Implementer planen i denne økten", } diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 48d436413c3..0593170d9b0 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Anuluj", "marketplace.contribute.prompt": "Brakuje skilla, trybu lub serwera MCP?", "marketplace.contribute.cta": "Wnieś wkład na GitHubie", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Wdróż", + "plan.followup.question": "Gotowy do wdrożenia?", + "plan.followup.answer.newSession": "Rozpocznij nową sesję", + "plan.followup.answer.newSession.description": "Wdróż w nowej sesji z czystym kontekstem", + "plan.followup.answer.continue": "Kontynuuj tutaj", + "plan.followup.answer.continue.description": "Wdróż plan w tej sesji", } diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 0ac0e661fd8..74acae59272 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Отмена", "marketplace.contribute.prompt": "Не хватает навыка, режима или MCP-сервера?", "marketplace.contribute.cta": "Внести вклад на GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Реализовать", + "plan.followup.question": "Готовы реализовать?", + "plan.followup.answer.newSession": "Начать новую сессию", + "plan.followup.answer.newSession.description": "Реализовать в новой сессии с чистым контекстом", + "plan.followup.answer.continue": "Продолжить здесь", + "plan.followup.answer.continue.description": "Реализовать план в этой сессии", } diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index d8a03606c75..48b02146af5 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "ยกเลิก", "marketplace.contribute.prompt": "ไม่พบทักษะ โหมด หรือเซิร์ฟเวอร์ MCP ที่ต้องการ?", "marketplace.contribute.cta": "ร่วมสมทบบน GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "ดำเนินการ", + "plan.followup.question": "พร้อมดำเนินการหรือยัง?", + "plan.followup.answer.newSession": "เริ่มเซสชันใหม่", + "plan.followup.answer.newSession.description": "ดำเนินการในเซสชันใหม่ที่มีบริบทว่างเปล่า", + "plan.followup.answer.continue": "ดำเนินการต่อที่นี่", + "plan.followup.answer.continue.description": "ดำเนินการตามแผนในเซสชันนี้", } diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 0a3a39d3dbd..e307f790772 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "İptal", "marketplace.contribute.prompt": "Bir yetenek, mod veya MCP sunucusu mu eksik?", "marketplace.contribute.cta": "GitHub'da katkıda bulun", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Uygula", + "plan.followup.question": "Uygulamaya hazır mısın?", + "plan.followup.answer.newSession": "Yeni oturum başlat", + "plan.followup.answer.newSession.description": "Temiz bir bağlamla yeni bir oturumda uygula", + "plan.followup.answer.continue": "Burada devam et", + "plan.followup.answer.continue.description": "Planı bu oturumda uygula", } diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index cb3b1090ce0..7d1707a3e51 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Скасувати", "marketplace.contribute.prompt": "Бракує навички, режиму або MCP-сервера?", "marketplace.contribute.cta": "Зробити внесок на GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Реалізувати", + "plan.followup.question": "Готові реалізувати?", + "plan.followup.answer.newSession": "Почати нову сесію", + "plan.followup.answer.newSession.description": "Реалізувати в новій сесії з чистим контекстом", + "plan.followup.answer.continue": "Продовжити тут", + "plan.followup.answer.continue.description": "Реалізувати план у цій сесії", } diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index d444f4e8eec..d5cd78e8778 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -68,4 +68,12 @@ export const dict = { "marketplace.warning.cancel": "取消", "marketplace.contribute.prompt": "缺少技能、模式或 MCP 服务器?", "marketplace.contribute.cta": "在 GitHub 上贡献", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "实现", + "plan.followup.question": "准备好实现了吗?", + "plan.followup.answer.newSession": "开启新会话", + "plan.followup.answer.newSession.description": "在具有干净上下文的新会话中实现", + "plan.followup.answer.continue": "在此继续", + "plan.followup.answer.continue.description": "在本会话中实现计划", } diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 275727428b8..5cdb8dff670 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -68,4 +68,12 @@ export const dict = { "marketplace.warning.cancel": "取消", "marketplace.contribute.prompt": "缺少技能、模式或 MCP 伺服器?", "marketplace.contribute.cta": "在 GitHub 上貢獻", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "實作", + "plan.followup.question": "準備好實作了嗎?", + "plan.followup.answer.newSession": "開啟新工作階段", + "plan.followup.answer.newSession.description": "在具有乾淨上下文的新工作階段中實作", + "plan.followup.answer.continue": "在此繼續", + "plan.followup.answer.continue.description": "在本工作階段中實作計畫", } diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 046b7512323..d11def623b8 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -74,6 +74,135 @@ - Service classes ↔ ``/`` entries in the corresponding module XML - `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list +## Session Component + +The chat session feature uses a three-layer Model / Controller / View architecture. All files live under +`frontend/src/main/kotlin/ai/kilocode/client/session/`. + +### Layers + +**`SessionModel`** (`model/SessionModel.kt`) + +- Single source of truth for session content and runtime state. +- **EDT-only access** — no synchronisation. `SessionController` guarantees all reads and writes happen on the EDT. +- State is mutated only through dedicated methods (`setState`, `upsertMessage`, `setDiff`, etc.), never via direct field assignment from outside the model. +- Every mutation fires a sealed `SessionModelEvent` that carries the data needed for rendering — UI never needs to read back from the model after receiving an event. +- Each event overrides `toString()` with a compact, stable label (e.g. `"MessageAdded msg1"`, `"DiffUpdated files=2"`). Tests assert events by comparing joined `toString()` output. +- `loadHistory()` and `clear()` reset all state fields — diff, todos, compactionCount, messages, and `SessionState.Idle`. Call them when opening or clearing a session. + +**`SessionController`** (`SessionController.kt`) + +- Owns one `SessionModel`. UIs read from `model` and subscribe to `SessionModelEvent` via `model.addListener()`. +- Accepts an optional `id` at construction. + - `id = null` → lazily creates a new session on the first `prompt()` call. This guarantees events are subscribed before the prompt is sent, eliminating race conditions. + - `id != null` → immediately loads history and subscribes to SSE events on construction. +- After history load, `recoverPending()` seeds state in this priority order: (1) pending permission, (2) pending question, (3) current session status (`busy`/`retry`/`offline` from `KiloSessionService.statuses`), (4) `Idle`. +- All SSE events are filtered by `sessionID` before being handled. `session.error` events with `null` sessionID are treated as global and pass through. +- Publishes coarser lifecycle updates (app/workspace changes, view switching) via `SessionControllerEvent` to registered listeners — keep these separate from the fine-grained `SessionModelEvent` stream. + +**View** (UI classes under `ui/`) + +- Listens to `SessionModelEvent` via `model.addListener(parent) { event -> when(event) { ... } }`. +- The `when` block must be exhaustive — add `-> Unit` branches for events the view intentionally ignores so new events surface as compile errors. +- Views call `SessionController` actions (`prompt()`, `replyPermission()`, etc.) on the EDT; the controller dispatches RPC calls to a coroutine scope. +- Views must never access RPC or services directly — everything goes through the controller. + +### Adding a New Event + +1. Add a subclass to `SessionModelEvent` with a stable `toString()`. +2. Add the corresponding state field and mutation method to `SessionModel`. Reset the field in both `loadHistory()` and `clear()`. +3. Handle the new `ChatEventDto` in `SessionController.handle()` by calling the model mutation method. +4. Add `-> Unit` stubs for the new event in any existing exhaustive `when` blocks in view code. + +### Testing + +Controller tests extend `SessionControllerTestBase` (`test/…/session/SessionControllerTestBase.kt`), +which provides a real IntelliJ Application and EDT via `BasePlatformTestCase`, real frontend services +wired to `FakeSessionRpcApi`, and a set of shared helpers. + +**Two setups:** + +| Setup | When to use | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `val (m, events, modelEvents) = prompted()` | New-session flow — sets app/workspace to ready, creates a controller with no ID, sends an initial prompt. `model.showMessages` is `true`. Start all event-driven tests from here. | +| `controller("ses_test")` + manual `appRpc`/`projectRpc` setup + `flush()` | Existing-session flow — opens a specific session, triggers history load and `recoverPending()`. `model.showMessages` is `false`. Use for recovery and history tests. Pass `show = false` to `assertSession`. | + +**Core assertion helpers:** + +```kotlin +// Full controller state — includes model transcript + status line. +// show=true is the default; pass show=false for existing-session tests. +assertSession(""" + assistant#msg1 + text#prt1: + hello + + [code] [kilo/gpt-5] [idle] +""", m) + +// Just the model transcript (no status line) +assertModel("diff: src/A.kt src/B.kt", m) + +// Model event stream — one event per line via event.toString() +assertModelEvents(""" + MessageAdded msg1 + ContentAdded msg1/prt1 +""", modelEvents) + +// Controller lifecycle events +assertControllerEvents("WorkspaceReady", events) +``` + +**Emitting events and flushing:** + +```kotlin +emit(ChatEventDto.TurnOpen("ses_test")) // emits + flushes by default +emit(ChatEventDto.PartDelta(…), flush = false) // batch without intermediate flush +flush() // settle coroutines + drain EDT +``` + +**`FakeSessionRpcApi` configurable state:** + +| Field | Purpose | +| ------------------------------------------------------------------ | ------------------------------------------------------ | +| `rpc.events` (`MutableSharedFlow`) | Emit `ChatEventDto` events the controller will receive | +| `rpc.statuses` (`MutableStateFlow>`) | Seed the status map read during `recoverPending()` | +| `rpc.history` | Messages returned by `messages()` (history load) | +| `rpc.pendingPermissionList` | Permissions returned during recovery | +| `rpc.pendingQuestionList` | Questions returned during recovery | +| `rpc.prompts`, `rpc.permissionReplies`, etc. | Call tracking for RPC side-effects | + +**String format of `model.toString()`** (used by `assertModel` / `assertSession`): + +``` +role#msgId +text#partId: + line one + line two +--- +tool#partId toolName [STATE] optional title +--- +question#id +tool: msgId/callId +header: … +prompt: … +option: label - description +multiple: false +custom: true +--- +diff: file1 file2 +--- +todo: [status] content +--- +compacted: N +``` + +Sections are separated by `---`. Only non-empty sections appear. The status line appended by `SessionController.toString()` is: + +``` +[agentName] [provider/modelId] [idle|busy|retry|offline|error|awaiting-question|awaiting-permission] [optional detail] +``` + ## UI Design Guidelines Official references: diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 379661bb358..497008ca7fd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -5,7 +5,12 @@ import ai.kilocode.backend.util.KiloLog import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -38,9 +43,20 @@ class KiloBackendChatManager( "message.removed", "message.part.updated", "message.part.delta", + "message.part.removed", "session.turn.open", "session.turn.close", "session.error", + "session.status", + "session.idle", + "session.compacted", + "session.diff", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "todo.updated", ) } @@ -174,8 +190,70 @@ class KiloBackendChatManager( } } + // ------ permission / question ------ + + fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) { + val body = KiloCliDataParser.buildPermissionReplyJson(reply) + post("/permission/$requestId/reply?directory=${encode(dir)}", body, "replyPermission") + } + + fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) { + val body = KiloCliDataParser.buildPermissionAlwaysRulesJson(rules) + post("/permission/$requestId/always-rules?directory=${encode(dir)}", body, "savePermissionRules") + } + + fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) { + val body = KiloCliDataParser.buildQuestionReplyJson(answers) + post("/question/$requestId/reply?directory=${encode(dir)}", body, "replyQuestion") + } + + fun rejectQuestion(requestId: String, dir: String) { + post("/question/$requestId/reject?directory=${encode(dir)}", "{}", "rejectQuestion") + } + + fun pendingPermissions(dir: String): List { + val raw = get("/permission?directory=${encode(dir)}", "pendingPermissions") ?: return emptyList() + return KiloCliDataParser.parsePermissionRequests(raw) + } + + fun pendingQuestions(dir: String): List { + val raw = get("/question?directory=${encode(dir)}", "pendingQuestions") ?: return emptyList() + return KiloCliDataParser.parseQuestionRequests(raw) + } + // ------ utilities ------ + private fun post(path: String, body: String, op: String) { + val http = requireClient() + val url = requireBase() + val request = Request.Builder() + .url("$url$path") + .post(body.toRequestBody(JSON_TYPE)) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("$op failed: HTTP ${response.code}") + } + } + } + + private fun get(path: String, op: String): String? { + val http = requireClient() + val url = requireBase() + val request = Request.Builder() + .url("$url$path") + .get() + .build() + return http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("$op failed: HTTP ${response.code}") + null + } else { + response.body?.string() + } + } + } + private fun requireClient(): OkHttpClient = client ?: throw IllegalStateException("Chat manager not started") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 5a1860e5dea..73057f894fd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -2,19 +2,30 @@ package ai.kilocode.backend.cli import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionSummaryDto import ai.kilocode.rpc.dto.SessionTimeDto +import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto +import ai.kilocode.rpc.dto.ToolRefDto import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull @@ -112,6 +123,88 @@ object KiloCliDataParser { ChatEventDto.Error(sid, err) } + "permission.asked" -> { + val sid = props.str("sessionID") ?: return null + val request = parsePermissionRequest(props) ?: return null + ChatEventDto.PermissionAsked(sid, request) + } + + "permission.replied" -> { + val sid = props.str("sessionID") ?: return null + val rid = props.str("requestID") ?: return null + ChatEventDto.PermissionReplied(sid, rid) + } + + "question.asked" -> { + val sid = props.str("sessionID") ?: return null + val request = parseQuestionRequest(props) ?: return null + ChatEventDto.QuestionAsked(sid, request) + } + + "question.replied" -> { + val sid = props.str("sessionID") ?: return null + val rid = props.str("requestID") ?: return null + ChatEventDto.QuestionReplied(sid, rid) + } + + "question.rejected" -> { + val sid = props.str("sessionID") ?: return null + val rid = props.str("requestID") ?: return null + ChatEventDto.QuestionRejected(sid, rid) + } + + "message.part.removed" -> { + val sid = props.str("sessionID") ?: return null + val mid = props.str("messageID") ?: return null + val pid = props.str("partID") ?: return null + ChatEventDto.PartRemoved(sid, mid, pid) + } + + "session.status" -> { + val sid = props.str("sessionID") ?: return null + val st = props["status"]?.jsonObject ?: return null + val dto = parseStatus(st) + ChatEventDto.SessionStatusChanged(sid, dto) + } + + "session.idle" -> { + val sid = props.str("sessionID") ?: return null + ChatEventDto.SessionIdle(sid) + } + + "session.compacted" -> { + val sid = props.str("sessionID") ?: return null + ChatEventDto.SessionCompacted(sid) + } + + "session.diff" -> { + val sid = props.str("sessionID") ?: return null + val diffs = props["diff"]?.jsonArray?.mapNotNull { elem -> + val d = elem.jsonObject + val file = d.str("file") ?: return@mapNotNull null + DiffFileDto( + file = file, + additions = d.long("additions")?.toInt() ?: 0, + deletions = d.long("deletions")?.toInt() ?: 0, + patch = d.str("patch"), + ) + } ?: emptyList() + ChatEventDto.SessionDiffChanged(sid, diffs) + } + + "todo.updated" -> { + val sid = props.str("sessionID") ?: return null + val todos = props["todos"]?.jsonArray?.map { elem -> + val t = elem.jsonObject + TodoDto( + content = t.str("content") ?: "", + status = t.str("status") ?: "pending", + priority = t.str("priority") ?: "medium", + ) + } ?: emptyList() + ChatEventDto.TodoUpdated(sid, todos) + } + else -> null } } @@ -119,15 +212,14 @@ object KiloCliDataParser { /** * Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair. * Returns null if the required fields are missing. - * - * Uses regex extraction (no full JSON parse) for consistency with - * the existing high-throughput status event handling. */ fun parseSessionStatus(data: String): Pair? { - val id = extractField(data, "sessionID") ?: return null - val type = extractNested(data, "status", "type") ?: "idle" - val msg = extractNested(data, "status", "message") - return id to SessionStatusDto(type, msg) + val obj = tryParseObject(data) ?: return null + val payload = obj["payload"]?.jsonObject ?: obj + val props = payload["properties"]?.jsonObject ?: obj + val id = props.str("sessionID") ?: return null + val st = props["status"]?.jsonObject ?: return id to SessionStatusDto("idle") + return id to parseStatus(st) } // ================================================================ @@ -255,6 +347,7 @@ object KiloCliDataParser { type = obj.str("type") ?: "unknown", text = obj.str("text"), tool = obj.str("tool"), + callID = obj.str("callID"), state = state?.str("status"), title = state?.str("title"), ) @@ -268,6 +361,40 @@ object KiloCliDataParser { return MessageErrorDto(type, msg) } + internal fun parsePermissionRequest(obj: JsonObject): PermissionRequestDto? { + val id = obj.str("id") ?: return null + val sid = obj.str("sessionID") ?: return null + val permission = obj.str("permission") ?: return null + val patterns = obj["patterns"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val always = obj["always"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val meta = obj["metadata"]?.jsonObject?.let { m -> + m.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") } + } ?: emptyMap() + val ref = toolRef(obj) + return PermissionRequestDto(id, sid, permission, patterns, meta, always, ref) + } + + internal fun parseQuestionRequest(obj: JsonObject): QuestionRequestDto? { + val id = obj.str("id") ?: return null + val sid = obj.str("sessionID") ?: return null + val questions = obj["questions"]?.jsonArray?.map { q -> + val qo = q.jsonObject + val options = qo["options"]?.jsonArray?.map { o -> + val oo = o.jsonObject + QuestionOptionDto(oo.str("label") ?: "", oo.str("description") ?: "") + } ?: emptyList() + QuestionInfoDto( + question = qo.str("question") ?: "", + header = qo.str("header") ?: "", + options = options, + multiple = qo.str("multiple") == "true", + custom = qo.str("custom") != "false", + ) + } ?: emptyList() + val ref = toolRef(obj) + return QuestionRequestDto(id, sid, questions, ref) + } + private fun parseSessionObject(obj: JsonObject): SessionDto { val time = obj["time"]?.jsonObject val summary = obj["summary"]?.jsonObject @@ -293,6 +420,91 @@ object KiloCliDataParser { ) } + // ================================================================ + // Internal — status parsing + // ================================================================ + + /** Safely parse an optional tool reference, handling `null` JSON values. */ + private fun toolRef(obj: JsonObject): ToolRefDto? { + val elem = obj["tool"] ?: return null + if (elem is JsonNull) return null + val t = elem.jsonObject + val mid = t.str("messageID") ?: return null + val cid = t.str("callID") ?: return null + return ToolRefDto(mid, cid) + } + + internal fun parseStatus(st: JsonObject): SessionStatusDto { + val type = st.str("type") ?: "idle" + return SessionStatusDto( + type = type, + message = st.str("message"), + attempt = st.long("attempt")?.toInt(), + next = st.long("next"), + requestID = st.str("requestID"), + ) + } + + // ================================================================ + // HTTP response parsing — lists + // ================================================================ + + /** + * Parse a list of pending permission requests (`GET /permission`). + */ + fun parsePermissionRequests(raw: String): List { + val arr = tryParseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + parsePermissionRequest(elem.jsonObject) + } + } + + /** + * Parse a list of pending question requests (`GET /question`). + */ + fun parseQuestionRequests(raw: String): List { + val arr = tryParseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + parseQuestionRequest(elem.jsonObject) + } + } + + // ================================================================ + // JSON serialization (DTO → JSON for outgoing permission/question requests) + // ================================================================ + + /** + * Build the JSON body for `POST /permission/{requestID}/reply`. + */ + fun buildPermissionReplyJson(reply: PermissionReplyDto): String { + val sb = StringBuilder() + sb.append("""{"reply":${escape(reply.reply)}""") + val msg = reply.message + if (msg != null) sb.append(""","message":${escape(msg)}""") + sb.append("}") + return sb.toString() + } + + /** + * Build the JSON body for `POST /permission/{requestID}/always-rules`. + */ + fun buildPermissionAlwaysRulesJson(rules: PermissionAlwaysRulesDto): String { + val approved = rules.approvedAlways.joinToString(",") { escape(it) } + val denied = rules.deniedAlways.joinToString(",") { escape(it) } + return """{"approvedAlways":[$approved],"deniedAlways":[$denied]}""" + } + + /** + * Build the JSON body for `POST /question/{requestID}/reply`. + */ + fun buildQuestionReplyJson(reply: QuestionReplyDto): String { + val answers = reply.answers.joinToString(",") { inner -> + val items = inner.joinToString(",") { escape(it) } + "[$items]" + } + return """{"answers":[$answers]}""" + } + // ================================================================ // Internal — regex-based field extraction (for status events) // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index c0c7c5c823d..a32477c53c2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -10,7 +10,12 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -88,14 +93,53 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.MessageUpdated -> event.sessionID is ChatEventDto.PartUpdated -> event.sessionID is ChatEventDto.PartDelta -> event.sessionID + is ChatEventDto.PartRemoved -> event.sessionID is ChatEventDto.TurnOpen -> event.sessionID is ChatEventDto.TurnClose -> event.sessionID is ChatEventDto.Error -> event.sessionID is ChatEventDto.MessageRemoved -> event.sessionID + is ChatEventDto.PermissionAsked -> event.sessionID + is ChatEventDto.PermissionReplied -> event.sessionID + is ChatEventDto.QuestionAsked -> event.sessionID + is ChatEventDto.QuestionReplied -> event.sessionID + is ChatEventDto.QuestionRejected -> event.sessionID + is ChatEventDto.SessionStatusChanged -> event.sessionID + is ChatEventDto.SessionIdle -> event.sessionID + is ChatEventDto.SessionCompacted -> event.sessionID + is ChatEventDto.SessionDiffChanged -> event.sessionID + is ChatEventDto.TodoUpdated -> event.sessionID } - sid == id + sid == null || sid == id } override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = chat.updateConfig(directory, config) + + // ------ permission / question resolution ------ + + override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { + LOG.info("replyPermission: requestId=$requestId, reply=${reply.reply}") + chat.replyPermission(requestId, directory, reply) + } + + override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) { + LOG.info("savePermissionRules: requestId=$requestId") + chat.savePermissionRules(requestId, directory, rules) + } + + override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) { + LOG.info("replyQuestion: requestId=$requestId, answers=${answers.answers.size}") + chat.replyQuestion(requestId, directory, answers) + } + + override suspend fun rejectQuestion(requestId: String, directory: String) { + LOG.info("rejectQuestion: requestId=$requestId") + chat.rejectQuestion(requestId, directory) + } + + override suspend fun pendingPermissions(directory: String): List = + chat.pendingPermissions(directory) + + override suspend fun pendingQuestions(directory: String): List = + chat.pendingQuestions(directory) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt new file mode 100644 index 00000000000..5ba748e1aa0 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt @@ -0,0 +1,201 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Round-trip serialization tests for [ChatEventDto]. + * + * Verifies that the @SerialName values match the real CLI event type strings + * and that polymorphic serialization/deserialization works end-to-end. + */ +class ChatDtoSerializationTest { + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + } + + // ================================================================ + // Serial name round-trips — confirms @SerialName matches CLI types + // ================================================================ + + @Test + fun `MessageUpdated serializes with correct type discriminator`() { + val event: ChatEventDto = ChatEventDto.MessageUpdated( + sessionID = "ses_1", + info = msg("msg_1"), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.updated""""), "Expected type=message.updated in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) + assertTrue(decoded is ChatEventDto.MessageUpdated) + assertEquals("ses_1", (decoded as ChatEventDto.MessageUpdated).sessionID) + } + + @Test + fun `PartUpdated serializes with type message dot part dot updated`() { + val event: ChatEventDto = ChatEventDto.PartUpdated( + sessionID = "ses_1", + part = part("p1"), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.part.updated""""), "Expected type=message.part.updated in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) + assertTrue(decoded is ChatEventDto.PartUpdated) + } + + @Test + fun `PartDelta serializes with type message dot part dot delta`() { + val event: ChatEventDto = ChatEventDto.PartDelta("ses_1", "msg_1", "part_1", "text", "hello") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.part.delta""""), "Expected type=message.part.delta in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.PartDelta + assertEquals("hello", decoded.delta) + } + + @Test + fun `PartRemoved serializes with type message dot part dot removed`() { + val event: ChatEventDto = ChatEventDto.PartRemoved("ses_1", "msg_1", "part_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.part.removed""""), "Expected type=message.part.removed in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.PartRemoved + assertEquals("part_1", decoded.partID) + } + + @Test + fun `TurnOpen serializes with type session dot turn dot open`() { + val event: ChatEventDto = ChatEventDto.TurnOpen("ses_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.turn.open""""), "Expected type=session.turn.open in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) + assertTrue(decoded is ChatEventDto.TurnOpen) + } + + @Test + fun `TurnClose serializes with type session dot turn dot close`() { + val event: ChatEventDto = ChatEventDto.TurnClose("ses_1", "completed") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.turn.close""""), "Expected type=session.turn.close in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.TurnClose + assertEquals("completed", decoded.reason) + } + + @Test + fun `Error serializes with type session dot error`() { + val event: ChatEventDto = ChatEventDto.Error("ses_1", MessageErrorDto("timeout", "Timed out")) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.error""""), "Expected type=session.error in: $encoded") + } + + @Test + fun `SessionStatusChanged serializes with full retry detail`() { + val event: ChatEventDto = ChatEventDto.SessionStatusChanged( + sessionID = "ses_1", + status = SessionStatusDto("retry", "Retrying", attempt = 3, next = 5000L), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.status""""), "Expected type=session.status in: $encoded") + assertTrue(encoded.contains(""""attempt":3"""), "Expected attempt in: $encoded") + assertTrue(encoded.contains(""""next":5000"""), "Expected next in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.SessionStatusChanged + assertEquals(3, decoded.status.attempt) + assertEquals(5000L, decoded.status.next) + } + + @Test + fun `SessionStatusChanged serializes with offline requestID`() { + val event: ChatEventDto = ChatEventDto.SessionStatusChanged( + sessionID = "ses_1", + status = SessionStatusDto("offline", "No connection", requestID = "req_abc"), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""requestID":"req_abc""""), "Expected requestID in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.SessionStatusChanged + assertEquals("req_abc", decoded.status.requestID) + } + + @Test + fun `PermissionAsked serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.PermissionAsked( + sessionID = "ses_1", + request = PermissionRequestDto("p1", "ses_1", "edit", listOf("*.kt")), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"permission.asked"""")) + } + + @Test + fun `QuestionAsked serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.QuestionAsked( + sessionID = "ses_1", + request = QuestionRequestDto("q1", "ses_1", listOf(QuestionInfoDto("pick", "h"))), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"question.asked"""")) + } + + @Test + fun `TodoUpdated serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.TodoUpdated("ses_1", emptyList()) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"todo.updated"""")) + } + + @Test + fun `SessionIdle serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.SessionIdle("ses_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.idle"""")) + } + + @Test + fun `SessionCompacted serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.SessionCompacted("ses_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.compacted"""")) + } + + @Test + fun `PartDto callID is preserved in round-trip`() { + val part = PartDto( + id = "p1", sessionID = "s1", messageID = "m1", + type = "tool", tool = "bash", callID = "call_abc", + ) + val encoded = json.encodeToString(PartDto.serializer(), part) + assertTrue(encoded.contains(""""callID":"call_abc"""")) + + val decoded = json.decodeFromString(PartDto.serializer(), encoded) + assertEquals("call_abc", decoded.callID) + } + + // ------ helpers ------ + + private fun msg(id: String) = MessageDto( + id = id, sessionID = "ses_1", role = "assistant", + time = MessageTimeDto(created = 0.0), + ) + + private fun part(id: String) = PartDto( + id = id, sessionID = "ses_1", messageID = "msg_1", type = "text", + ) +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 91f525ba1c5..40fb0a47f3c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -3,8 +3,11 @@ package ai.kilocode.backend.cli import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.QuestionReplyDto import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -465,6 +468,380 @@ class KiloCliDataParserTest { assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) } + // ================================================================ + // parseChatEvent — permission / question events + // ================================================================ + + @Test + fun `parseChatEvent - permission asked`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_1", + "sessionID": "ses_1", + "permission": "edit", + "patterns": ["*.kt"], + "always": [], + "metadata": {"file": "src/A.kt"}, + "tool": {"messageID": "msg_1", "callID": "call_1"} + } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.request.id) + assertEquals("edit", result.request.permission) + assertEquals(listOf("*.kt"), result.request.patterns) + assertEquals("src/A.kt", result.request.metadata["file"]) + assertEquals("msg_1", result.request.tool?.messageID) + } + + @Test + fun `parseChatEvent - permission replied`() { + val data = globalEvent(""" + "type": "permission.replied", + "properties": { "sessionID": "ses_1", "requestID": "perm_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionReplied) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.requestID) + } + + @Test + fun `parseChatEvent - question asked`() { + val data = globalEvent(""" + "type": "question.asked", + "properties": { + "id": "q_1", + "sessionID": "ses_1", + "questions": [{"question": "Pick one", "header": "Choice", "options": [{"label": "A", "description": "Option A"}]}], + "tool": null + } + """) + + val result = KiloCliDataParser.parseChatEvent("question.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("q_1", result.request.id) + assertEquals(1, result.request.questions.size) + assertEquals("Pick one", result.request.questions[0].question) + assertEquals("A", result.request.questions[0].options[0].label) + } + + @Test + fun `parseChatEvent - question replied`() { + val data = globalEvent(""" + "type": "question.replied", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("question.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionReplied) + assertEquals("q_1", result.requestID) + } + + @Test + fun `parseChatEvent - question rejected`() { + val data = globalEvent(""" + "type": "question.rejected", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("question.rejected", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionRejected) + assertEquals("q_1", result.requestID) + } + + // ================================================================ + // parseChatEvent — session.status with retry/offline detail + // ================================================================ + + @Test + fun `parseChatEvent - session status idle`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { "sessionID": "ses_1", "status": {"type": "idle"} } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("idle", result.status.type) + assertNull(result.status.attempt) + assertNull(result.status.requestID) + } + + @Test + fun `parseChatEvent - session status retry with attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2, "next": 5000} + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("retry", result.status.type) + assertEquals("Retrying...", result.status.message) + assertEquals(2, result.status.attempt) + assertEquals(5000L, result.status.next) + } + + @Test + fun `parseChatEvent - session status offline with requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "offline", "message": "No network", "requestID": "req_abc"} + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("offline", result.status.type) + assertEquals("No network", result.status.message) + assertEquals("req_abc", result.status.requestID) + } + + // ================================================================ + // parseChatEvent — message.part.removed + // ================================================================ + + @Test + fun `parseChatEvent - message part removed`() { + val data = globalEvent(""" + "type": "message.part.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1", "partID": "part_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + } + + // ================================================================ + // parseChatEvent — todo.updated + // ================================================================ + + @Test + fun `parseChatEvent - todo updated`() { + val data = globalEvent(""" + "type": "todo.updated", + "properties": { + "sessionID": "ses_1", + "todos": [ + {"content": "Write tests", "status": "in_progress", "priority": "high"}, + {"content": "Review PR", "status": "pending", "priority": "medium"} + ] + } + """) + + val result = KiloCliDataParser.parseChatEvent("todo.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TodoUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals(2, result.todos.size) + assertEquals("Write tests", result.todos[0].content) + assertEquals("high", result.todos[0].priority) + } + + // ================================================================ + // parseChatEvent — session.idle / session.compacted / session.diff + // ================================================================ + + @Test + fun `parseChatEvent - session idle`() { + val data = globalEvent(""" + "type": "session.idle", + "properties": { "sessionID": "ses_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.idle", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionIdle) + assertEquals("ses_1", result.sessionID) + } + + @Test + fun `parseChatEvent - session compacted`() { + val data = globalEvent(""" + "type": "session.compacted", + "properties": { "sessionID": "ses_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.compacted", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionCompacted) + } + + @Test + fun `parseChatEvent - session diff`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}] + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.diff", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionDiffChanged) + assertEquals(1, result.diff.size) + assertEquals("src/A.kt", result.diff[0].file) + assertEquals(3, result.diff[0].additions) + } + + // ================================================================ + // parseChatEvent — part with callID + // ================================================================ + + @Test + fun `parseChatEvent - part updated with callID`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "bash", + "callID": "call_abc", + "state": { "status": "running" } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + assertEquals("call_abc", result.part.callID) + assertEquals("bash", result.part.tool) + } + + // ================================================================ + // parseSessionStatus — full detail + // ================================================================ + + @Test + fun `parseSessionStatus - retry preserves attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_retry", + "status": {"type": "retry", "message": "Rate limited", "attempt": 3, "next": 10000} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_retry", result.first) + assertEquals("retry", result.second.type) + assertEquals(3, result.second.attempt) + assertEquals(10000L, result.second.next) + } + + @Test + fun `parseSessionStatus - offline preserves requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_off", + "status": {"type": "offline", "message": "Offline", "requestID": "req_xyz"} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("req_xyz", result.second.requestID) + } + + // ================================================================ + // buildPermissionReplyJson + // ================================================================ + + @Test + fun `buildPermissionReplyJson - once reply`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once")) + assertEquals("""{"reply":"once"}""", result) + } + + @Test + fun `buildPermissionReplyJson - always reply with message`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "always", message = "approved")) + assertTrue(result.contains(""""reply":"always"""")) + assertTrue(result.contains(""""message":"approved"""")) + } + + // ================================================================ + // buildPermissionAlwaysRulesJson + // ================================================================ + + @Test + fun `buildPermissionAlwaysRulesJson - approved list`() { + val result = KiloCliDataParser.buildPermissionAlwaysRulesJson( + PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"), deniedAlways = emptyList()) + ) + assertTrue(result.contains(""""approvedAlways":["src/**"]""")) + assertTrue(result.contains(""""deniedAlways":[]""")) + } + + // ================================================================ + // buildQuestionReplyJson + // ================================================================ + + @Test + fun `buildQuestionReplyJson - single question single answer`() { + val result = KiloCliDataParser.buildQuestionReplyJson(QuestionReplyDto(answers = listOf(listOf("A")))) + assertEquals("""{"answers":[["A"]]}""", result) + } + + @Test + fun `buildQuestionReplyJson - multiple questions`() { + val result = KiloCliDataParser.buildQuestionReplyJson( + QuestionReplyDto(answers = listOf(listOf("A", "B"), listOf("Yes"))) + ) + assertEquals("""{"answers":[["A","B"],["Yes"]]}""", result) + } + + // ================================================================ + // parsePermissionRequests / parseQuestionRequests + // ================================================================ + + @Test + fun `parsePermissionRequests - parses list`() { + val raw = """[ + {"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}} + ]""" + val result = KiloCliDataParser.parsePermissionRequests(raw) + assertEquals(1, result.size) + assertEquals("p1", result[0].id) + assertEquals("edit", result[0].permission) + } + + @Test + fun `parsePermissionRequests - empty list`() { + assertEquals(emptyList(), KiloCliDataParser.parsePermissionRequests("[]")) + } + + @Test + fun `parseQuestionRequests - parses list`() { + val raw = """[ + {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} + ]""" + val result = KiloCliDataParser.parseQuestionRequests(raw) + assertEquals(1, result.size) + assertEquals("q1", result[0].id) + } + // ================================================================ // Helpers // ================================================================ diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index d2408e8549e..735d87eff95 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -19,6 +19,11 @@ dependencies { implementation(project(":shared")) + implementation(libs.commonmark) + implementation(libs.commonmark.autolink) + implementation(libs.commonmark.tables) + implementation(libs.commonmark.strikethrough) + testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index f6bbf13166e..060588265c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -62,10 +62,10 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { cs: CoroutineScope, ) { try { - val chat = SessionUi(project, workspace, sessions, app, cs) + val ui = SessionUi(project, workspace, sessions, app, cs) val content = ContentFactory.getInstance() - .createContent(chat, "", false) - content.setDisposer(chat) + .createContent(ui, "", false) + content.setDisposer(ui) toolWindow.contentManager.addContent(content) ActionManager.getInstance().getAction("Kilo.Settings")?.let { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 45c81c2e2c9..50e80b05afe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -6,8 +6,13 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.Service @@ -28,7 +33,7 @@ import kotlinx.coroutines.launch * Project-level frontend service for session management. * * Stateless with respect to "active session" — callers pass explicit - * session IDs. [ai.kilocode.client.session.model.SessionModel] owns the + * session IDs. [ai.kilocode.client.session.SessionController] owns the * active session concept. */ @Service(Service.Level.PROJECT) @@ -143,4 +148,34 @@ class KiloSessionService internal constructor( suspend fun updateConfig(dir: String, config: ConfigUpdateDto) { call { updateConfig(dir, config) } } + + // ------ permission / question resolution ------ + + /** Reply to a pending permission request. */ + suspend fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) { + call { replyPermission(requestId, dir, reply) } + } + + /** Save always-rules for a pending permission request. */ + suspend fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) { + call { savePermissionRules(requestId, dir, rules) } + } + + /** Reply to a pending question with user answers. */ + suspend fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) { + call { replyQuestion(requestId, dir, answers) } + } + + /** Reject a pending question. */ + suspend fun rejectQuestion(requestId: String, dir: String) { + call { rejectQuestion(requestId, dir) } + } + + /** List pending permissions (caller filters by session ID). */ + suspend fun pendingPermissions(dir: String): List = + call { pendingPermissions(dir) } + + /** List pending questions (caller filters by session ID). */ + suspend fun pendingQuestions(dir: String): List = + call { pendingQuestions(dir) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt new file mode 100644 index 00000000000..e1aa9cd827d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -0,0 +1,574 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.AgentItem +import ai.kilocode.client.session.model.ModelItem +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.PermissionRequestState +import ai.kilocode.client.session.model.Question +import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.model.ToolCallRef +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Session lifecycle orchestrator for a single session. + * + * Accepts an optional [id] — if non-null, loads that session immediately. + * If null, lazily creates a session on the first [prompt] call. This ensures + * event subscription happens before the prompt is sent, eliminating races. + * + * Owns [SessionModel] — the single source of truth for session content and + * state. UIs observe model changes via [SessionModelEvent] on [model]. + * Lifecycle events (app/workspace state, view switching) are published + * via [SessionControllerEvent] to registered listeners. + */ +class SessionController( + parent: Disposable, + id: String?, + private val sessions: KiloSessionService, + private val workspace: Workspace, + private val app: KiloAppService, + private val cs: CoroutineScope, +) : Disposable { + + companion object { + private val LOG = Logger.getInstance(SessionController::class.java) + } + + init { + Disposer.register(parent, this) + } + + val model = SessionModel() + + private val listeners = mutableListOf() + private var sessionId: String? = id + private val directory: String get() = workspace.directory + + private var partType: String? = null + private var tool: String? = null + private var eventJob: Job? = null + + val ready: Boolean get() = model.isReady() + + fun addListener(parent: Disposable, listener: SessionControllerListener) { + listeners.add(listener) + Disposer.register(parent) { listeners.remove(listener) } + } + + fun prompt(text: String) { + showMessages() + cs.launch { + try { + val id = sessionId ?: run { + val session = sessions.create(directory) + sessionId = session.id + subscribeEvents() + session.id + } + sessions.prompt(id, directory, text) + } catch (e: Exception) { + LOG.warn("prompt failed", e) + edt { + val msg = e.message ?: KiloBundle.message("session.error.prompt") + model.setState(SessionState.Error(msg)) + } + } + } + } + + fun abort() { + val id = sessionId ?: return + cs.launch { + try { + sessions.abort(id, directory) + } catch (e: Exception) { + LOG.warn("abort failed", e) + } + } + } + + fun selectAgent(name: String) { + model.agent = name + cs.launch { + try { + sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) + } catch (e: Exception) { + LOG.warn("selectAgent failed", e) + } + } + fire(SessionControllerEvent.WorkspaceReady) + } + + fun selectModel(provider: String, id: String) { + model.model = "$provider/$id" + cs.launch { + try { + sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) + } catch (e: Exception) { + LOG.warn("selectModel failed", e) + } + } + fire(SessionControllerEvent.WorkspaceReady) + } + + // ------ permission / question resolution ------ + + fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { + cs.launch { + try { + if (rules != null) sessions.savePermissionRules(requestId, directory, rules) + sessions.replyPermission(requestId, directory, reply) + } catch (e: Exception) { + LOG.warn("replyPermission failed", e) + } + } + } + + fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + cs.launch { + try { + sessions.replyQuestion(requestId, directory, answers) + } catch (e: Exception) { + LOG.warn("replyQuestion failed", e) + } + } + } + + fun rejectQuestion(requestId: String) { + cs.launch { + try { + sessions.rejectQuestion(requestId, directory) + } catch (e: Exception) { + LOG.warn("rejectQuestion failed", e) + } + } + } + + init { + if (sessionId != null) { + loadHistory() + subscribeEvents() + } + + app.connect() + cs.launch { + app.state.collect { state -> + if (state.status == KiloAppStatusDto.READY) app.fetchVersionAsync() + fire(SessionControllerEvent.AppChanged) { + model.app = state + model.version = app.version + } + } + } + + cs.launch { + workspace.state.collect { state -> + fire(SessionControllerEvent.WorkspaceChanged) { + model.workspace = state + + if (state.status != KiloWorkspaceStatusDto.READY) return@fire + + model.agents = state.agents?.agents?.map { + AgentItem(it.name, it.displayName ?: it.name) + } ?: emptyList() + + model.models = state.providers?.let { providers -> + providers.providers + .filter { it.id in providers.connected } + .flatMap { provider -> + provider.models.map { (id, info) -> + ModelItem(id, info.name, provider.id) + } + } + } ?: emptyList() + + if (this@SessionController.model.agent == null) this@SessionController.model.agent = state.agents?.default + if (this@SessionController.model.model == null) { + this@SessionController.model.model = state.providers?.defaults?.entries?.firstOrNull()?.let { "${it.key}/${it.value}" } + } + } + + if (state.status == KiloWorkspaceStatusDto.READY) { + fire(SessionControllerEvent.WorkspaceReady) + } + } + } + } + + private fun loadHistory() { + val id = sessionId ?: return + cs.launch { + try { + val history = sessions.messages(id, directory) + edt { + this@SessionController.model.loadHistory(history) + if (!model.isEmpty()) showMessages() + } + recoverPending(id) + } catch (e: Exception) { + LOG.warn("loadHistory failed", e) + } + } + } + + private fun subscribeEvents() { + val id = sessionId ?: return + eventJob?.cancel() + eventJob = cs.launch { + sessions.events(id, directory).collect { event -> + // Defensive session ID guard: skip events for other sessions + if (!matchesSession(event, id)) return@collect + edt { handle(event) } + } + } + } + + /** Rehydrate pending permissions/questions and current session status after history load. */ + private suspend fun recoverPending(id: String) { + try { + val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } + val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } + val status = sessions.statuses.value[id] + edt { + if (permissions.isNotEmpty()) { + model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) + } else if (questions.isNotEmpty()) { + model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) + } else if (status != null) { + seedStatus(status) + } + } + } catch (e: Exception) { + LOG.warn("recoverPending failed", e) + } + } + + /** + * Seed initial session state from a snapshot status value. + * + * Used only during recovery — does not apply the live-event clobbering guard + * for "busy" because no more-specific state has arrived yet. + */ + private fun seedStatus(dto: SessionStatusDto) { + val state = when (dto.type) { + "busy" -> SessionState.Busy(KiloBundle.message("session.status.considering")) + "retry" -> SessionState.Retry( + message = dto.message ?: "", + attempt = dto.attempt ?: 0, + next = dto.next ?: 0L, + ) + "offline" -> SessionState.Offline( + message = dto.message ?: "", + requestId = dto.requestID ?: "", + ) + else -> return // idle or unknown — leave as Idle + } + model.setState(state) + } + + private fun handle(event: ChatEventDto) { + when (event) { + is ChatEventDto.MessageUpdated -> { + val added = model.upsertMessage(event.info) + if (added) showMessages() + } + + is ChatEventDto.PartUpdated -> { + partType = event.part.type + tool = event.part.tool + model.updateContent(event.part.messageID, event.part) + if (model.state is SessionState.Busy) { + model.setState(SessionState.Busy(status())) + } + } + + is ChatEventDto.PartDelta -> { + if (event.field == "text") { + model.appendDelta(event.messageID, event.partID, event.delta) + } + } + + is ChatEventDto.PartRemoved -> { + model.removeContent(event.messageID, event.partID) + } + + is ChatEventDto.TurnOpen -> { + partType = null + tool = null + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + + is ChatEventDto.TurnClose -> { + partType = null + tool = null + // "completed" always transitions to idle. + // Other reasons: don't clobber a more specific terminal state (Error, + // AwaitingPermission, AwaitingQuestion) that arrived just before close. + val current = model.state + val clobberOk = event.reason == "completed" + || current is SessionState.Busy + || current is SessionState.Retry + || current is SessionState.Offline + if (clobberOk) model.setState(SessionState.Idle) + } + + is ChatEventDto.Error -> { + partType = null + tool = null + val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") + model.setState(SessionState.Error(msg, event.error?.type)) + } + + is ChatEventDto.MessageRemoved -> { + model.removeMessage(event.messageID) + } + + is ChatEventDto.PermissionAsked -> { + model.setState(SessionState.AwaitingPermission(toPermission(event.request))) + } + + is ChatEventDto.PermissionReplied -> { + val current = model.state + if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + } + + is ChatEventDto.QuestionAsked -> { + model.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) + } + + is ChatEventDto.QuestionReplied -> { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + } + + is ChatEventDto.QuestionRejected -> { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { + model.setState(SessionState.Idle) + } + } + + is ChatEventDto.SessionStatusChanged -> { + val state = when (event.status.type) { + "idle" -> SessionState.Idle + "busy" -> { + val current = model.state + if (current is SessionState.Idle || current is SessionState.Error) + SessionState.Busy(KiloBundle.message("session.status.considering")) + else return // already in a more specific phase + } + "retry" -> SessionState.Retry( + message = event.status.message ?: "", + attempt = event.status.attempt ?: 0, + next = event.status.next ?: 0L, + ) + "offline" -> SessionState.Offline( + message = event.status.message ?: "", + requestId = event.status.requestID ?: "", + ) + else -> return + } + model.setState(state) + } + + is ChatEventDto.SessionIdle -> { + // Treat session.idle as an explicit signal to return to Idle. + // Only apply if we're not in a more specific non-terminal state. + val current = model.state + if (current !is SessionState.Error + && current !is SessionState.AwaitingPermission + && current !is SessionState.AwaitingQuestion + ) { + model.setState(SessionState.Idle) + } + } + + is ChatEventDto.SessionCompacted -> model.markCompacted() + is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff) + is ChatEventDto.TodoUpdated -> model.setTodos(event.todos) + } + } + + private fun showMessages() { + if (!model.showMessages) { + model.showMessages = true + fire(SessionControllerEvent.ViewChanged(true)) + } + } + + private fun status(): String = when (partType) { + "reasoning" -> KiloBundle.message("session.status.thinking") + "text" -> KiloBundle.message("session.status.writing") + "tool" -> when (tool) { + "task" -> KiloBundle.message("session.status.delegating") + "todowrite", "todoread" -> KiloBundle.message("session.status.planning") + "read" -> KiloBundle.message("session.status.gathering") + "glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase") + "webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web") + "edit", "write" -> KiloBundle.message("session.status.editing") + "bash" -> KiloBundle.message("session.status.commands") + else -> KiloBundle.message("session.status.considering") + } + else -> KiloBundle.message("session.status.considering") + } + + private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) { + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + before?.invoke() + for (l in listeners) l.onEvent(event) + return + } + application.invokeLater { + before?.invoke() + for (l in listeners) l.onEvent(event) + } + } + + private fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeLater(block) + } + + override fun dispose() { + eventJob?.cancel() + cs.cancel() + } + + override fun toString(): String { + val out = mutableListOf() + val body = model.toString().trim() + if (body.isNotEmpty()) out.add(body) + if (out.isNotEmpty()) out.add("") + out.add(statusLine()) + return out.joinToString("\n") + } + + private fun statusLine(): String { + val out = mutableListOf() + model.agent?.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + model.model?.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + + if (!ready) { + out.add("[app: ${model.app.status}]") + out.add("[workspace: ${model.workspace.status}]") + return out.joinToString(" ") + } + + when (val state = model.state) { + is SessionState.Idle -> out.add("[idle]") + is SessionState.Busy -> { + out.add("[busy]") + out.add("[${state.text.toDumpText()}]") + } + is SessionState.AwaitingQuestion -> out.add("[awaiting-question]") + is SessionState.AwaitingPermission -> out.add("[awaiting-permission]") + is SessionState.Retry -> { + out.add("[retry]") + state.message.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + } + is SessionState.Offline -> { + out.add("[offline]") + state.message.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + } + is SessionState.Error -> { + out.add("[error]") + out.add("[${state.message}]") + } + } + + return out.joinToString(" ") + } +} + +/** Returns true when [event]'s sessionID matches [id] (or event has no sessionID, like Error). */ +private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (event) { + is ChatEventDto.MessageUpdated -> event.sessionID == id + is ChatEventDto.PartUpdated -> event.sessionID == id + is ChatEventDto.PartDelta -> event.sessionID == id + is ChatEventDto.PartRemoved -> event.sessionID == id + is ChatEventDto.TurnOpen -> event.sessionID == id + is ChatEventDto.TurnClose -> event.sessionID == id + is ChatEventDto.Error -> event.sessionID == null || event.sessionID == id + is ChatEventDto.MessageRemoved -> event.sessionID == id + is ChatEventDto.PermissionAsked -> event.sessionID == id + is ChatEventDto.PermissionReplied -> event.sessionID == id + is ChatEventDto.QuestionAsked -> event.sessionID == id + is ChatEventDto.QuestionReplied -> event.sessionID == id + is ChatEventDto.QuestionRejected -> event.sessionID == id + is ChatEventDto.SessionStatusChanged -> event.sessionID == id + is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionCompacted -> event.sessionID == id + is ChatEventDto.SessionDiffChanged -> event.sessionID == id + is ChatEventDto.TodoUpdated -> event.sessionID == id +} + +private fun toPermission(dto: PermissionRequestDto): Permission { + val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } + val file = dto.metadata["file"] ?: dto.metadata["path"] + val state = dto.metadata["state"]?.let { raw -> + PermissionRequestState.values().firstOrNull { item -> item.name.equals(raw, ignoreCase = true) } + } ?: PermissionRequestState.PENDING + return Permission( + id = dto.id, + sessionId = dto.sessionID, + name = dto.permission, + patterns = dto.patterns, + always = dto.always, + meta = PermissionMeta(filePath = file, raw = dto.metadata), + tool = ref, + state = state, + ) +} + +private fun toQuestion(dto: QuestionRequestDto): Question { + val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } + val items = dto.questions.map { + QuestionItem( + question = it.question, + header = it.header, + options = it.options.map { opt -> QuestionOption(opt.label, opt.description) }, + multiple = it.multiple, + custom = it.custom, + ) + } + return Question(id = dto.id, items = items, tool = ref) +} + +private fun String.toDumpText(): String { + val text = lowercase() + .replace("\u2026", "") + .replace("…", "") + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + return text +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt new file mode 100644 index 00000000000..6026aed5e28 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt @@ -0,0 +1,32 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent + +/** + * Lifecycle events fired by [SessionController] on the EDT. + * + * These cover app/workspace state changes and view switching — things + * outside the [SessionModel] domain. For model mutations (messages, + * parts, state), listen to [SessionModelEvent] on [SessionModel] directly. + */ +sealed class SessionControllerEvent { + + // App + workspace lifecycle (every state transition) + data object AppChanged : SessionControllerEvent() + data object WorkspaceChanged : SessionControllerEvent() + + // Workspace ready (pickers populated) + data object WorkspaceReady : SessionControllerEvent() + data class ViewChanged(val show: Boolean) : SessionControllerEvent() { + override fun toString() = if (show) "ViewChanged show" else "ViewChanged hide" + } +} + +/** + * Listener for [SessionControllerEvent]s fired by [SessionController]. + * All callbacks are guaranteed to run on the EDT. + */ +fun interface SessionControllerListener { + fun onEvent(event: SessionControllerEvent) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 2a9aec5e9e3..9393b5fb2af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,10 +3,10 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.model.SessionEvent -import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.LabelPicker -import ai.kilocode.client.session.ui.MessageListPanel +import ai.kilocode.client.session.ui.MessageListUi import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.StatusPanel import com.intellij.openapi.Disposable @@ -18,18 +18,7 @@ import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.JPanel -/** - * Main chat panel — reacts to [SessionModel] events. - * - * Uses [CardLayout] in the center to switch between the empty panel - * (shown before the first prompt) and the scrollable message list. - * - * All business logic (app/workspace watching, session lifecycle, event - * handling, status computation) lives in [SessionModel]. Welcome - * rendering lives in [ai.kilocode.client.session.ui.StatusPanel]. This class handles layout, prompt - * wiring, message list updates, card switching, picker population, - * busy state, and scrolling. - */ +/** Main session panel — reacts to [SessionController] events. */ class SessionUi( project: Project, workspace: Workspace, @@ -43,9 +32,9 @@ class SessionUi( private const val MESSAGES = "messages" } - private val model = SessionModel(this, null, sessions, workspace, app, cs) - private val status = StatusPanel(this, model) - private val messages = MessageListPanel() + private val controller = SessionController(this, null, sessions, workspace, app, cs) + private val status = StatusPanel(this, controller) + private val messages = MessageListUi(this, controller.model) private val cards = CardLayout() private val center = JPanel(cards) @@ -57,13 +46,12 @@ class SessionUi( } private val prompt = PromptPanel( - project = project, - onSend = { text -> send(text) }, - onAbort = { model.abort() }, + project = project, + onSend = { text -> send(text) }, + onAbort = { controller.abort() }, ) init { - // Layout center.add(status, STATUS) center.add(scroll, MESSAGES) cards.show(center, STATUS) @@ -71,120 +59,66 @@ class SessionUi( add(center, BorderLayout.CENTER) add(prompt, BorderLayout.SOUTH) - // Wire picker callbacks via typed model methods - prompt.mode.onSelect = { item -> - model.selectAgent(item.id) - } - prompt.model.onSelect = { item -> + prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } + prompt.model.onSelect = picker@{ item -> val group = item.group - if (group != null) { - model.selectModel(group, item.id) + if (group == null) return@picker + controller.selectModel(group, item.id) + } + + // Lifecycle events from the manager (app/workspace state, view switching) + controller.addListener(this) { event -> + when (event) { + is SessionControllerEvent.WorkspaceReady -> { + val c = controller.model + prompt.mode.setItems(c.agents.map { LabelPicker.Item(it.name, it.display) }, c.agent) + val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } + val selected = c.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } + prompt.model.setItems(items, selected) + prompt.setReady(c.isReady()) + } + + is SessionControllerEvent.ViewChanged -> cards.show(center, if (event.show) MESSAGES else STATUS) + + is SessionControllerEvent.AppChanged, + is SessionControllerEvent.WorkspaceChanged -> prompt.setReady(controller.model.isReady()) } } - // React to model events — no coroutines, pure EDT - model.addListener(this) { event -> + // Model events — state drives the prompt busy state + controller.model.addListener(this) { event -> when (event) { - is SessionEvent.MessageAdded -> { - val msg = model.chat.message(event.id) ?: return@addListener - messages.addMessage(msg.info) - refreshMessages() - } - - is SessionEvent.MessageRemoved -> { - messages.removeMessage(event.id) - refreshMessages() - } - - is SessionEvent.PartUpdated -> { - val part = model.chat.part(event.messageId, event.partId) ?: return@addListener - messages.updatePartText(event.messageId, event.partId, part.text.toString()) - refreshMessages() - } - - is SessionEvent.PartDelta -> { - messages.appendDelta(event.messageId, event.partId, event.delta) - refreshMessages() - } - - is SessionEvent.StatusChanged -> { - messages.setStatus(event.text) - refreshMessages() - } - - is SessionEvent.Error -> { - messages.addError(event.message) - refreshMessages() - } - - is SessionEvent.HistoryLoaded -> { - messages.clear() - for (msg in model.chat.messages()) { - messages.addMessage(msg.info) - for ((partId, part) in msg.parts) { - if (part.dto.type == "text" && part.text.isNotEmpty()) { - messages.updatePartText(msg.info.id, partId, part.text.toString()) - } - } - } - refreshMessages() - } - - is SessionEvent.Cleared -> { - messages.clear() - refreshMessages() - } - - is SessionEvent.WorkspaceReady -> { - val c = model.chat - prompt.mode.setItems( - c.agents.map { LabelPicker.Item(it.name, it.display) }, - c.agent, - ) - val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } - // chat.model is "provider/modelId", picker items use modelId only. - // Find the matching item and pass its id for selection. - val selected = c.model?.let { full -> - items.firstOrNull { "${it.group}/${it.id}" == full }?.id - } - prompt.model.setItems(items, selected) - prompt.setReady(c.ready) - } - - is SessionEvent.ViewChanged -> { - cards.show(center, if (event.show) MESSAGES else STATUS) - } - - is SessionEvent.BusyChanged -> { - prompt.setBusy(event.busy) - } - - is SessionEvent.AppChanged, - is SessionEvent.WorkspaceChanged -> { - // Handled by StatusPanel + is SessionModelEvent.StateChanged -> { + val busy = event.state.isBusy() + prompt.setBusy(busy) + scrollToBottom() } + is SessionModelEvent.MessageAdded, + is SessionModelEvent.ContentAdded, + is SessionModelEvent.ContentUpdated, + is SessionModelEvent.ContentDelta -> scrollToBottom() + is SessionModelEvent.HistoryLoaded -> scrollToBottom() + else -> {} } } } private fun send(text: String) { if (text.isBlank()) return - model.prompt(text) + controller.prompt(text) prompt.clear() } - private fun refreshMessages() { - messages.revalidate() - messages.repaint() - scrollToBottom() - } - private fun scrollToBottom() { val bar = scroll.verticalScrollBar bar.value = bar.maximum } - override fun dispose() { - // All children (status, model) disposed by Disposer - } + override fun dispose() {} +} + +private fun SessionState.isBusy(): Boolean = when (this) { + is SessionState.Idle -> false + is SessionState.Error -> false + else -> true } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt new file mode 100644 index 00000000000..02daa0ecfc3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -0,0 +1,45 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.MessageDto + +/** A single message with its typed contents. */ +class Message( + val info: MessageDto, +) { + val parts = LinkedHashMap() +} + +/** Typed content within a message. */ +sealed class Content(val id: String) + +/** Streamed text content from the assistant. */ +class Text(id: String) : Content(id) { + val content = StringBuilder() +} + +/** Model reasoning / chain-of-thought. */ +class Reasoning(id: String) : Content(id) { + val content = StringBuilder() +} + +/** Tool invocation with lifecycle state. */ +class Tool(id: String, val name: String) : Content(id) { + var state: ToolExecState = ToolExecState.PENDING + var title: String? = null +} + +/** Context compaction marker. */ +class Compaction(id: String) : Content(id) + +/** + * Generic fallback for part types not yet given a dedicated class. + * Preserves the [type] string so unknown content is not silently dropped. + */ +class Generic(id: String, val type: String) : Content(id) + +enum class ToolExecState { PENDING, RUNNING, COMPLETED, ERROR } + +data class ToolCallRef( + val messageId: String, + val callId: String, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt new file mode 100644 index 00000000000..d6034eee7ca --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.session.model + +enum class PermissionRequestState { PENDING, RESPONDING, RESOLVED, ERROR } + +enum class PermissionReply { ONCE, ALWAYS, REJECT } + +data class Permission( + val id: String, + val sessionId: String, + val name: String, + val patterns: List, + val always: List, + val meta: PermissionMeta, + val message: String? = null, + val tool: ToolCallRef? = null, + val state: PermissionRequestState = PermissionRequestState.PENDING, +) + +data class PermissionMeta( + val rules: List = emptyList(), + val diff: String? = null, + val filePath: String? = null, + val fileDiff: PermissionFileDiff? = null, + val raw: Map = emptyMap(), +) + +data class PermissionFileDiff( + val file: String, + val patch: String? = null, + val before: String? = null, + val after: String? = null, + val additions: Int, + val deletions: Int, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt new file mode 100644 index 00000000000..70ea656fdbc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.session.model + +enum class QuestionRequestState { PENDING, RESPONDING, ERROR } + +data class Question( + val id: String, + val items: List, + val tool: ToolCallRef? = null, + val state: QuestionRequestState = QuestionRequestState.PENDING, +) + +data class QuestionItem( + val question: String, + val header: String, + val options: List, + val multiple: Boolean, + val custom: Boolean, +) + +data class QuestionOption( + val label: String, + val description: String, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt deleted file mode 100644 index f45903f0555..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt +++ /dev/null @@ -1,45 +0,0 @@ -package ai.kilocode.client.session.model - -/** - * Change events fired by [SessionModel] on the EDT. - * - * Events carry IDs so the UI knows **which** message/part changed. - * The UI can read full data from [SessionState] directly (safe — same - * EDT thread). [PartDelta] also carries the delta string so the - * view can append efficiently without reading the whole text. - */ -sealed class SessionEvent { - - // Message lifecycle - data class MessageAdded(val id: String) : SessionEvent() - data class MessageRemoved(val id: String) : SessionEvent() - - // Part changes - data class PartUpdated(val messageId: String, val partId: String) : SessionEvent() - data class PartDelta(val messageId: String, val partId: String, val delta: String) : SessionEvent() - - // Session state - data class StatusChanged(val text: String?) : SessionEvent() - data class BusyChanged(val busy: Boolean) : SessionEvent() - data class Error(val message: String) : SessionEvent() - - // Bulk operations - data object HistoryLoaded : SessionEvent() - data object Cleared : SessionEvent() - - // App + workspace lifecycle (every state transition) - data object AppChanged : SessionEvent() - data object WorkspaceChanged : SessionEvent() - - // Workspace ready (pickers populated) - data object WorkspaceReady : SessionEvent() - data class ViewChanged(val show: Boolean) : SessionEvent() -} - -/** - * Listener for [SessionEvent]s fired by [SessionModel]. - * All callbacks are guaranteed to run on the EDT. - */ -fun interface SessionModelListener { - fun onEvent(event: SessionEvent) -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 74c78e1f2e7..6d90a918382 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -1,350 +1,346 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.app.Workspace -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.ChatEventDto -import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.Disposer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch /** - * Session lifecycle controller for a single session. + * Pure session model — single source of truth for session content and runtime state. * - * Accepts an optional [id] — if non-null, loads that session immediately. - * If null, lazily creates a session on the first [prompt] call. This - * ensures event subscription happens *before* the prompt is sent, - * eliminating race conditions. - * - * Owns [SessionState] and the listener list. All model mutations and - * listener notifications happen on the EDT — [fire] auto-dispatches - * via `invokeLater` when called from a background thread. + * **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all + * reads and writes happen on the EDT. */ -class SessionModel( - parent: Disposable, - id: String?, - private val sessions: KiloSessionService, - private val workspace: Workspace, - private val app: KiloAppService, - private val cs: CoroutineScope, -) : Disposable { +class SessionModel { - companion object { - private val LOG = Logger.getInstance(SessionModel::class.java) - } + private val entries = LinkedHashMap() - init { - Disposer.register(parent, this) - } + var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + var version: String? = null - val chat = SessionState() + var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + var agents: List = emptyList() + var models: List = emptyList() + var agent: String? = null + var model: String? = null + var showMessages: Boolean = false - private val listeners = mutableListOf() + var state: SessionState = SessionState.Idle + private set - /** The session ID owned by this model. Null until created or passed in. */ - private var sessionId: String? = id + var diff: List = emptyList() + private set - /** Resolved project directory for RPC calls. */ - private val directory: String get() = workspace.directory + var todos: List = emptyList() + private set - // Status computation state (EDT-only) - private var partType: String? = null - private var tool: String? = null - private var busy: Boolean = false + var compactionCount: Int = 0 + private set - // Coroutine job for the current event subscription - private var eventJob: Job? = null + private val listeners = mutableListOf() - // --- Listener management (EDT) --- - - /** - * Register a listener whose lifetime is tied to [parent]. - * When [parent] is disposed the listener is auto-removed. - */ - fun addListener(parent: Disposable, listener: SessionModelListener) { + fun addListener(parent: Disposable, listener: SessionModelEvent.Listener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } } - // --- Actions (called from EDT) --- + fun messages(): Collection = entries.values + + fun message(id: String): Message? = entries[id] + + fun content(messageId: String, contentId: String): Content? = entries[messageId]?.parts?.get(contentId) + + fun isEmpty(): Boolean = entries.isEmpty() + + fun isReady(): Boolean = app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY /** - * Send a prompt. If no session exists, creates one first, - * subscribes to events, then sends the prompt — all in one - * coroutine to avoid race conditions. + * Add a message if it doesn't exist, or update its [MessageDto] info if it does. + * Returns true when the message was newly added (caller can decide to show messages). */ - fun prompt(text: String) { - showMessages() - cs.launch { - try { - val id = sessionId ?: run { - val session = sessions.create(directory) - sessionId = session.id - subscribeEvents() - session.id - } - sessions.prompt(id, directory, text) - } catch (e: Exception) { - LOG.warn("prompt failed", e) - edt { - fire(SessionEvent.Error(e.message ?: KiloBundle.message("session.error.prompt"))) - fire(SessionEvent.BusyChanged(false)) - } - } + fun upsertMessage(dto: MessageDto): Boolean { + val existing = entries[dto.id] + if (existing != null) { + val updated = Message(dto).also { it.parts.putAll(existing.parts) } + entries[dto.id] = updated + fire(SessionModelEvent.MessageUpdated(updated)) + return false } + val msg = Message(dto) + entries[dto.id] = msg + fire(SessionModelEvent.MessageAdded(msg)) + return true } - fun abort() { - val id = sessionId ?: return - cs.launch { - try { - sessions.abort(id, directory) - } catch (e: Exception) { - LOG.warn("abort failed", e) - } - } + /** @deprecated Use [upsertMessage] instead. Kept for incremental migration. */ + fun addMessage(dto: MessageDto): Message? { + if (entries.containsKey(dto.id)) return null + val msg = Message(dto) + entries[dto.id] = msg + fire(SessionModelEvent.MessageAdded(msg)) + return msg } - fun selectAgent(name: String) { - chat.agent = name - cs.launch { - try { - sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) - } catch (e: Exception) { - LOG.warn("selectAgent failed", e) - } - } - fire(SessionEvent.WorkspaceReady) + fun removeMessage(id: String) { + if (entries.remove(id) == null) return + fire(SessionModelEvent.MessageRemoved(id)) } - fun selectModel(provider: String, id: String) { - chat.model = "$provider/$id" - cs.launch { - try { - sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) - } catch (e: Exception) { - LOG.warn("selectModel failed", e) - } - } - fire(SessionEvent.WorkspaceReady) + fun removeContent(messageId: String, contentId: String) { + val msg = entries[messageId] ?: return + if (msg.parts.remove(contentId) == null) return + fire(SessionModelEvent.ContentRemoved(messageId, contentId)) } - // --- Internal: coroutine → EDT bridge --- - - init { - // If we have a session ID, load it immediately - if (sessionId != null) { - loadHistory() - subscribeEvents() - } - - // Watch session statuses for busy/idle - cs.launch { - sessions.statuses.collect { statuses -> - val id = sessionId ?: return@collect - val st = statuses[id] - edt { fire(SessionEvent.BusyChanged(st?.type == "busy")) } - } - } - - // Watch app lifecycle state - app.connect() - cs.launch { - app.state.collect { state -> - if (state.status == KiloAppStatusDto.READY) app.fetchVersionAsync() - edt { - chat.app = state - chat.version = app.version - fire(SessionEvent.AppChanged) - } - } - } - - // Watch workspace state for providers/agents and lifecycle - cs.launch { - workspace.state.collect { state -> - edt { - chat.workspace = state - fire(SessionEvent.WorkspaceChanged) - - if (state.status == KiloWorkspaceStatusDto.READY) { - chat.agents = state.agents?.agents?.map { - AgentItem(it.name, it.displayName ?: it.name) - } ?: emptyList() - - chat.models = state.providers?.let { providers -> - providers.providers - .filter { it.id in providers.connected } - .flatMap { provider -> - provider.models.map { (id, info) -> - ModelItem(id, info.name, provider.id) - } - } - } ?: emptyList() - - if (chat.agent == null) { - chat.agent = state.agents?.default - } - if (chat.model == null) { - chat.model = state.providers?.defaults?.entries?.firstOrNull()?.value - } - - chat.ready = true - fire(SessionEvent.WorkspaceReady) - } - } - } + fun updateContent(messageId: String, dto: PartDto) { + val msg = entries[messageId] ?: return + val existing = msg.parts[dto.id] + if (existing != null) { + updateExisting(messageId, existing, dto) + return } + val content = fromDto(dto) + msg.parts[dto.id] = content + fire(SessionModelEvent.ContentAdded(messageId, content)) } - private fun loadHistory() { - val id = sessionId ?: return - cs.launch { - try { - val history = sessions.messages(id, directory) - edt { - chat.load(history) - if (!chat.isEmpty()) showMessages() - fire(SessionEvent.HistoryLoaded) - } - } catch (e: Exception) { - LOG.warn("loadHistory failed", e) + fun appendDelta(messageId: String, contentId: String, delta: String) { + val msg = entries[messageId] ?: return + val existing = msg.parts[contentId] + if (existing != null) { + val buf = when (existing) { + is Text -> existing.content + is Reasoning -> existing.content + else -> return } - } - } - - private fun subscribeEvents() { - val id = sessionId ?: return - eventJob?.cancel() - eventJob = cs.launch { - sessions.events(id, directory).collect { event -> - edt { handle(event) } - } - } - } - - private fun handle(event: ChatEventDto) { - when (event) { - is ChatEventDto.MessageUpdated -> { - chat.addMessage(event.info) - showMessages() - fire(SessionEvent.MessageAdded(event.info.id)) - } - - is ChatEventDto.PartUpdated -> { - partType = event.part.type - tool = event.part.tool - chat.updatePart(event.part.messageID, event.part) - if (busy) { - fire(SessionEvent.StatusChanged(status())) - } - if (event.part.type == "text" && event.part.text != null) { - fire(SessionEvent.PartUpdated(event.part.messageID, event.part.id)) - } - } - - is ChatEventDto.PartDelta -> { - if (event.field == "text") { - chat.appendDelta(event.messageID, event.partID, event.delta) - fire(SessionEvent.PartDelta(event.messageID, event.partID, event.delta)) - } - } - - is ChatEventDto.TurnOpen -> { - partType = null - tool = null - busy = true - fire(SessionEvent.StatusChanged(KiloBundle.message("session.status.considering"))) - fire(SessionEvent.BusyChanged(true)) - } - - is ChatEventDto.TurnClose -> { - partType = null - tool = null - busy = false - fire(SessionEvent.StatusChanged(null)) - fire(SessionEvent.BusyChanged(false)) - } - - is ChatEventDto.Error -> { - val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - busy = false - fire(SessionEvent.Error(msg)) - fire(SessionEvent.StatusChanged(null)) - fire(SessionEvent.BusyChanged(false)) - } - - is ChatEventDto.MessageRemoved -> { - chat.removeMessage(event.messageID) - fire(SessionEvent.MessageRemoved(event.messageID)) - } - } - } - - // --- View switching (EDT) --- - - private fun showMessages() { - if (!chat.showMessages) { - chat.showMessages = true - fire(SessionEvent.ViewChanged(true)) - } - } - - private fun hideMessages() { - if (chat.showMessages) { - chat.showMessages = false - fire(SessionEvent.ViewChanged(false)) - } - } - - /** - * Compute a human-readable status from the last streaming part. - */ - private fun status(): String = when (partType) { - "reasoning" -> KiloBundle.message("session.status.thinking") - "text" -> KiloBundle.message("session.status.writing") - "tool" -> when (tool) { - "task" -> KiloBundle.message("session.status.delegating") - "todowrite", "todoread" -> KiloBundle.message("session.status.planning") - "read" -> KiloBundle.message("session.status.gathering") - "glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase") - "webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web") - "edit", "write" -> KiloBundle.message("session.status.editing") - "bash" -> KiloBundle.message("session.status.commands") - else -> KiloBundle.message("session.status.considering") - } - else -> KiloBundle.message("session.status.considering") - } - - /** - * Notify all listeners. If called from the EDT, listeners run - * immediately. If called from a background thread, the notification - * is dispatched via `invokeLater`. - */ - private fun fire(event: SessionEvent) { - val application = ApplicationManager.getApplication() - if (application.isDispatchThread) { - for (l in listeners) l.onEvent(event) + buf.append(delta) } else { - application.invokeLater { for (l in listeners) l.onEvent(event) } + val content = Text(contentId) + content.content.append(delta) + msg.parts[contentId] = content + fire(SessionModelEvent.ContentAdded(messageId, content)) + } + fire(SessionModelEvent.ContentDelta(messageId, contentId, delta)) + } + + fun setState(state: SessionState) { + this.state = state + fire(SessionModelEvent.StateChanged(state)) + } + + fun setDiff(diff: List) { + this.diff = diff + fire(SessionModelEvent.DiffUpdated(diff)) + } + + fun setTodos(todos: List) { + this.todos = todos + fire(SessionModelEvent.TodosUpdated(todos)) + } + + fun markCompacted() { + compactionCount++ + fire(SessionModelEvent.Compacted(compactionCount)) + } + + fun loadHistory(history: List) { + entries.clear() + state = SessionState.Idle + diff = emptyList() + todos = emptyList() + compactionCount = 0 + for (msg in history) { + val item = Message(msg.info) + for (part in msg.parts) { + val content = fromDto(part, part.text) + item.parts[content.id] = content + } + entries[msg.info.id] = item + } + fire(SessionModelEvent.HistoryLoaded) + } + + fun clear() { + entries.clear() + state = SessionState.Idle + diff = emptyList() + todos = emptyList() + compactionCount = 0 + fire(SessionModelEvent.Cleared) + } + + private fun updateExisting(messageId: String, existing: Content, dto: PartDto) { + when (existing) { + is Text -> { + val text = dto.text ?: return + existing.content.clear() + existing.content.append(text) + } + is Reasoning -> { + val text = dto.text ?: return + existing.content.clear() + existing.content.append(text) + } + is Tool -> { + existing.state = parseToolState(dto.state) + existing.title = dto.title + } + is Compaction -> return + is Generic -> return + } + fire(SessionModelEvent.ContentUpdated(messageId, existing)) + } + + private fun fromDto(dto: PartDto, text: CharSequence? = null): Content { + val content = text ?: dto.text + return when (dto.type) { + "text" -> Text(dto.id).apply { + if (content != null && content.isNotEmpty()) this.content.append(content) + } + "reasoning" -> Reasoning(dto.id).apply { + if (content != null && content.isNotEmpty()) this.content.append(content) + } + "tool" -> Tool(dto.id, dto.tool ?: "unknown").apply { + state = parseToolState(dto.state) + title = dto.title + } + "compaction" -> Compaction(dto.id) + else -> Generic(dto.id, dto.type) } } - private fun edt(block: () -> Unit) { - ApplicationManager.getApplication().invokeLater(block) + private fun fire(event: SessionModelEvent) { + for (l in listeners) l.onEvent(event) } - override fun dispose() { - eventJob?.cancel() - cs.cancel() + override fun toString(): String { + val out = mutableListOf() + + for (msg in messages()) { + if (out.isNotEmpty()) out.add("---") + out.addAll(renderMessage(msg)) + } + + when (val state = this.state) { + is SessionState.AwaitingQuestion -> { + if (out.isNotEmpty()) out.add("---") + out.addAll(renderQuestion(state.question)) + } + is SessionState.AwaitingPermission -> { + if (out.isNotEmpty()) out.add("---") + out.addAll(renderPermission(state.permission)) + } + else -> {} + } + + if (diff.isNotEmpty()) { + if (out.isNotEmpty()) out.add("---") + out.add("diff: ${diff.joinToString(" ") { it.file }}") + } + if (todos.isNotEmpty()) { + if (out.isNotEmpty()) out.add("---") + todos.forEach { out.add("todo: [${it.status}] ${it.content}") } + } + if (compactionCount > 0) { + if (out.isNotEmpty()) out.add("---") + out.add("compacted: $compactionCount") + } + + return out.joinToString("\n") } } + +private fun parseToolState(raw: String?): ToolExecState = when (raw) { + "pending" -> ToolExecState.PENDING + "running" -> ToolExecState.RUNNING + "completed" -> ToolExecState.COMPLETED + "error" -> ToolExecState.ERROR + else -> ToolExecState.PENDING +} + +data class AgentItem(val name: String, val display: String) + +data class ModelItem(val id: String, val display: String, val provider: String) + +private fun renderMessage(msg: Message): List { + val out = mutableListOf() + out.add("${msg.info.role}#${msg.info.id}") + for (part in msg.parts.values) { + when (part) { + is Text -> { + out.add("text#${part.id}:") + out.addAll(renderText(part.content)) + } + is Reasoning -> { + out.add("reasoning#${part.id}:") + out.addAll(renderText(part.content)) + } + is Tool -> out.add(renderTool(part)) + is Compaction -> out.add("compaction#${part.id}") + is Generic -> out.add("${part.type}#${part.id}") + } + } + return out +} + +private fun renderQuestion(question: Question): List { + val out = mutableListOf() + out.add("question#${question.id}") + out.add("tool: ${renderToolRef(question.tool)}") + for (item in question.items) { + out.add("header: ${item.header}") + out.add("prompt: ${item.question}") + for (opt in item.options) { + out.add("option: ${opt.label} - ${opt.description}") + } + out.add("multiple: ${item.multiple}") + out.add("custom: ${item.custom}") + } + return out +} + +private fun renderPermission(permission: Permission): List { + val out = mutableListOf() + out.add("permission#${permission.id}") + out.add("tool: ${renderToolRef(permission.tool)}") + out.add("name: ${permission.name}") + out.add("patterns: ${permission.patterns.joinToString(", ").ifEmpty { "" }}") + out.add("always: ${permission.always.joinToString(", ").ifEmpty { "" }}") + out.add("file: ${renderFile(permission.meta)}") + out.add("state: ${permission.state.name}") + val meta = permission.meta.raw.entries + .filter { it.key !in setOf("file", "path", "state") } + .sortedBy { it.key } + .joinToString(", ") { "${it.key}=${it.value}" } + .ifEmpty { "" } + out.add("metadata: $meta") + return out +} + +private fun renderToolRef(ref: ToolCallRef?): String = ref?.let { "${it.messageId}/${it.callId}" } ?: "" + +private fun renderFile(meta: PermissionMeta): String { + meta.filePath?.takeIf { it.isNotBlank() }?.let { return it } + meta.raw["file"]?.takeIf { it.isNotBlank() }?.let { return it } + meta.raw["path"]?.takeIf { it.isNotBlank() }?.let { return it } + return "" +} + +private fun renderTool(tool: Tool): String { + val state = tool.state.name + val title = tool.title?.takeIf { it.isNotBlank() }?.let { " $it" } ?: "" + return "tool#${tool.id} ${tool.name} [$state]$title" +} + +private fun renderText(text: CharSequence): List { + val raw = text.toString() + if (raw.isEmpty()) return listOf(" ") + return raw.split("\n").map { " $it" } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt new file mode 100644 index 00000000000..6ddb5be4f26 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -0,0 +1,52 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.TodoDto + +/** + * Change events fired by [SessionModel]. + * + * Events carry the data needed for rendering so UI can update without + * reading back from the model except for [HistoryLoaded]. + */ +sealed class SessionModelEvent { + data class MessageAdded(val info: Message) : SessionModelEvent() { + override fun toString() = "MessageAdded ${info.info.id}" + } + data class MessageUpdated(val info: Message) : SessionModelEvent() { + override fun toString() = "MessageUpdated ${info.info.id}" + } + data class MessageRemoved(val id: String) : SessionModelEvent() { + override fun toString() = "MessageRemoved $id" + } + data class ContentAdded(val messageId: String, val content: Content) : SessionModelEvent() { + override fun toString() = "ContentAdded $messageId/${content.id}" + } + data class ContentUpdated(val messageId: String, val content: Content) : SessionModelEvent() { + override fun toString() = "ContentUpdated $messageId/${content.id}" + } + data class ContentRemoved(val messageId: String, val contentId: String) : SessionModelEvent() { + override fun toString() = "ContentRemoved $messageId/$contentId" + } + data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() { + override fun toString() = "ContentDelta $messageId/$contentId" + } + data class StateChanged(val state: SessionState) : SessionModelEvent() { + override fun toString() = "StateChanged ${state::class.simpleName}" + } + data class DiffUpdated(val diff: List) : SessionModelEvent() { + override fun toString() = "DiffUpdated files=${diff.size}" + } + data class TodosUpdated(val todos: List) : SessionModelEvent() { + override fun toString() = "TodosUpdated count=${todos.size}" + } + data class Compacted(val count: Int) : SessionModelEvent() { + override fun toString() = "Compacted count=$count" + } + data object HistoryLoaded : SessionModelEvent() + data object Cleared : SessionModelEvent() + + fun interface Listener { + fun onEvent(event: SessionModelEvent) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 69effbcb9dc..6226dde8a55 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -1,121 +1,18 @@ package ai.kilocode.client.session.model -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.MessageDto -import ai.kilocode.rpc.dto.MessageWithPartsDto -import ai.kilocode.rpc.dto.PartDto +/** Single source of truth for what a session is doing right now. */ +sealed class SessionState { + data object Idle : SessionState() -/** - * Pure data holder for the active session's messages, parts, and - * workspace state (agents, models, selection). - * - * **EDT-only access** — no synchronization. [SessionModel] guarantees - * all reads and writes happen on the EDT. - */ -class SessionState { + data class Busy(val text: String) : SessionState() - private val messages = LinkedHashMap() + data class AwaitingQuestion(val question: Question) : SessionState() - // --- App lifecycle state (set by SessionModel, read by EmptyChatUi) --- + data class AwaitingPermission(val permission: Permission) : SessionState() - var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) - var version: String? = null + data class Retry(val message: String, val attempt: Int, val next: Long) : SessionState() - // --- Workspace state (set by SessionModel, read by UI) --- + data class Offline(val message: String, val requestId: String) : SessionState() - var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) - var agents: List = emptyList() - var models: List = emptyList() - var agent: String? = null - var model: String? = null - var ready: Boolean = false - var showMessages: Boolean = false - - // --- Read --- - - fun message(id: String): MessageData? = messages[id] - - fun messages(): Collection = messages.values - - fun part(messageId: String, partId: String): PartData? = - messages[messageId]?.parts?.get(partId) - - fun isEmpty(): Boolean = messages.isEmpty() - - // --- Write (called by SessionModel on EDT) --- - - /** - * Add a message. Returns false if the message already exists. - */ - fun addMessage(info: MessageDto): Boolean { - if (messages.containsKey(info.id)) return false - messages[info.id] = MessageData(info, LinkedHashMap()) - return true - } - - /** - * Remove a message by ID. Returns false if not found. - */ - fun removeMessage(id: String): Boolean = - messages.remove(id) != null - - /** - * Create or replace a part entry and set its text from [PartDto.text]. - */ - fun updatePart(messageId: String, part: PartDto) { - val msg = messages[messageId] ?: return - val text = StringBuilder(part.text ?: "") - msg.parts[part.id] = PartData(part, text) - } - - /** - * Append a text delta to an existing part. Creates the part if missing. - */ - fun appendDelta(messageId: String, partId: String, delta: String) { - val msg = messages[messageId] ?: return - val existing = msg.parts[partId] - if (existing != null) { - existing.text.append(delta) - } else { - msg.parts[partId] = PartData( - PartDto(id = partId, sessionID = "", messageID = messageId, type = "text"), - StringBuilder(delta), - ) - } - } - - /** - * Bulk-load message history from RPC DTOs. Clears existing data first. - */ - fun load(history: List) { - messages.clear() - for (msg in history) { - val parts = LinkedHashMap() - for (part in msg.parts) { - parts[part.id] = PartData(part, StringBuilder(part.text ?: "")) - } - messages[msg.info.id] = MessageData(msg.info, parts) - } - } - - fun clear() { - messages.clear() - } + data class Error(val message: String, val kind: String? = null) : SessionState() } - -data class MessageData( - val info: MessageDto, - val parts: LinkedHashMap, -) - -class PartData( - val dto: PartDto, - val text: StringBuilder, -) - -data class AgentItem(val name: String, val display: String) - -data class ModelItem(val id: String, val display: String, val provider: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt deleted file mode 100644 index 1f8cb192078..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt +++ /dev/null @@ -1,165 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.rpc.dto.MessageDto -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.JBColor -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout -import java.awt.FlowLayout -import javax.swing.BoxLayout -import javax.swing.JPanel -import javax.swing.JTextArea -import javax.swing.border.MatteBorder - -/** - * Scrollable panel displaying chat messages aligned to the top, - * with an optional animated status indicator at the bottom. - * - * Inner panel uses [BoxLayout.Y_AXIS] for stacking, wrapped in a - * [BorderLayout.NORTH] so messages stay top-aligned when the scroll - * viewport is taller than the content. - */ -class MessageListPanel : JPanel(BorderLayout()) { - - private val panels = LinkedHashMap() - - private val inner = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(4, 8) - } - - private val statusLabel = JBLabel().apply { - foreground = UIUtil.getContextHelpForeground() - } - - private val status = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { - isOpaque = false - isVisible = false - border = JBUI.Borders.empty(6, 0) - alignmentX = LEFT_ALIGNMENT - add(JBLabel(AnimatedIcon.Default())) - add(statusLabel) - } - - init { - isOpaque = true - background = UIUtil.getPanelBackground() - inner.add(status) - add(inner, BorderLayout.NORTH) - } - - fun addMessage(info: MessageDto) { - if (panels.containsKey(info.id)) return - val block = MessageBlock(info) - panels[info.id] = block - // Insert before the status row (which is always last) - inner.add(block, inner.componentCount - 1) - revalidate() - repaint() - } - - fun updatePartText(messageID: String, partID: String, text: String) { - panels[messageID]?.setText(partID, text) - revalidate() - repaint() - } - - fun appendDelta(messageID: String, partID: String, delta: String) { - panels[messageID]?.appendDelta(partID, delta) - revalidate() - repaint() - } - - fun removeMessage(messageID: String) { - val block = panels.remove(messageID) ?: return - inner.remove(block) - revalidate() - repaint() - } - - fun addError(msg: String) { - val label = JBLabel(msg).apply { - foreground = JBColor.RED - font = JBUI.Fonts.label() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - inner.add(label, inner.componentCount - 1) - revalidate() - repaint() - } - - /** - * Show or hide the working status indicator at the bottom of the list. - * Pass null to hide, a string to show with animated spinner. - */ - fun setStatus(text: String?) { - if (text != null) { - statusLabel.text = text - status.isVisible = true - } else { - status.isVisible = false - } - revalidate() - repaint() - } - - fun clear() { - panels.clear() - inner.removeAll() - // Re-add status row (always last) - inner.add(status) - status.isVisible = false - revalidate() - repaint() - } -} - -/** - * A single message block — text content only, no role header. - * User messages get a thin top border as separator. - */ -private class MessageBlock(info: MessageDto) : JPanel() { - private val parts = LinkedHashMap() - - init { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = LEFT_ALIGNMENT - - border = if (info.role == "user") { - JBUI.Borders.compound( - MatteBorder(1, 0, 0, 0, JBColor.border()), - JBUI.Borders.empty(8, 0, 4, 0), - ) - } else { - JBUI.Borders.empty(4, 0) - } - } - - fun setText(partID: String, text: String) { - val area = parts.getOrPut(partID) { createArea().also { add(it) } } - area.text = text - revalidate() - } - - fun appendDelta(partID: String, delta: String) { - val area = parts.getOrPut(partID) { createArea().also { add(it) } } - area.append(delta) - revalidate() - } - - private fun createArea() = JTextArea().apply { - isEditable = false - lineWrap = true - wrapStyleWord = true - isOpaque = false - font = JBUI.Fonts.label() - foreground = UIUtil.getLabelForeground() - border = JBUI.Borders.empty() - alignmentX = LEFT_ALIGNMENT - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt new file mode 100644 index 00000000000..f7a106ffbbc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -0,0 +1,279 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.model.Compaction +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Generic +import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import com.intellij.openapi.Disposable +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.FlowLayout +import javax.swing.BoxLayout +import javax.swing.JPanel +import javax.swing.JTextArea +import javax.swing.border.MatteBorder + +/** + * Scrollable panel displaying session messages aligned to the top, + * with an optional animated status indicator at the bottom. + * + * Passive view — all rendering is driven by [SessionModelEvent]s + * from the [SessionModel]. No public mutation methods. + */ +class MessageListUi( + parent: Disposable, + private val model: SessionModel, +) : JPanel(BorderLayout()) { + + private val blocks = LinkedHashMap() + private var errorLabel: JBLabel? = null + + private val inner = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + border = JBUI.Borders.empty(4, 8) + } + + private val label = JBLabel().apply { + foreground = UIUtil.getContextHelpForeground() + } + + private val spinner = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { + isOpaque = false + isVisible = false + border = JBUI.Borders.empty(6, 0) + alignmentX = LEFT_ALIGNMENT + add(JBLabel(AnimatedIcon.Default())) + add(label) + } + + init { + isOpaque = true + background = UIUtil.getPanelBackground() + inner.add(spinner) + add(inner, BorderLayout.NORTH) + + model.addListener(parent) { event -> + when (event) { + is SessionModelEvent.MessageAdded -> onAdded(event.info) + is SessionModelEvent.MessageUpdated -> onAdded(event.info) // refresh/upsert + is SessionModelEvent.MessageRemoved -> onRemoved(event.id) + is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content) + is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content) + is SessionModelEvent.ContentRemoved -> onContentRemoved(event.messageId, event.contentId) + is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta) + is SessionModelEvent.StateChanged -> onState(event.state) + is SessionModelEvent.HistoryLoaded -> onHistory() + is SessionModelEvent.Cleared -> onCleared() + is SessionModelEvent.DiffUpdated, + is SessionModelEvent.TodosUpdated, + is SessionModelEvent.Compacted -> Unit + } + } + } + + private fun onAdded(info: Message) { + if (blocks.containsKey(info.info.id)) return + val block = MessageBlock(info) + blocks[info.info.id] = block + inner.add(block, inner.componentCount - 1) + refresh() + } + + private fun onRemoved(id: String) { + val block = blocks.remove(id) ?: return + inner.remove(block) + refresh() + } + + private fun onContentAdded(messageId: String, content: Content) { + blocks[messageId]?.addContent(content) + refresh() + } + + private fun onContentUpdated(messageId: String, content: Content) { + blocks[messageId]?.updateContent(content) + refresh() + } + + private fun onContentRemoved(messageId: String, contentId: String) { + blocks[messageId]?.removeContent(contentId) + refresh() + } + + private fun onContentDelta(messageId: String, contentId: String, delta: String) { + blocks[messageId]?.appendDelta(contentId, delta) + refresh() + } + + private fun onState(state: SessionState) { + errorLabel?.let { inner.remove(it); errorLabel = null } + when (state) { + is SessionState.Busy -> { + label.text = state.text + spinner.isVisible = true + } + is SessionState.Error -> { + spinner.isVisible = false + val err = JBLabel(state.message).apply { + foreground = JBColor.RED + font = JBUI.Fonts.label() + border = JBUI.Borders.empty(4, 0) + alignmentX = LEFT_ALIGNMENT + } + errorLabel = err + inner.add(err, inner.componentCount - 1) + } + else -> { + spinner.isVisible = false + } + } + refresh() + } + + private fun onHistory() { + clear() + for (entry in model.messages()) { + val block = MessageBlock(entry) + blocks[entry.info.id] = block + inner.add(block, inner.componentCount - 1) + for ((_, content) in entry.parts) block.addContent(content) + } + refresh() + } + + private fun onCleared() { + clear() + refresh() + } + + private fun clear() { + blocks.clear() + errorLabel = null + inner.removeAll() + inner.add(spinner) + spinner.isVisible = false + } + + private fun refresh() { + revalidate() + repaint() + } +} + +private class MessageBlock(info: Message) : JPanel() { + private val areas = LinkedHashMap() + private val labels = LinkedHashMap() + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + alignmentX = LEFT_ALIGNMENT + + border = if (info.info.role == "user") { + JBUI.Borders.compound( + MatteBorder(1, 0, 0, 0, JBColor.border()), + JBUI.Borders.empty(8, 0, 4, 0), + ) + } else { + JBUI.Borders.empty(4, 0) + } + } + + fun addContent(content: Content) { + when (content) { + is Text -> { + val area = createArea() + if (content.content.isNotEmpty()) area.text = content.content.toString() + areas[content.id] = area + add(area) + } + is Reasoning -> { + val area = createArea().apply { + foreground = UIUtil.getContextHelpForeground() + } + if (content.content.isNotEmpty()) area.text = content.content.toString() + areas[content.id] = area + add(area) + } + is Tool -> { + val lbl = createToolLabel(content) + labels[content.id] = lbl + add(lbl) + } + is Compaction -> { + val lbl = JBLabel("Context compacted").apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + border = JBUI.Borders.empty(4, 0) + alignmentX = LEFT_ALIGNMENT + } + labels[content.id] = lbl + add(lbl) + } + is Generic -> {} // unknown part type — not rendered in this pass + } + revalidate() + } + + fun removeContent(contentId: String) { + areas.remove(contentId)?.let { remove(it) } + labels.remove(contentId)?.let { remove(it) } + revalidate() + } + + fun updateContent(content: Content) { + when (content) { + is Text -> areas[content.id]?.text = content.content.toString() + is Reasoning -> areas[content.id]?.text = content.content.toString() + is Tool -> labels[content.id]?.text = toolText(content) + is Compaction -> {} + is Generic -> {} + } + revalidate() + } + + fun appendDelta(contentId: String, delta: String) { + areas[contentId]?.append(delta) + revalidate() + } + + private fun createArea() = JTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + isOpaque = false + font = JBUI.Fonts.label() + foreground = UIUtil.getLabelForeground() + border = JBUI.Borders.empty() + alignmentX = LEFT_ALIGNMENT + } + + private fun createToolLabel(content: Tool) = JBLabel(toolText(content)).apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + border = JBUI.Borders.empty(2, 0) + alignmentX = LEFT_ALIGNMENT + } + + private fun toolText(content: Tool): String { + val icon = when (content.state) { + ToolExecState.PENDING -> "\u23F3" + ToolExecState.RUNNING -> "\u25B6" + ToolExecState.COMPLETED -> "\u2713" + ToolExecState.ERROR -> "\u2717" + } + return "$icon ${content.title ?: content.name}" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt index fc543dd03db..a27b5471129 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt @@ -1,9 +1,9 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.model.SessionEvent -import ai.kilocode.client.session.model.SessionModel -import ai.kilocode.client.session.model.SessionModelListener import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.SessionControllerEvent +import ai.kilocode.client.session.SessionControllerListener import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -29,8 +29,8 @@ import javax.swing.SwingConstants /** * Welcome panel showing app + workspace initialization progress. * - * Pure view — listens to [SessionModel] events and reads - * [SessionState][ai.kilocode.client.session.model.SessionState] for data. + * Pure view — listens to [SessionController] events and reads + * [SessionModel][ai.kilocode.client.session.model.SessionModel] for data. * No coroutines, no service references. * * Uses icon+label rows for each resource being loaded. Icons act as @@ -39,8 +39,8 @@ import javax.swing.SwingConstants */ class StatusPanel( parent: Disposable, - private val model: SessionModel, -) : JPanel(GridBagLayout()), SessionModelListener, Disposable { + private val controller: SessionController, +) : JPanel(GridBagLayout()), SessionControllerListener, Disposable { init { Disposer.register(parent, this) @@ -110,19 +110,19 @@ class StatusPanel( add(body, GridBagConstraints()) resetAll() - model.addListener(this, this) + controller.addListener(this, this) } - override fun onEvent(event: SessionEvent) { + override fun onEvent(event: SessionControllerEvent) { when (event) { - is SessionEvent.AppChanged -> { - renderApp(model.chat.app) + is SessionControllerEvent.AppChanged -> { + renderApp(controller.model.app) revalidate() repaint() } - is SessionEvent.WorkspaceChanged -> { - renderWorkspace(model.chat.workspace) + is SessionControllerEvent.WorkspaceChanged -> { + renderWorkspace(controller.model.workspace) revalidate() repaint() } @@ -187,7 +187,7 @@ class StatusPanel( } private fun renderWorkspace(state: KiloWorkspaceStateDto) { - val appReady = model.chat.app.status == KiloAppStatusDto.READY + val appReady = controller.model.app.status == KiloAppStatusDto.READY val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING wsSection.isVisible = visible if (!visible) return @@ -241,7 +241,7 @@ class StatusPanel( KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") KiloAppStatusDto.READY -> { - val ver = model.chat.version + val ver = controller.model.version if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver) else KiloBundle.message("toolwindow.status.connected") } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt new file mode 100644 index 00000000000..146075250e5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -0,0 +1,298 @@ +package ai.kilocode.client.ui.md + +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Font +import java.awt.Point +import javax.swing.JComponent +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +/** + * Markdown rendering component backed by [JBHtmlPane] with editor-aware styling. + * + * By default, font and colors are derived from the global editor colour scheme. + * All style properties are optional overrides on top of those defaults. + * Call [resetStyles] to revert to editor defaults after overriding. + * + * Create instances via [MdView.html]. All public methods must be called on the EDT. + */ +@Suppress("UnstableApiUsage") +abstract class MdView private constructor() { + + abstract val component: JComponent + abstract fun set(text: String) + abstract fun append(delta: String) + abstract fun clear() + /** Revert all style overrides to editor-derived defaults. */ + abstract fun resetStyles() + abstract fun addLinkListener(listener: LinkListener) + abstract fun removeLinkListener(listener: LinkListener) + + abstract var font: Font + abstract var foreground: Color + abstract var background: Color + abstract var linkColor: Color + abstract var codeBg: Color + abstract var preBg: Color + abstract var preFg: Color + abstract var codeFont: String + abstract var quoteBorder: Color + abstract var quoteFg: Color + abstract var tableBorder: Color + + /** + * When `false`, the component is transparent — the parent's background shows through + * and no background is forced in the CSS body rule. + */ + abstract var opaque: Boolean + + data class LinkEvent( + val href: String, + val point: Point? = null, + ) + + fun interface LinkListener { + fun onLink(event: LinkEvent) + } + + internal abstract fun markdown(): String + internal abstract fun html(): String + /** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */ + internal abstract fun overrideSheet(): String + internal abstract fun simulateLink(href: String) + + companion object { + fun html(): MdView = HtmlImpl() + } + + @Suppress("UnstableApiUsage") + private class HtmlImpl : MdView() { + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var rendered = "" + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder().extensions(extensions).build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) + .build() + + // nullable overrides — null means "use JBHtmlPane / editor default" + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val pane: JBHtmlPane = JBHtmlPane( + JBHtmlPaneStyleConfiguration { + // colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + // fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService + customStyleSheetProvider { buildOverrideStyleSheet() } + } + ).apply { + isEditable = false + isOpaque = true + background = UIUtil.getPanelBackground() + + addHyperlinkListener { e -> + if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + val event = LinkEvent(href, pt) + for (l in listeners) l.onLink(event) + } + } + } + + override val component: JComponent get() = pane + + // -- style properties (non-null API backed by nullable overrides) ---- + + override var font: Font + get() = fontOverride ?: JBUI.Fonts.label() + set(value) { fontOverride = value; markDirty() } + + override var foreground: Color + get() = foregroundOverride ?: UIUtil.getLabelForeground() + set(value) { foregroundOverride = value; markDirty() } + + override var background: Color + get() = backgroundOverride ?: pane.background + set(value) { + backgroundOverride = value + if (opaqueState) pane.background = value + markDirty() + } + + override var linkColor: Color + get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) + set(value) { linkColorOverride = value; markDirty() } + + override var codeBg: Color + get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) + set(value) { codeBgOverride = value; markDirty() } + + override var preBg: Color + get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) + set(value) { preBgOverride = value; markDirty() } + + override var preFg: Color + get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) + set(value) { preFgOverride = value; markDirty() } + + override var codeFont: String + // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font + get() = codeFontOverride ?: "_EditorFontNoLigatures_" + set(value) { codeFontOverride = value; markDirty() } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { quoteBorderOverride = value; markDirty() } + + override var quoteFg: Color + get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) + set(value) { quoteFgOverride = value; markDirty() } + + override var tableBorder: Color + get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { tableBorderOverride = value; markDirty() } + + override var opaque: Boolean + get() = opaqueState + set(value) { + opaqueState = value + pane.isOpaque = value + if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground() + markDirty() + } + + override fun resetStyles() { + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + pane.isOpaque = true + pane.background = UIUtil.getPanelBackground() + markDirty() + } + + // -- content API --------------------------------------------------- + + override fun set(text: String) { + source.clear() + source.append(text) + render() + } + + override fun append(delta: String) { + source.append(delta) + render() + } + + override fun clear() { + source.clear() + rendered = "" + pane.text = "" + } + + override fun addLinkListener(listener: LinkListener) { listeners.add(listener) } + override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) } + + override fun markdown(): String = source.toString() + override fun html(): String = rendered + override fun overrideSheet(): String = buildOverrideRulesString() + + override fun simulateLink(href: String) { + val event = LinkEvent(href) + for (l in listeners) l.onLink(event) + } + + private fun markDirty() { + pane.reloadCssStylesheets() + if (source.isNotEmpty()) render() + } + + private fun render() { + val body = renderer.render(parser.parse(source.toString())) + rendered = body + pane.text = "$body" + pane.caretPosition = 0 + } + + private fun buildOverrideStyleSheet(): StyleSheet { + val sheet = StyleSheet() + val rules = buildOverrideRulesString() + if (rules.isNotEmpty()) { + try { sheet.addRule(rules) } catch (_: Exception) {} + } + return sheet + } + + private fun buildOverrideRulesString(): String { + val rules = StringBuilder() + + val body = mutableListOf() + foregroundOverride?.let { body.add("color: ${hex(it)}") } + if (!opaqueState) body.add("background: transparent") + fontOverride?.let { + body.add("font-family: '${it.family}', sans-serif") + body.add("font-size: ${it.size}pt") + } + if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + + linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } + codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") } + preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } + preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } + codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } + quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } + quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") } + tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") } + + return rules.toString().trim() + } + + companion object { + private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt new file mode 100644 index 00000000000..157642dc922 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto + +class AppWatchingTest : SessionControllerTestBase() { + + fun `test app state change fires AppChanged`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flush() + + assertControllerEvents("AppChanged", events) + assertSession( + """ + [app: READY] [workspace: PENDING] + """, + m, + show = false, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt new file mode 100644 index 00000000000..eb53354c488 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt @@ -0,0 +1,54 @@ +package ai.kilocode.client.session + +class ConfigSelectionTest : SessionControllerTestBase() { + + fun `test selectModel updates SessionModel and calls updateConfig`() { + val m = controller() + collect(m) + flush() + + edt { m.selectModel("kilo", "gpt-5") } + flush() + + assertEquals(1, rpc.configs.size) + assertEquals("kilo/gpt-5", rpc.configs[0].second.model) + assertSession( + """ + [kilo/gpt-5] [app: DISCONNECTED] [workspace: PENDING] + """, + m, + show = false, + ) + } + + fun `test selectAgent updates SessionModel and calls updateConfig`() { + val m = controller() + collect(m) + flush() + + edt { m.selectAgent("plan") } + flush() + + assertEquals(1, rpc.configs.size) + assertEquals("plan", rpc.configs[0].second.agent) + assertSession( + """ + [plan] [app: DISCONNECTED] [workspace: PENDING] + """, + m, + show = false, + ) + } + + fun `test selectModel fires WorkspaceReady event`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.selectModel("kilo", "gpt-5") } + flush() + + assertControllerEvents("WorkspaceReady", events) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt new file mode 100644 index 00000000000..6a892a4a5f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt @@ -0,0 +1,43 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.MessageWithPartsDto + +class HistoryLoadingTest : SessionControllerTestBase() { + + fun `test existing session loads history on init`() { + val m = msg("msg1", "ses_test", "user") + val part = part("prt1", "ses_test", "msg1", "text", text = "hello") + rpc.history.add(MessageWithPartsDto(m, listOf(part))) + + val c = controller("ses_test") + val modelEvents = collectModelEvents(c) + flush() + + assertModelEvents("HistoryLoaded", modelEvents) + assertModel( + """ + user#msg1 + text#prt1: + hello + """, + c, + ) + } + + fun `test non-empty history shows messages view`() { + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + + val c = controller("ses_test") + flush() + + assertSession( + """ + user#msg1 + + [app: DISCONNECTED] [workspace: PENDING] + """, + c, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt new file mode 100644 index 00000000000..174c05879a2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -0,0 +1,129 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.util.Disposer + +class ListenerLifecycleTest : SessionControllerTestBase() { + + fun `test listener removed on parent dispose`() { + val m = controller() + val disposable = Disposer.newDisposable("listener-parent") + Disposer.register(parent, disposable) + + val events = mutableListOf() + m.addListener(disposable) { events.add(it) } + + edt { m.prompt("before") } + flush() + + Disposer.dispose(disposable) + + edt { m.prompt("after") } + flush() + + assertControllerEvents(""" + ViewChanged show + AppChanged + WorkspaceChanged + """, events) + } + + fun `test all listeners notified`() { + val m = controller() + val events1 = mutableListOf() + val events2 = mutableListOf() + val d1 = Disposer.newDisposable("l1") + val d2 = Disposer.newDisposable("l2") + Disposer.register(parent, d1) + Disposer.register(parent, d2) + + m.addListener(d1) { events1.add(it) } + m.addListener(d2) { events2.add(it) } + + edt { m.prompt("go") } + flush() + + assertControllerEvents(""" + ViewChanged show + AppChanged + WorkspaceChanged + """, events1) + assertControllerEvents(""" + ViewChanged show + AppChanged + WorkspaceChanged + """, events2) + } + + fun `test session status idle fires StateChanged to Idle`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + modelEvents.clear() + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + + assertModelEvents("StateChanged Idle", modelEvents) + assertEquals(SessionState.Idle, m.model.state) + } + + fun `test session status busy fires StateChanged to Busy`() { + val (_, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null))) + + assertModelEvents("StateChanged Busy", modelEvents) + } + + fun `test session status busy ignored when already busy`() { + val (_, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + modelEvents.clear() + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy"))) + + // Already in Busy — status busy is ignored + assertTrue(modelEvents.isEmpty()) + } + + fun `test session status retry with zero attempt`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("retry", "Waiting...", attempt = 0, next = 1000L), + )) + + val state = m.model.state as SessionState.Retry + assertEquals("Waiting...", state.message) + assertEquals(0, state.attempt) + assertEquals(1000L, state.next) + } + + fun `test session status offline`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("offline", "Disconnected", requestID = "req_1"), + )) + + val state = m.model.state as SessionState.Offline + assertEquals("Disconnected", state.message) + assertEquals("req_1", state.requestId) + } + + fun `test session status unknown type is ignored`() { + val (m, _, modelEvents) = prompted() + modelEvents.clear() + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("weird_future_status"))) + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt new file mode 100644 index 00000000000..3290a336b1c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt @@ -0,0 +1,116 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageTimeDto + +class MessageListTest : SessionControllerTestBase() { + + fun `test MessageUpdated adds message to SessionModel`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + assertModelEvents("MessageAdded msg1", modelEvents) + assertNotNull(m.model.message("msg1")) + } + + fun `test MessageUpdated second event updates existing message`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + // Second update for the same message ID (e.g. tokens/cost finalized) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + // Should fire MessageUpdated, not MessageAdded + assertModelEvents("MessageUpdated msg1", modelEvents) + assertNotNull(m.model.message("msg1")) + } + + fun `test MessageUpdated second event preserves existing parts`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) + + // Update the message info (should not wipe parts) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + assertNotNull(m.model.message("msg1")!!.parts["prt1"]) + } + + fun `test PartUpdated text updates SessionModel`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) + + assertModelEvents(""" + MessageAdded msg1 + ContentAdded msg1/prt1 + """, modelEvents) + assertModel( + """ + assistant#msg1 + text#prt1: + hello + """, + m, + ) + } + + fun `test PartDelta appends text to SessionModel`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world")) + + assertModel( + """ + assistant#msg1 + text#prt1: + hello world + """, + m, + ) + } + + fun `test PartRemoved removes content from message`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) + modelEvents.clear() + + emit(ChatEventDto.PartRemoved("ses_test", "msg1", "prt1")) + + assertNull(m.model.message("msg1")!!.parts["prt1"]) + assertModelEvents("ContentRemoved msg1/prt1", modelEvents) + } + + fun `test PartRemoved unknown part is noop`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartRemoved("ses_test", "msg1", "no_such_part")) + + assertTrue(modelEvents.isEmpty()) + } + + fun `test MessageRemoved removes from SessionModel`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) + assertNotNull(m.model.message("msg1")) + + emit(ChatEventDto.MessageRemoved("ses_test", "msg1")) + assertNull(m.model.message("msg1")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt new file mode 100644 index 00000000000..38cb5abf77b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt @@ -0,0 +1,202 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.ToolRefDto + +class PromptLifecycleTest : SessionControllerTestBase() { + + fun `test PermissionAsked moves state to AwaitingPermission`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + assertSession( + """ + permission#perm1 + tool: msg1/call1 + name: edit + patterns: *.kt + always: + file: src/A.kt + state: RESPONDING + metadata: kind=edit + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, + ) + } + + fun `test PermissionReplied resumes Busy state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test QuestionAsked moves state to AwaitingQuestion`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + + assertSession( + """ + question#q1 + tool: msg1/call1 + header: Choice + prompt: Pick one + option: A - Option A + multiple: false + custom: true + + [code] [kilo/gpt-5] [awaiting-question] + """, + m, + ) + } + + fun `test QuestionReplied resumes Busy state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionReplied("ses_test", "q1")) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test QuestionRejected moves state to Idle`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionRejected("ses_test", "q1")) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test PermissionReplied with wrong requestID is ignored`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + emit(ChatEventDto.PermissionReplied("ses_test", "wrong_id")) + + // State must remain AwaitingPermission + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + + fun `test QuestionReplied with wrong requestID is ignored`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionReplied("ses_test", "wrong_id")) + + // State must remain AwaitingQuestion + assertTrue(m.model.state is SessionState.AwaitingQuestion) + } + + fun `test QuestionRejected with wrong requestID is ignored`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionRejected("ses_test", "wrong_id")) + + // State must remain AwaitingQuestion + assertTrue(m.model.state is SessionState.AwaitingQuestion) + } + + fun `test replyPermission calls RPC`() { + val (m, _, _) = prompted() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + edt { m.replyPermission("perm1", PermissionReplyDto("once")) } + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("perm1", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + } + + fun `test replyPermission with rules saves always rules first`() { + val (m, _, _) = prompted() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + val rules = PermissionAlwaysRulesDto(approvedAlways = listOf("src/**")) + edt { m.replyPermission("perm1", PermissionReplyDto("always"), rules) } + flush() + + assertEquals(1, rpc.permissionRulesSaved.size) + assertEquals("perm1", rpc.permissionRulesSaved[0].first) + assertEquals(1, rpc.permissionReplies.size) + } + + fun `test replyQuestion calls RPC`() { + val (m, _, _) = prompted() + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + + edt { m.replyQuestion("q1", QuestionReplyDto(listOf(listOf("A")))) } + flush() + + assertEquals(1, rpc.questionReplies.size) + assertEquals("q1", rpc.questionReplies[0].first) + } + + fun `test rejectQuestion calls RPC`() { + val (m, _, _) = prompted() + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + + edt { m.rejectQuestion("q1") } + flush() + + assertEquals(1, rpc.questionRejects.size) + assertEquals("q1", rpc.questionRejects[0].first) + } + + private fun permission(id: String) = PermissionRequestDto( + id = id, + sessionID = "ses_test", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + metadata = mapOf("kind" to "edit", "file" to "src/A.kt", "state" to "RESPONDING"), + tool = ToolRefDto("msg1", "call1"), + ) + + private fun question(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = listOf(QuestionOptionDto("A", "Option A")), + multiple = false, + custom = true, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt new file mode 100644 index 00000000000..e25d544235a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt @@ -0,0 +1,184 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.TodoDto + +/** + * Controller-level tests for session artifact events: + * [ChatEventDto.SessionDiffChanged], [ChatEventDto.TodoUpdated], and + * [ChatEventDto.SessionCompacted]. + * + * Verifies that the controller forwards them to the model and fires the + * expected model events, and that the session ID filter drops events + * belonging to other sessions. + */ +class SessionArtifactsTest : SessionControllerTestBase() { + + // ------ SessionDiffChanged ------ + + fun `test SessionDiffChanged updates model and fires DiffUpdated`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionDiffChanged( + sessionID = "ses_test", + diff = listOf( + DiffFileDto("src/A.kt", additions = 5, deletions = 2), + DiffFileDto("src/B.kt", additions = 1, deletions = 0), + ), + )) + + assertModelEvents("DiffUpdated files=2", modelEvents) + assertSession( + """ + diff: src/A.kt src/B.kt + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionDiffChanged from other session is ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionDiffChanged( + sessionID = "ses_other", + diff = listOf(DiffFileDto("src/Other.kt", additions = 1, deletions = 0)), + )) + + assertModelEvents("", modelEvents) + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionDiffChanged with empty list clears diff`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 3, 1)))) + emit(ChatEventDto.SessionDiffChanged("ses_test", emptyList())) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + // ------ TodoUpdated ------ + + fun `test TodoUpdated updates model and fires TodosUpdated`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TodoUpdated( + sessionID = "ses_test", + todos = listOf( + TodoDto("Write tests", "pending", "high"), + TodoDto("Ship it", "in_progress", "medium"), + ), + )) + + assertModelEvents("TodosUpdated count=2", modelEvents) + assertSession( + """ + todo: [pending] Write tests + todo: [in_progress] Ship it + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test TodoUpdated from other session is ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TodoUpdated( + sessionID = "ses_other", + todos = listOf(TodoDto("alien task", "pending", "low")), + )) + + assertModelEvents("", modelEvents) + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test TodoUpdated replaces previous todo list`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TodoUpdated("ses_test", listOf( + TodoDto("task1", "pending", "high"), + TodoDto("task2", "pending", "low"), + ))) + emit(ChatEventDto.TodoUpdated("ses_test", listOf( + TodoDto("only task", "completed", "medium"), + ))) + + assertSession( + """ + todo: [completed] only task + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + // ------ SessionCompacted ------ + + fun `test SessionCompacted fires Compacted event`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionCompacted("ses_test")) + + assertModelEvents("Compacted count=1", modelEvents) + assertSession( + """ + compacted: 1 + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test multiple SessionCompacted events accumulate`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionCompacted("ses_test"), flush = false) + emit(ChatEventDto.SessionCompacted("ses_test"), flush = false) + emit(ChatEventDto.SessionCompacted("ses_test")) + + assertSession( + """ + compacted: 3 + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionCompacted from other session is ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionCompacted("ses_other")) + + assertModelEvents("", modelEvents) + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt similarity index 64% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index d7b4ea1b2b3..d4410271665 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -1,7 +1,9 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi @@ -10,6 +12,8 @@ import ai.kilocode.client.app.Workspace import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.MessageDto @@ -30,12 +34,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking /** - * Base class for [SessionModel] tests. + * Base class for [SessionController] tests. * * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. */ -abstract class SessionModelTestBase : BasePlatformTestCase() { +abstract class SessionControllerTestBase : BasePlatformTestCase() { protected lateinit var rpc: FakeSessionRpcApi protected lateinit var appRpc: FakeAppRpcApi @@ -73,16 +77,16 @@ abstract class SessionModelTestBase : BasePlatformTestCase() { } } - // ------ Model creation ------ + // ------ Controller creation ------ - protected fun model(id: String? = null) = - SessionModel(parent, id, sessions, workspace, app, scope) + protected fun controller(id: String? = null) = + SessionController(parent, id, sessions, workspace, app, scope) // ------ Event collection ------ - /** Attach a listener that collects events and asserts EDT. */ - protected fun collect(m: SessionModel): MutableList { - val events = mutableListOf() + /** Attach a listener that collects lifecycle events and asserts EDT. */ + protected fun collect(m: SessionController): MutableList { + val events = mutableListOf() val disposable = Disposer.newDisposable("listener") Disposer.register(parent, disposable) m.addListener(disposable) { event -> @@ -92,6 +96,17 @@ abstract class SessionModelTestBase : BasePlatformTestCase() { return events } + /** Attach a listener that collects model events (messages, parts, phase). */ + protected fun collectModelEvents(m: SessionController): MutableList { + val events = mutableListOf() + val disposable = Disposer.newDisposable("model-listener") + Disposer.register(parent, disposable) + m.model.addListener(disposable) { event -> + events.add(event) + } + return events + } + // ------ EDT + coroutine helpers ------ /** Let coroutines settle, then drain all pending EDT events. */ @@ -107,17 +122,43 @@ abstract class SessionModelTestBase : BasePlatformTestCase() { } /** Emit a chat event into the fake RPC flow. */ - protected fun emit(event: ChatEventDto) = runBlocking { - rpc.events.emit(event) + protected fun emit(event: ChatEventDto, flush: Boolean = true) { + runBlocking { rpc.events.emit(event) } + if (flush) flush() } - /** Create a model, attach listener, send initial prompt, and flush. */ - protected fun prompted(): Pair> { - val m = model() + /** Create a controller, attach both listeners, send initial prompt, and flush. */ + protected fun prompted(): Triple, MutableList> { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller() val events = collect(m) + val modelEvents = collectModelEvents(m) + flush() edt { m.prompt("go") } flush() - return m to events + return Triple(m, events, modelEvents) + } + + protected fun assertModel(expected: String, model: SessionModel) { + assertEquals(expected.trimIndent().trim(), model.toString().trim()) + } + + protected fun assertModel(expected: String, c: SessionController) { + assertModel(expected, c.model) + } + + protected fun assertSession(expected: String, c: SessionController, show: Boolean = true) { + assertEquals(expected.trimIndent().trim(), c.toString().trim()) + assertEquals(show, c.model.showMessages) + } + + protected fun assertControllerEvents(expected: String, events: List) { + assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + } + + protected fun assertModelEvents(expected: String, events: List) { + assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) } // ------ DTO factories ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt similarity index 67% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt index d1a02f01798..ac7d2c9a944 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt @@ -1,10 +1,12 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session -class SessionCreationTest : SessionModelTestBase() { +class SessionCreationTest : SessionControllerTestBase() { fun `test prompt creates session on first call`() { - val m = model() + val m = controller() val events = collect(m) + flush() + events.clear() edt { m.prompt("hello") } flush() @@ -12,11 +14,17 @@ class SessionCreationTest : SessionModelTestBase() { assertEquals(1, rpc.creates) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) - assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) + assertControllerEvents("ViewChanged show", events) + assertSession( + """ + [app: DISCONNECTED] [workspace: PENDING] + """, + m, + ) } fun `test prompt reuses existing session`() { - val m = model() + val m = controller() edt { m.prompt("first") } flush() @@ -29,7 +37,7 @@ class SessionCreationTest : SessionModelTestBase() { } fun `test prompt with existing ID skips creation`() { - val m = model("existing") + val m = controller("existing") collect(m) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt new file mode 100644 index 00000000000..87d68d4ff32 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt @@ -0,0 +1,266 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.SessionTimeDto + +/** + * Tests for pending permission/question recovery after history load. + * + * VS Code rehydrates pending prompts by calling list endpoints after + * reconnect. JetBrains now does the same in [SessionController.recoverPending]. + */ +class SessionRecoveryTest : SessionControllerTestBase() { + + override fun setUp() { + super.setUp() + // Set a pre-existing session in the fake API + rpc.session = rpc.session.copy(id = "ses_test") + } + + fun `test pending permission is recovered on history load`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_pending", + sessionID = "ses_test", + permission = "read", + patterns = listOf("*.json"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("perm_pending", perm.id) + assertEquals("read", perm.name) + } + + fun `test pending question is recovered when no pending permissions`() { + rpc.pendingQuestionList.add( + QuestionRequestDto( + id = "q_pending", + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("What?", "Q")), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertTrue(m.model.state is SessionState.AwaitingQuestion) + val q = (m.model.state as SessionState.AwaitingQuestion).question + assertEquals("q_pending", q.id) + } + + fun `test pending from other session is ignored`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_other", + sessionID = "ses_other", // different session + permission = "read", + patterns = emptyList(), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // State should remain Idle — other session's pending is irrelevant + assertEquals(SessionState.Idle, m.model.state) + } + + fun `test permission takes priority over question in recovery`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_pending", + sessionID = "ses_test", + permission = "edit", + patterns = emptyList(), + ) + ) + rpc.pendingQuestionList.add( + QuestionRequestDto( + id = "q_pending", + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("What?", "Q")), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // Permission list non-empty → AwaitingPermission wins + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + + // ------ Status seeding from KiloSessionService.statuses ------ + + fun `test busy status is seeded from statuses map`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, show = false, + ) + } + + fun `test retry status is seeded with message attempt and next`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto( + type = "retry", + message = "Rate limited", + attempt = 3, + next = 5000L, + )) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [retry] [Rate limited] + """, + m, show = false, + ) + val state = m.model.state as SessionState.Retry + assertEquals(3, state.attempt) + assertEquals(5000L, state.next) + } + + fun `test offline status is seeded with message and requestId`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto( + type = "offline", + message = "No network", + requestID = "req_xyz", + )) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [offline] [No network] + """, + m, show = false, + ) + assertEquals("req_xyz", (m.model.state as SessionState.Offline).requestId) + } + + fun `test idle status in map leaves controller in Idle`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle")) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, show = false, + ) + } + + fun `test missing status entry leaves controller in Idle`() { + rpc.statuses.value = emptyMap() + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, show = false, + ) + } + + fun `test pending permission overrides a seeded busy status`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_p", + sessionID = "ses_test", + permission = "read", + patterns = listOf("*.kt"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + permission#perm_p + tool: + name: read + patterns: *.kt + always: + file: + state: PENDING + metadata: + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, show = false, + ) + } + + fun `test pending question overrides a seeded retry status`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("retry", "Rate limited", attempt = 1, next = 1000L)) + rpc.pendingQuestionList.add( + QuestionRequestDto( + id = "q_p", + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("Proceed?", "Q")), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + question#q_p + tool: + header: Q + prompt: Proceed? + multiple: false + custom: true + + [code] [kilo/gpt-5] [awaiting-question] + """, + m, show = false, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt new file mode 100644 index 00000000000..d82f05b2f2a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt @@ -0,0 +1,43 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.ChatEventDto + +class StatusComputationTest : SessionControllerTestBase() { + + fun `test status shows tool-specific text`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) + + assertSession( + """ + assistant#msg1 + tool#prt1 bash [PENDING] + + [code] [kilo/gpt-5] [busy] [running commands] + """, + m, + ) + } + + fun `test PartUpdated after TurnClose does not fire StateChanged`() { + val (_, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) + + assertModelEvents(""" + MessageAdded msg1 + StateChanged Busy + StateChanged Idle + ContentAdded msg1/prt1 + """, modelEvents) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt new file mode 100644 index 00000000000..317271b1a60 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt @@ -0,0 +1,164 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.SessionStatusDto + +class TurnLifecycleTest : SessionControllerTestBase() { + + fun `test TurnOpen fires StateChanged to Busy`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test TurnClose fires StateChanged to Idle`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test TurnClose error reason does not clobber Error state`() { + val (m, _, _) = prompted() + + // Error event arrives just before TurnClose + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + emit(ChatEventDto.TurnClose("ses_test", "error")) + + // Error state must survive + assertSession( + """ + [code] [kilo/gpt-5] [error] [Timed out] + """, + m, + ) + } + + fun `test TurnClose completed clobbers Error state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + + // "completed" always wins over error + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test Error fires StateChanged to Error`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) + + assertSession( + """ + [code] [kilo/gpt-5] [error] [Bad Request] + """, + m, + ) + } + + fun `test Error with null message falls back to type`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) + + assertSession( + """ + [code] [kilo/gpt-5] [error] [timeout] + """, + m, + ) + } + + fun `test SessionStatusChanged retry with full detail`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("retry", "Rate limited", attempt = 2, next = 5000L), + )) + + val state = m.model.state as SessionState.Retry + assertEquals("Rate limited", state.message) + assertEquals(2, state.attempt) + assertEquals(5000L, state.next) + } + + fun `test SessionStatusChanged offline with requestID`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("offline", "No network", requestID = "req_abc"), + )) + + val state = m.model.state as SessionState.Offline + assertEquals("No network", state.message) + assertEquals("req_abc", state.requestId) + } + + fun `test SessionIdle transitions to Idle from Busy`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionIdle("ses_test")) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionIdle does not clobber Error state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + emit(ChatEventDto.SessionIdle("ses_test")) + + // Error state must survive + assertSession( + """ + [code] [kilo/gpt-5] [error] [Timed out] + """, + m, + ) + } + + fun `test events for wrong session are ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_other")) + + // No state change — event was filtered out + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + assertModelEvents("", modelEvents) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt new file mode 100644 index 00000000000..66f687eb884 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt @@ -0,0 +1,36 @@ +package ai.kilocode.client.session + +class ViewSwitchingTest : SessionControllerTestBase() { + + fun `test first prompt shows messages view`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.prompt("hello") } + flush() + + assertControllerEvents("ViewChanged show", events) + assertSession( + """ + [app: DISCONNECTED] [workspace: PENDING] + """, + m, + ) + } + + fun `test ViewChanged not fired twice`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.prompt("first") } + flush() + edt { m.prompt("second") } + flush() + + assertControllerEvents("ViewChanged show", events) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt new file mode 100644 index 00000000000..92ba9f79785 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt @@ -0,0 +1,43 @@ +package ai.kilocode.client.session + +class WorkspaceWatchingTest : SessionControllerTestBase() { + + fun `test workspace ready populates agents and models`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + projectRpc.state.value = workspaceReady() + flush() + + assertEquals(1, m.model.agents.size) + assertEquals("code", m.model.agents[0].name) + assertEquals(1, m.model.models.size) + assertEquals("gpt-5", m.model.models[0].id) + assertFalse(m.model.isReady()) + assertControllerEvents(""" + WorkspaceChanged + WorkspaceReady + """, events) + assertSession( + """ + [code] [kilo/gpt-5] [app: DISCONNECTED] [workspace: READY] + """, + m, + show = false, + ) + } + + fun `test workspace ready sets default agent and model`() { + val m = controller() + collect(m) + flush() + + projectRpc.state.value = workspaceReady() + flush() + + assertEquals("code", m.model.agent) + assertEquals("kilo/gpt-5", m.model.model) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt deleted file mode 100644 index d22150f9040..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto - -class AppWatchingTest : SessionModelTestBase() { - - fun `test app state change fires AppChanged`() { - val m = model() - val events = collect(m) - flush() - - appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) - flush() - - assertTrue(events.any { it is SessionEvent.AppChanged }) - assertEquals(KiloAppStatusDto.READY, m.chat.app.status) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt deleted file mode 100644 index 370aad40e41..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package ai.kilocode.client.session.model - -class ConfigSelectionTest : SessionModelTestBase() { - - fun `test selectModel updates ChatModel and calls updateConfig`() { - val m = model() - collect(m) - flush() - - edt { m.selectModel("kilo", "gpt-5") } - flush() - - assertEquals("kilo/gpt-5", m.chat.model) - assertEquals(1, rpc.configs.size) - assertEquals("kilo/gpt-5", rpc.configs[0].second.model) - } - - fun `test selectAgent updates ChatModel and calls updateConfig`() { - val m = model() - collect(m) - flush() - - edt { m.selectAgent("plan") } - flush() - - assertEquals("plan", m.chat.agent) - assertEquals(1, rpc.configs.size) - assertEquals("plan", rpc.configs[0].second.agent) - } - - fun `test selectModel fires WorkspaceReady event`() { - val m = model() - val events = collect(m) - flush() - - edt { m.selectModel("kilo", "gpt-5") } - flush() - - assertTrue(events.any { it is SessionEvent.WorkspaceReady }) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt deleted file mode 100644 index 84a9b277996..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.MessageWithPartsDto - -class HistoryLoadingTest : SessionModelTestBase() { - - fun `test existing session loads history on init`() { - val m = msg("msg1", "ses_test", "user") - val p = part("prt1", "ses_test", "msg1", "text", text = "hello") - rpc.history.add(MessageWithPartsDto(m, listOf(p))) - - val model = model("ses_test") - val events = collect(model) - flush() - - assertTrue(events.any { it is SessionEvent.HistoryLoaded }) - assertNotNull(model.chat.message("msg1")) - assertEquals("hello", model.chat.part("msg1", "prt1")?.text?.toString()) - } - - fun `test non-empty history shows messages view`() { - rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) - - val model = model("ses_test") - val events = collect(model) - flush() - - assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) - assertTrue(model.chat.showMessages) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt deleted file mode 100644 index 88187e7ffc3..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.SessionStatusDto -import com.intellij.openapi.util.Disposer - -class ListenerLifecycleTest : SessionModelTestBase() { - - fun `test listener removed on parent dispose`() { - val m = model() - val disposable = Disposer.newDisposable("listener-parent") - Disposer.register(parent, disposable) - - val events = mutableListOf() - m.addListener(disposable) { events.add(it) } - - edt { m.prompt("before") } - flush() - val before = events.size - - Disposer.dispose(disposable) - - edt { m.prompt("after") } - flush() - - assertEquals(before, events.size) - } - - fun `test all listeners notified`() { - val m = model() - val events1 = mutableListOf() - val events2 = mutableListOf() - val d1 = Disposer.newDisposable("l1") - val d2 = Disposer.newDisposable("l2") - Disposer.register(parent, d1) - Disposer.register(parent, d2) - - m.addListener(d1) { events1.add(it) } - m.addListener(d2) { events2.add(it) } - - edt { m.prompt("go") } - flush() - - assertTrue(events1.isNotEmpty()) - assertTrue(events2.isNotEmpty()) - assertEquals(events1.map { it::class }, events2.map { it::class }) - } - - fun `test session status busy fires BusyChanged`() { - val m = model() - val events = collect(m) - - edt { m.prompt("go") } - flush() - - rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy", null)) - flush() - - assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy }) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt deleted file mode 100644 index d155dc83f32..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.ChatEventDto - -class MessageListTest : SessionModelTestBase() { - - fun `test MessageUpdated adds message to ChatModel`() { - val (m, events) = prompted() - - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() - - assertTrue(events.any { it is SessionEvent.MessageAdded && it.id == "msg1" }) - assertNotNull(m.chat.message("msg1")) - } - - fun `test PartUpdated text fires PartUpdated event`() { - val (m, events) = prompted() - - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() - - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) - flush() - - assertTrue(events.any { it is SessionEvent.PartUpdated && it.messageId == "msg1" && it.partId == "prt1" }) - } - - fun `test PartDelta appends text to ChatModel`() { - val (m, _) = prompted() - - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() - - emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello ")) - emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world")) - flush() - - val p = m.chat.part("msg1", "prt1") - assertNotNull(p) - assertEquals("hello world", p!!.text.toString()) - } - - fun `test MessageRemoved removes from ChatModel`() { - val (m, _) = prompted() - - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) - flush() - assertNotNull(m.chat.message("msg1")) - - emit(ChatEventDto.MessageRemoved("ses_test", "msg1")) - flush() - assertNull(m.chat.message("msg1")) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt new file mode 100644 index 00000000000..50cad2007ce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -0,0 +1,577 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.TodoDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.UsefulTestCase + +class SessionModelTest : UsefulTestCase() { + + private lateinit var model: SessionModel + private lateinit var parent: Disposable + private lateinit var events: MutableList + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("test") + model = SessionModel() + events = mutableListOf() + model.addListener(parent) { events.add(it) } + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + } finally { + super.tearDown() + } + } + + fun `test initial app and workspace state`() { + assertEquals(KiloAppStatusDto.DISCONNECTED, model.app.status) + assertEquals(KiloWorkspaceStatusDto.PENDING, model.workspace.status) + assertFalse(model.isReady()) + assertEquals(SessionState.Idle, model.state) + } + + fun `test isReady requires app and workspace readiness`() { + model.app = KiloAppStateDto(KiloAppStatusDto.READY) + assertFalse(model.isReady()) + + model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + assertTrue(model.isReady()) + } + + fun `test addMessage stores entry and fires MessageAdded`() { + model.addMessage(msg("m1", "user")) + + val item = model.message("m1") + assertNotNull(item) + assertEquals(1, events.size) + val event = events[0] as SessionModelEvent.MessageAdded + assertEquals("m1", event.info.info.id) + } + + fun `test addMessage duplicate is ignored`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.addMessage(msg("m1", "user")) + + assertEquals(1, model.messages().size) + assertTrue(events.isEmpty()) + } + + fun `test removeMessage removes entry and fires MessageRemoved`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.removeMessage("m1") + + assertNull(model.message("m1")) + assertEquals(1, events.size) + assertEquals("m1", (events[0] as SessionModelEvent.MessageRemoved).id) + } + + fun `test removeMessage unknown id is noop`() { + model.removeMessage("unknown") + assertTrue(events.isEmpty()) + } + + fun `test updateContent text creates Text content and fires ContentAdded`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "hello")) + + val p = model.message("m1")!!.parts["p1"] + assertTrue(p is Text) + assertEquals("hello", (p as Text).content.toString()) + val event = events.single() as SessionModelEvent.ContentAdded + assertEquals("m1", event.messageId) + } + + fun `test updateContent text replaces content and fires ContentUpdated`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "old")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "new")) + + assertEquals("new", (model.message("m1")!!.parts["p1"] as Text).content.toString()) + assertTrue(events.single() is SessionModelEvent.ContentUpdated) + } + + fun `test updateContent reasoning creates Reasoning content`() { + model.addMessage(msg("m1", "assistant")) + + model.updateContent("m1", part("p1", "m1", "reasoning", text = "thinking")) + + val p = model.message("m1")!!.parts["p1"] + assertTrue(p is Reasoning) + assertEquals("thinking", (p as Reasoning).content.toString()) + } + + fun `test updateContent tool creates Tool content and tracks state`() { + model.addMessage(msg("m1", "assistant")) + + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running", title = "ls")) + + val p = model.message("m1")!!.parts["p1"] as Tool + assertEquals("bash", p.name) + assertEquals(ToolExecState.RUNNING, p.state) + assertEquals("ls", p.title) + } + + fun `test updateContent tool updates lifecycle`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "completed")) + + val p = model.message("m1")!!.parts["p1"] as Tool + assertEquals(ToolExecState.COMPLETED, p.state) + assertTrue(events.single() is SessionModelEvent.ContentUpdated) + } + + fun `test updateContent compaction creates Compaction content`() { + model.addMessage(msg("m1", "assistant")) + + model.updateContent("m1", part("p1", "m1", "compaction")) + + assertTrue(model.message("m1")!!.parts["p1"] is Compaction) + } + + fun `test updateContent unknown type stored as Generic`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "step-start")) + + val p = model.message("m1")!!.parts["p1"] + assertTrue("Expected Generic fallback but got: ${p?.javaClass?.simpleName}", p is Generic) + assertEquals("step-start", (p as Generic).type) + assertTrue(events.single() is SessionModelEvent.ContentAdded) + } + + fun `test appendDelta appends to existing text content`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "hello ")) + events.clear() + + model.appendDelta("m1", "p1", "world") + + assertEquals("hello world", (model.message("m1")!!.parts["p1"] as Text).content.toString()) + val event = events.single() as SessionModelEvent.ContentDelta + assertEquals("p1", event.contentId) + assertEquals("world", event.delta) + } + + fun `test appendDelta appends to reasoning content`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "reasoning", text = "hmm ")) + events.clear() + + model.appendDelta("m1", "p1", "interesting") + + assertEquals("hmm interesting", (model.message("m1")!!.parts["p1"] as Reasoning).content.toString()) + } + + fun `test appendDelta creates text content if missing`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.appendDelta("m1", "p1", "first") + + val p = model.message("m1")!!.parts["p1"] + assertTrue(p is Text) + assertEquals("first", (p as Text).content.toString()) + assertEquals(2, events.size) + assertTrue(events[0] is SessionModelEvent.ContentAdded) + assertTrue(events[1] is SessionModelEvent.ContentDelta) + } + + fun `test appendDelta on tool content is noop`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running")) + events.clear() + + model.appendDelta("m1", "p1", "text") + + assertTrue(events.isEmpty()) + } + + fun `test setState stores state and fires StateChanged`() { + val state = SessionState.Busy("thinking") + model.setState(state) + + assertEquals(state, model.state) + assertEquals(state, (events.single() as SessionModelEvent.StateChanged).state) + } + + fun `test setState to Error stores error data`() { + model.setState(SessionState.Error("something broke", "timeout")) + + val state = model.state as SessionState.Error + assertEquals("something broke", state.message) + assertEquals("timeout", state.kind) + } + + fun `test setState to AwaitingQuestion stores question`() { + val q = question("q1") + model.setState(SessionState.AwaitingQuestion(q)) + + assertModel( + """ + question#q1 + tool: + header: Pick + prompt: Which option? + option: A - Option A + option: B - Option B + multiple: false + custom: true + """, + ) + } + + fun `test setState to AwaitingPermission stores permission`() { + val p = permission("p1") + model.setState(SessionState.AwaitingPermission(p)) + + assertModel( + """ + permission#p1 + tool: + name: edit + patterns: *.kt + always: + file: + state: PENDING + metadata: + """, + ) + } + + fun `test question tool ref is stored in awaiting question state`() { + val q = Question( + id = "q1", + items = listOf(QuestionItem("Pick one", "Choice", listOf(QuestionOption("A", "Option A")), false, true)), + tool = ToolCallRef("msg1", "call1"), + ) + + model.setState(SessionState.AwaitingQuestion(q)) + + assertModel( + """ + question#q1 + tool: msg1/call1 + header: Choice + prompt: Pick one + option: A - Option A + multiple: false + custom: true + """, + ) + } + + fun `test permission fields are preserved in awaiting permission state`() { + val p = Permission( + id = "p1", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = listOf("src/**"), + meta = PermissionMeta( + rules = listOf("src/**"), + diff = "patch", + filePath = "src/A.kt", + fileDiff = PermissionFileDiff("src/A.kt", additions = 2, deletions = 1), + raw = mapOf("kind" to "edit"), + ), + message = "Allow edit?", + tool = ToolCallRef("msg1", "call1"), + state = PermissionRequestState.RESPONDING, + ) + + model.setState(SessionState.AwaitingPermission(p)) + + assertModel( + """ + permission#p1 + tool: msg1/call1 + name: edit + patterns: *.kt + always: src/** + file: src/A.kt + state: RESPONDING + metadata: kind=edit + """, + ) + } + + fun `test loadHistory populates typed contents and fires HistoryLoaded`() { + model.addMessage(msg("old", "user")) + events.clear() + + val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "hello") + val tool = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "tool", tool = "bash", state = "completed", title = "ls") + + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, tool)))) + + assertNull(model.message("old")) + val entry = model.message("m1")!! + assertTrue(entry.parts["p1"] is Text) + assertEquals("hello", (entry.parts["p1"] as Text).content.toString()) + assertTrue(entry.parts["p2"] is Tool) + assertEquals(ToolExecState.COMPLETED, (entry.parts["p2"] as Tool).state) + assertTrue(events.single() is SessionModelEvent.HistoryLoaded) + } + + fun `test loadHistory stores unknown content types as Generic`() { + val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") + val snapshot = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "snapshot") + + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, snapshot)))) + + val entry = model.message("m1")!! + assertTrue(entry.parts.containsKey("p1")) + assertTrue(entry.parts.containsKey("p2")) + assertTrue(entry.parts["p2"] is Generic) + assertEquals("snapshot", (entry.parts["p2"] as Generic).type) + } + + fun `test upsertMessage adds new message and returns true`() { + val added = model.upsertMessage(msg("m1", "user")) + + assertTrue(added) + assertNotNull(model.message("m1")) + val event = events.single() as SessionModelEvent.MessageAdded + assertEquals("m1", event.info.info.id) + } + + fun `test upsertMessage updates existing message and returns false`() { + model.upsertMessage(msg("m1", "user")) + events.clear() + + val updated = msg("m1", "assistant") // same id, different role (simulating metadata update) + val added = model.upsertMessage(updated) + + assertFalse(added) + assertEquals("assistant", model.message("m1")!!.info.role) + val event = events.single() as SessionModelEvent.MessageUpdated + assertEquals("m1", event.info.info.id) + } + + fun `test upsertMessage update preserves existing parts`() { + model.upsertMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "hello")) + events.clear() + + model.upsertMessage(msg("m1", "assistant")) + + assertNotNull(model.message("m1")!!.parts["p1"]) + } + + fun `test removeContent fires ContentRemoved`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "hello")) + events.clear() + + model.removeContent("m1", "p1") + + assertNull(model.message("m1")!!.parts["p1"]) + val event = events.single() as SessionModelEvent.ContentRemoved + assertEquals("m1", event.messageId) + assertEquals("p1", event.contentId) + } + + fun `test removeContent unknown messageId is noop`() { + events.clear() + model.removeContent("missing", "p1") + assertTrue(events.isEmpty()) + } + + fun `test removeContent unknown contentId is noop`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + model.removeContent("m1", "missing_part") + assertTrue(events.isEmpty()) + } + + fun `test setDiff stores diff and fires DiffUpdated`() { + model.setDiff(listOf( + DiffFileDto("src/A.kt", additions = 3, deletions = 1), + DiffFileDto("src/B.kt", additions = 0, deletions = 2), + )) + + assertModel("diff: src/A.kt src/B.kt") + assertEquals("DiffUpdated files=2", events.single().toString()) + } + + fun `test setDiff with empty list clears diff`() { + model.setDiff(listOf(DiffFileDto("src/A.kt", additions = 1, deletions = 0))) + events.clear() + + model.setDiff(emptyList()) + + assertModel("") + assertEquals("DiffUpdated files=0", events.single().toString()) + } + + fun `test setTodos stores todos and fires TodosUpdated`() { + model.setTodos(listOf( + TodoDto("Write tests", "pending", "high"), + TodoDto("Ship it", "in_progress", "medium"), + )) + + assertModel(""" + todo: [pending] Write tests + todo: [in_progress] Ship it + """) + assertEquals("TodosUpdated count=2", events.single().toString()) + } + + fun `test setTodos with empty list clears todos`() { + model.setTodos(listOf(TodoDto("old task", "completed", "low"))) + events.clear() + + model.setTodos(emptyList()) + + assertModel("") + assertEquals("TodosUpdated count=0", events.single().toString()) + } + + fun `test markCompacted increments count and fires Compacted`() { + model.markCompacted() + + assertModel("compacted: 1") + assertEquals("Compacted count=1", events.single().toString()) + } + + fun `test markCompacted accumulates across multiple calls`() { + model.markCompacted() + model.markCompacted() + model.markCompacted() + + assertModel("compacted: 3") + assertEquals("Compacted count=3", events.last().toString()) + } + + fun `test clear resets diff todos and compactionCount`() { + model.setDiff(listOf(DiffFileDto("a.kt", 1, 0))) + model.setTodos(listOf(TodoDto("task", "pending", "high"))) + model.markCompacted() + events.clear() + + model.clear() + + assertModel("") + assertTrue(events.single() is SessionModelEvent.Cleared) + } + + fun `test loadHistory resets diff todos and compactionCount`() { + model.setDiff(listOf(DiffFileDto("a.kt", 1, 0))) + model.setTodos(listOf(TodoDto("task", "pending", "high"))) + model.markCompacted() + events.clear() + + model.loadHistory(emptyList()) + + assertModel("") + assertTrue(events.single() is SessionModelEvent.HistoryLoaded) + } + + fun `test clear resets messages and state`() { + model.addMessage(msg("m1", "user")) + model.setState(SessionState.Busy("busy")) + model.app = KiloAppStateDto(KiloAppStatusDto.READY) + model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + events.clear() + + model.clear() + + assertTrue(model.isEmpty()) + assertEquals(SessionState.Idle, model.state) + assertTrue(events.single() is SessionModelEvent.Cleared) + } + + fun `test listener auto removed on dispose`() { + val child = Disposer.newDisposable("child") + Disposer.register(parent, child) + + val extra = mutableListOf() + model.addListener(child) { extra.add(it) } + + model.addMessage(msg("m1", "user")) + assertEquals(1, extra.size) + + Disposer.dispose(child) + extra.clear() + + model.addMessage(msg("m2", "user")) + assertTrue(extra.isEmpty()) + } + + private fun msg(id: String, role: String) = MessageDto( + id = id, + sessionID = "ses", + role = role, + time = MessageTimeDto(created = 0.0), + ) + + private fun part( + id: String, + mid: String, + type: String, + text: String? = null, + tool: String? = null, + state: String? = null, + title: String? = null, + ) = PartDto( + id = id, + sessionID = "ses", + messageID = mid, + type = type, + text = text, + tool = tool, + state = state, + title = title, + ) + + private fun question(id: String) = Question( + id = id, + items = listOf( + QuestionItem( + question = "Which option?", + header = "Pick", + options = listOf(QuestionOption("A", "Option A"), QuestionOption("B", "Option B")), + multiple = false, + custom = true, + ), + ), + ) + + private fun permission(id: String) = Permission( + id = id, + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + ) + + private fun assertModel(expected: String) { + assertEquals(expected.trimIndent().trim(), model.toString().trim()) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt deleted file mode 100644 index 08d5845348b..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.ChatEventDto - -class StatusComputationTest : SessionModelTestBase() { - - fun `test status shows tool-specific text`() { - val (_, events) = prompted() - - emit(ChatEventDto.TurnOpen("ses_test")) - flush() - - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() - - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) - flush() - - val status = events.filterIsInstance() - .lastOrNull { it.text != null && it.text != KiloBundle.message("session.status.considering") } - assertNotNull(status) - assertEquals(KiloBundle.message("session.status.commands"), status!!.text) - } - - fun `test PartUpdated after TurnClose does not fire StatusChanged`() { - val (_, events) = prompted() - - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() - emit(ChatEventDto.TurnOpen("ses_test")) - flush() - emit(ChatEventDto.TurnClose("ses_test", "completed")) - flush() - - val before = events.filterIsInstance().size - - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) - flush() - - val after = events.filterIsInstance().size - assertEquals(before, after) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt deleted file mode 100644 index dff16b01c58..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.ChatEventDto -import ai.kilocode.rpc.dto.MessageErrorDto - -class TurnLifecycleTest : SessionModelTestBase() { - - fun `test TurnOpen fires BusyChanged true`() { - val (_, events) = prompted() - - emit(ChatEventDto.TurnOpen("ses_test")) - flush() - - assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy }) - assertTrue(events.any { it is SessionEvent.StatusChanged && it.text == KiloBundle.message("session.status.considering") }) - } - - fun `test TurnClose fires BusyChanged false and clears status`() { - val (_, events) = prompted() - - emit(ChatEventDto.TurnOpen("ses_test")) - flush() - emit(ChatEventDto.TurnClose("ses_test", "completed")) - flush() - - val last = events.filterIsInstance().last() - assertFalse(last.busy) - val status = events.filterIsInstance().last() - assertNull(status.text) - } - - fun `test Error fires Error event with message`() { - val (_, events) = prompted() - - emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) - flush() - - val err = events.filterIsInstance().firstOrNull() - assertNotNull(err) - assertEquals("Bad Request", err!!.message) - } - - fun `test Error with null message falls back to type`() { - val (_, events) = prompted() - - emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) - flush() - - val err = events.filterIsInstance().first() - assertEquals("timeout", err.message) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt deleted file mode 100644 index 996140dec5f..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ai.kilocode.client.session.model - -class ViewSwitchingTest : SessionModelTestBase() { - - fun `test first prompt shows messages view`() { - val m = model() - val events = collect(m) - - edt { m.prompt("hello") } - flush() - - assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) - } - - fun `test ViewChanged not fired twice`() { - val m = model() - val events = collect(m) - - edt { m.prompt("first") } - flush() - edt { m.prompt("second") } - flush() - - assertEquals(1, events.count { it is SessionEvent.ViewChanged && it.show }) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt deleted file mode 100644 index afb1f66c54f..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ai.kilocode.client.session.model - -class WorkspaceWatchingTest : SessionModelTestBase() { - - fun `test workspace ready populates agents and models`() { - val m = model() - val events = collect(m) - flush() - - projectRpc.state.value = workspaceReady() - flush() - - assertEquals(1, m.chat.agents.size) - assertEquals("code", m.chat.agents[0].name) - assertEquals(1, m.chat.models.size) - assertEquals("gpt-5", m.chat.models[0].id) - assertTrue(m.chat.ready) - assertTrue(events.any { it is SessionEvent.WorkspaceReady }) - } - - fun `test workspace ready sets default agent and model`() { - val m = model() - collect(m) - flush() - - projectRpc.state.value = workspaceReady() - flush() - - assertEquals("code", m.chat.agent) - assertEquals("gpt-5", m.chat.model) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 85b8c255488..573db3e2c12 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -4,7 +4,12 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -43,11 +48,21 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Push status updates here. */ val statuses = MutableStateFlow>(emptyMap()) + /** Pending permissions returned by [pendingPermissions]. */ + val pendingPermissionList = mutableListOf() + + /** Pending questions returned by [pendingQuestions]. */ + val pendingQuestionList = mutableListOf() + // --- Call tracking --- val prompts = mutableListOf>() val aborts = mutableListOf>() val configs = mutableListOf>() + val permissionReplies = mutableListOf>() + val permissionRulesSaved = mutableListOf>() + val questionReplies = mutableListOf>() + val questionRejects = mutableListOf>() var creates = 0 private set @@ -111,4 +126,34 @@ class FakeSessionRpcApi : KiloSessionRpcApi { assertNotEdt("updateConfig") configs.add(directory to config) } + + override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { + assertNotEdt("replyPermission") + permissionReplies.add(Triple(requestId, directory, reply)) + } + + override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) { + assertNotEdt("savePermissionRules") + permissionRulesSaved.add(Triple(requestId, directory, rules)) + } + + override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) { + assertNotEdt("replyQuestion") + questionReplies.add(Triple(requestId, directory, answers)) + } + + override suspend fun rejectQuestion(requestId: String, directory: String) { + assertNotEdt("rejectQuestion") + questionRejects.add(requestId to directory) + } + + override suspend fun pendingPermissions(directory: String): List { + assertNotEdt("pendingPermissions") + return pendingPermissionList.toList() + } + + override suspend fun pendingQuestions(directory: String): List { + assertNotEdt("pendingQuestions") + return pendingQuestionList.toList() + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt new file mode 100644 index 00000000000..4fa8ff2d04b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -0,0 +1,401 @@ +package ai.kilocode.client.ui.md + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Font + +/** + * Tests for [MdView] created via [MdView.html]. + * + * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that + * JBHtmlPane initialisation works correctly. + */ +@Suppress("UnstableApiUsage") +class MdViewTest : BasePlatformTestCase() { + + private lateinit var view: MdView + + override fun setUp() { + super.setUp() + view = MdView.html() + } + + // ---- set ---- + + fun `test set stores source`() { + view.set("hello **world**") + assertEquals("hello **world**", view.markdown()) + } + + fun `test set replaces previous content`() { + view.set("first") + view.set("second") + assertEquals("second", view.markdown()) + } + + fun `test set renders bold`() { + view.set("hello **world**") + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("world")) + } + + fun `test set renders italic`() { + view.set("hello *world*") + assertTrue(view.html().contains("")) + } + + fun `test set renders inline code`() { + view.set("use `foo()` here") + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("foo()")) + } + + fun `test set renders fenced code block`() { + view.set("```kotlin\nval x = 1\n```") + assertTrue(view.html().contains("
"))
+        assertTrue(view.html().contains(""))
+    }
+
+    fun `test set renders unordered list`() {
+        view.set("- one\n- two\n- three")
+        assertTrue(view.html().contains("
    ")) + assertTrue(view.html().contains("
  • ")) + } + + fun `test set renders ordered list`() { + view.set("1. one\n2. two\n3. three") + assertTrue(view.html().contains("
      ")) + } + + fun `test set renders blockquote`() { + view.set("> quoted text") + assertTrue(view.html().contains("
      ")) + } + + fun `test set renders strikethrough`() { + view.set("~~deleted~~") + assertTrue(view.html().contains("")) + } + + fun `test set renders table`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("
      ")) + assertTrue(view.html().contains("")) + } + + fun `test set renders autolink`() { + view.set("Visit https://example.com for details") + assertTrue(view.html().contains("")) + } + + fun `test append after set extends content`() { + view.set("first ") + view.append("second") + assertEquals("first second", view.markdown()) + } + + // ---- clear ---- + + fun `test clear resets source`() { + view.set("some content") + view.clear() + assertEquals("", view.markdown()) + } + + fun `test clear resets rendered html`() { + view.set("some content") + view.clear() + assertFalse(view.html().contains("some content")) + } + + // ---- link listener ---- + + fun `test link listener receives event on activation`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + view.set("[click](https://example.com)") + view.simulateLink("https://example.com") + assertEquals(1, received.size) + assertEquals("https://example.com", received[0].href) + } + + fun `test multiple link listeners all receive event`() { + val first = mutableListOf() + val second = mutableListOf() + view.addLinkListener { first.add(it) } + view.addLinkListener { second.add(it) } + view.simulateLink("https://a.com") + assertEquals(1, first.size) + assertEquals(1, second.size) + } + + fun `test removed listener does not receive events`() { + val received = mutableListOf() + val listener = MdView.LinkListener { received.add(it) } + view.addLinkListener(listener) + view.removeLinkListener(listener) + view.simulateLink("https://example.com") + assertTrue(received.isEmpty()) + } + + // ---- component ---- + + fun `test component is not null`() { + assertNotNull(view.component) + } + + // ---- complex markdown ---- + + fun `test complex markdown with mixed elements`() { + val md = """ + # Heading + + Some **bold** and *italic* text with `code`. + + - item one + - item two + + ``` + code block + ``` + + > blockquote + + [link](https://example.com) + """.trimIndent() + + view.set(md) + val html = view.html() + assertTrue(html.contains("

      ")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("
        ")) + assertTrue(html.contains("
        "))
        +        assertTrue(html.contains("
        ")) + assertTrue(html.contains("")) + assertTrue(view.html().contains("Done.")) + } + + // ---- style overrides (empty by default) ---- + + fun `test no overrides produces empty override sheet`() { + assertEquals("", view.overrideSheet()) + } + + // ---- style overrides appear in override sheet when set ---- + + fun `test foreground override appears in override sheet`() { + view.foreground = Color(0xAA, 0xBB, 0xCC) + view.set("text") + assertTrue(view.overrideSheet().contains("#aabbcc")) + } + + fun `test link color override appears in override sheet`() { + view.linkColor = Color(0xFF, 0x00, 0x77) + view.set("[a](https://x.com)") + assertTrue(view.overrideSheet().contains("#ff0077")) + } + + fun `test code bg override appears in override sheet`() { + view.codeBg = Color(0x10, 0x20, 0x30) + view.set("`code`") + assertTrue(view.overrideSheet().contains("#102030")) + } + + fun `test pre bg and fg overrides appear in override sheet`() { + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.preFg = Color(0xD0, 0xE0, 0xF0) + view.set("```\ncode\n```") + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#0a0b0c")) + assertTrue(sheet.contains("#d0e0f0")) + } + + fun `test code font override appears in override sheet`() { + view.codeFont = "Fira Code" + view.set("`x`") + assertTrue(view.overrideSheet().contains("Fira Code")) + } + + fun `test blockquote color overrides appear in override sheet`() { + view.quoteBorder = Color(0xAA, 0x00, 0x00) + view.quoteFg = Color(0x00, 0xBB, 0x00) + view.set("> quote") + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#aa0000")) + assertTrue(sheet.contains("#00bb00")) + } + + fun `test table border override appears in override sheet`() { + view.tableBorder = Color(0x12, 0x34, 0x56) + view.set("| a |\n|---|\n| 1 |") + assertTrue(view.overrideSheet().contains("#123456")) + } + + fun `test font family override appears in override sheet`() { + view.font = Font("Courier New", Font.PLAIN, 14) + view.set("text") + assertTrue(view.overrideSheet().contains("Courier New")) + } + + fun `test font size override appears in override sheet`() { + view.font = Font("Arial", Font.PLAIN, 18) + view.set("text") + assertTrue(view.overrideSheet().contains("18pt")) + } + + fun `test style change re-renders and override sheet reflects change`() { + view.set("hello") + view.foreground = Color(0xDE, 0xAD, 0x00) + assertTrue(view.overrideSheet().contains("#dead00")) + assertTrue(view.html().contains("hello")) + } + + fun `test style change without content does not crash`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeFont = "Monospaced" + assertEquals("", view.markdown()) + } + + // ---- default codeFont uses editor font placeholder ---- + + fun `test default codeFont is editor font placeholder`() { + // When no codeFont override is set, the getter returns the editor font placeholder + assertTrue(view.codeFont.contains("_Editor")) + } + + fun `test default override sheet is empty before any set`() { + // Only overrides appear in the sheet; editor defaults are handled by JBHtmlPane + assertEquals("", view.overrideSheet()) + } + + // ---- background sets component background ---- + + fun `test background override sets component background`() { + view.background = Color(0x11, 0x22, 0x33) + assertEquals(Color(0x11, 0x22, 0x33), view.component.background) + } + + fun `test background override does not appear in override sheet`() { + // background is applied to the Swing component, not via CSS override rule + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertFalse(view.overrideSheet().contains("#112233")) + } + + // ---- opaque / transparent ---- + + fun `test opaque true sets component opaque`() { + view.opaque = true + assertTrue(view.component.isOpaque) + } + + fun `test opaque false sets component non-opaque`() { + view.opaque = false + assertFalse(view.component.isOpaque) + } + + fun `test opaque false adds transparent background to override sheet`() { + view.opaque = false + view.set("text") + assertTrue(view.overrideSheet().contains("transparent")) + } + + fun `test opaque true does not add transparent rule`() { + view.opaque = true + view.set("text") + assertFalse(view.overrideSheet().contains("transparent")) + } + + fun `test opaque false does not affect pre background override`() { + view.opaque = false + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.set("```\ncode\n```") + assertTrue(view.overrideSheet().contains("#0a0b0c")) + } + + fun `test background override applied to component when opaque is true`() { + view.background = Color(0xFE, 0xFE, 0xFE) + view.opaque = true + assertEquals(Color(0xFE, 0xFE, 0xFE), view.component.background) + } + + fun `test opaque toggle updates component opacity`() { + view.opaque = false + assertFalse(view.component.isOpaque) + view.opaque = true + assertTrue(view.component.isOpaque) + } + + // ---- resetStyles ---- + + fun `test resetStyles clears foreground override`() { + view.foreground = Color.RED + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles clears all overrides`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeBg = Color.GREEN + view.preBg = Color.ORANGE + view.preFg = Color.CYAN + view.codeFont = "Monospaced" + view.quoteBorder = Color.PINK + view.quoteFg = Color.MAGENTA + view.tableBorder = Color.YELLOW + view.font = Font("Arial", Font.PLAIN, 18) + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles restores opaque to true`() { + view.opaque = false + view.resetStyles() + assertTrue(view.component.isOpaque) + } + + fun `test resetStyles after set still renders content`() { + view.set("hello **world**") + view.foreground = Color.RED + view.resetStyles() + assertTrue(view.html().contains("")) + } +} diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 9d81facbccf..637d84c3c5e 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -1,15 +1,20 @@ [versions] -intellij-platform = "2025.3" +intellij-platform = "2026.1" intellij-gradle-plugin = "2.14.0" -intellij-rpc-plugin = "2.3.20-0.1" +intellij-rpc-plugin = "2.3.20-RC2-0.1" kotlin-jvm-plugin = "2.3.20" kotlin-serialization-plugin = "2.3.20" kotlin-serialization = "1.11.0" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" +commonmark = "0.28.0" [libraries] +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } +commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index a482176d674..bffa2bf5be7 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -3,7 +3,12 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -66,4 +71,24 @@ interface KiloSessionRpcApi : RemoteApi { /** Update config (model, agent/mode, temperature). */ suspend fun updateConfig(directory: String, config: ConfigUpdateDto) + + // ------ permission / question resolution ------ + + /** Reply to a pending permission request (once, always, or reject). */ + suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) + + /** Save always-rules for a pending permission request before replying. */ + suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) + + /** Reply to a pending question with user answers. */ + suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) + + /** Reject a pending question. */ + suspend fun rejectQuestion(requestId: String, directory: String) + + /** List all pending permission requests (caller filters by session). */ + suspend fun pendingPermissions(directory: String): List + + /** List all pending question requests (caller filters by session). */ + suspend fun pendingQuestions(directory: String): List } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 06ead59e46d..3fe3d313faf 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -47,7 +47,7 @@ data class MessageWithPartsDto( val parts: List, ) -// --- Parts (simplified for basic chat) --- +// --- Parts --- @Serializable data class PartDto( @@ -57,6 +57,7 @@ data class PartDto( val type: String, val text: String? = null, val tool: String? = null, + val callID: String? = null, val state: String? = null, val title: String? = null, ) @@ -90,14 +91,14 @@ sealed class ChatEventDto { ) : ChatEventDto() @Serializable - @SerialName("part.updated") + @SerialName("message.part.updated") data class PartUpdated( val sessionID: String, val part: PartDto, ) : ChatEventDto() @Serializable - @SerialName("part.delta") + @SerialName("message.part.delta") data class PartDelta( val sessionID: String, val messageID: String, @@ -107,20 +108,28 @@ sealed class ChatEventDto { ) : ChatEventDto() @Serializable - @SerialName("turn.open") + @SerialName("message.part.removed") + data class PartRemoved( + val sessionID: String, + val messageID: String, + val partID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.turn.open") data class TurnOpen( val sessionID: String, ) : ChatEventDto() @Serializable - @SerialName("turn.close") + @SerialName("session.turn.close") data class TurnClose( val sessionID: String, val reason: String, ) : ChatEventDto() @Serializable - @SerialName("error") + @SerialName("session.error") data class Error( val sessionID: String?, val error: MessageErrorDto? = null, @@ -132,8 +141,156 @@ sealed class ChatEventDto { val sessionID: String, val messageID: String, ) : ChatEventDto() + + @Serializable + @SerialName("permission.asked") + data class PermissionAsked( + val sessionID: String, + val request: PermissionRequestDto, + ) : ChatEventDto() + + @Serializable + @SerialName("permission.replied") + data class PermissionReplied( + val sessionID: String, + val requestID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("question.asked") + data class QuestionAsked( + val sessionID: String, + val request: QuestionRequestDto, + ) : ChatEventDto() + + @Serializable + @SerialName("question.replied") + data class QuestionReplied( + val sessionID: String, + val requestID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("question.rejected") + data class QuestionRejected( + val sessionID: String, + val requestID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.status") + data class SessionStatusChanged( + val sessionID: String, + val status: SessionStatusDto, + ) : ChatEventDto() + + @Serializable + @SerialName("session.idle") + data class SessionIdle( + val sessionID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.compacted") + data class SessionCompacted( + val sessionID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.diff") + data class SessionDiffChanged( + val sessionID: String, + val diff: List = emptyList(), + ) : ChatEventDto() + + @Serializable + @SerialName("todo.updated") + data class TodoUpdated( + val sessionID: String, + val todos: List = emptyList(), + ) : ChatEventDto() } +// --- Permission DTOs --- + +@Serializable +data class PermissionRequestDto( + val id: String, + val sessionID: String, + val permission: String, + val patterns: List, + val metadata: Map = emptyMap(), + val always: List = emptyList(), + val tool: ToolRefDto? = null, +) + +@Serializable +data class ToolRefDto( + val messageID: String, + val callID: String, +) + +@Serializable +data class PermissionReplyDto( + val reply: String, + val message: String? = null, +) + +@Serializable +data class PermissionAlwaysRulesDto( + val approvedAlways: List = emptyList(), + val deniedAlways: List = emptyList(), +) + +// --- Question DTOs --- + +@Serializable +data class QuestionRequestDto( + val id: String, + val sessionID: String, + val questions: List, + val tool: ToolRefDto? = null, +) + +@Serializable +data class QuestionInfoDto( + val question: String, + val header: String, + val options: List = emptyList(), + val multiple: Boolean = false, + val custom: Boolean = true, +) + +@Serializable +data class QuestionOptionDto( + val label: String, + val description: String, +) + +@Serializable +data class QuestionReplyDto( + val answers: List>, +) + +// --- Todo DTO --- + +@Serializable +data class TodoDto( + val content: String, + val status: String, + val priority: String, +) + +// --- Diff DTO --- + +@Serializable +data class DiffFileDto( + val file: String, + val additions: Int, + val deletions: Int, + val patch: String? = null, +) + // --- Config Update --- @Serializable diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt index 47958e66bce..4c7c2c25307 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt @@ -32,6 +32,9 @@ data class SessionSummaryDto( data class SessionStatusDto( val type: String, val message: String? = null, + val attempt: Int? = null, + val next: Long? = null, + val requestID: String? = null, ) @Serializable diff --git a/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts new file mode 100644 index 00000000000..638ce3ae2c4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { dict as ar } from "@kilocode/kilo-i18n/ar" +import { dict as br } from "@kilocode/kilo-i18n/br" +import { dict as bs } from "@kilocode/kilo-i18n/bs" +import { dict as da } from "@kilocode/kilo-i18n/da" +import { dict as de } from "@kilocode/kilo-i18n/de" +import { dict as en } from "@kilocode/kilo-i18n/en" +import { dict as es } from "@kilocode/kilo-i18n/es" +import { dict as fr } from "@kilocode/kilo-i18n/fr" +import { dict as ja } from "@kilocode/kilo-i18n/ja" +import { dict as ko } from "@kilocode/kilo-i18n/ko" +import { dict as nl } from "@kilocode/kilo-i18n/nl" +import { dict as no } from "@kilocode/kilo-i18n/no" +import { dict as pl } from "@kilocode/kilo-i18n/pl" +import { dict as ru } from "@kilocode/kilo-i18n/ru" +import { dict as th } from "@kilocode/kilo-i18n/th" +import { dict as tr } from "@kilocode/kilo-i18n/tr" +import { dict as uk } from "@kilocode/kilo-i18n/uk" +import { dict as zh } from "@kilocode/kilo-i18n/zh" +import { dict as zht } from "@kilocode/kilo-i18n/zht" + +const dicts: Record> = { + ar, + br, + bs, + da, + de, + en, + es, + fr, + ja, + ko, + nl, + no, + pl, + ru, + th, + tr, + uk, + zh, + zht, +} + +const keys = [ + "plan.followup.header", + "plan.followup.question", + "plan.followup.answer.newSession", + "plan.followup.answer.newSession.description", + "plan.followup.answer.continue", + "plan.followup.answer.continue.description", +] + +describe("plan follow-up i18n keys", () => { + for (const locale of Object.keys(dicts)) { + test(`${locale} defines every plan.followup.* key`, () => { + const d = dicts[locale]! + for (const key of keys) { + const value = d[key] + expect(value, `${locale} is missing ${key}`).toBeDefined() + expect(value, `${locale} has empty value for ${key}`).toBeTruthy() + } + }) + } +}) diff --git a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts index e2300c20031..44f536c2e11 100644 --- a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts @@ -5,6 +5,7 @@ import { resolveQuestionMode, resolveSelectedQuestionMode, toggleAnswer, + tr, } from "../../webview-ui/src/components/chat/question-dock-utils" describe("toggleAnswer", () => { @@ -151,3 +152,32 @@ describe("pickOutcome", () => { expect(pickOutcome({ single: false, multi: true, custom: true })).toEqual({ kind: "stay" }) }) }) + +describe("tr", () => { + it("returns the translated value when the key is present", () => { + const dict: Record = { "plan.followup.answer.continue": "Continuer ici" } + const t = (key: string) => dict[key] ?? key + expect(tr(t, "plan.followup.answer.continue", "Continue here")).toBe("Continuer ici") + }) + + it("returns the fallback when key is undefined", () => { + const t = (key: string) => key + expect(tr(t, undefined, "Continue here")).toBe("Continue here") + }) + + it("returns the fallback when the key is missing from the dict (language.t echoes the key)", () => { + const t = (key: string) => key + expect(tr(t, "plan.followup.answer.continue", "Continue here")).toBe("Continue here") + }) + + it("returns an empty string fallback when no key and no label are available", () => { + const t = (key: string) => key + expect(tr(t, undefined, "")).toBe("") + }) + + it("returns the translated value even when the fallback is empty", () => { + const dict: Record = { "plan.followup.question": "Prêt à implémenter ?" } + const t = (key: string) => dict[key] ?? key + expect(tr(t, "plan.followup.question", "")).toBe("Prêt à implémenter ?") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 67edc29e642..c9771f19465 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -17,6 +17,7 @@ import { resolveOptimisticQuestionAgent, resolveSelectedQuestionMode, toggleAnswer, + tr, } from "./question-dock-utils" export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { @@ -69,6 +70,25 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => return language.t("question.summary", { n, total: total() }) }) + // Localized view of the current question. The wire-format `label` is preserved for reply + // matching; only the display text goes through `tr()`. + // + // translateOption returns accessor functions, not plain strings. SolidJS runs the + // child callback in an untracked scope (via mapArray), so reading `language.t(...)` once + // at construction would freeze translations at the first render. Accessors force every + // JSX read to happen inside the binding's tracking scope, so switching the sidebar + // language while a question dock is visible re-renders the option labels. + const questionText = createMemo(() => tr(language.t, question()?.questionKey, question()?.question ?? "")) + const translateOption = (opt: { + label: string + description?: string + labelKey?: string + descriptionKey?: string + }) => ({ + label: () => tr(language.t, opt.labelKey, opt.label), + description: () => (opt.description ? tr(language.t, opt.descriptionKey, opt.description) : ""), + }) + const focusPrompt = () => requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt"))) const reply = (answers: string[][]) => { @@ -128,6 +148,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => if (outcome.kind === "submit") { // Mirror TUI behaviour: a single-question single-select option pick submits immediately. // handleCustomSubmit covers the custom-input path via its own submit() call. + // + // NOTE: This auto-submit applies to every single-question single-select prompt, not only + // the plan follow-up. If a future caller needs the user to review before submitting, set + // `multiple: true` on the question (that path stays on the current tab and waits for the + // footer Submit button). reply([[answer]]) return } @@ -285,7 +310,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
        {summary()}
        -
        {question()?.question}
        +
        {questionText()}
        e.stopPropagation()}> @@ -328,7 +353,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
        -
        {question()?.question}
        +
        {questionText()}
        {language.t("ui.question.singleHint")}
        }>
        {language.t("ui.question.multiHint")}
        @@ -336,6 +361,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {(opt, i) => { const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + const localized = translateOption(opt) return ( @@ -439,7 +465,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const answered = () => Boolean(value()) return (
        - {q.question} + {tr(language.t, q.questionKey, q.question)} {answered() ? value() : language.t("ui.question.review.notAnswered")} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts index 62395ef0099..9e7b2a0f8e5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts @@ -1,5 +1,25 @@ import type { QuestionOption } from "../../types/messages" +/** + * Translate a backend-provided i18n key, falling back to the canonical label when no key is + * set or the key is missing from the dictionary. + * + * Implementation detail: our `language.t` (see webview-ui/src/context/language.tsx) returns + * the key string back when a translation is missing. We detect that "echo" and substitute + * the fallback. If `language.t` ever changes to return `undefined` / empty / throw on miss, + * this helper's fallback branch must be updated to match. + * + * Edge case: if a translator ever writes a value identical to the dotted key string, this + * helper would falsely fall back to the English label. Namespaced keys (`plan.followup.*`) + * make this practically impossible, but keep it in mind before picking ambiguous key names. + */ +export function tr(translate: (key: string) => string, key: string | undefined, fallback: string): string { + if (!key) return fallback + const result = translate(key) + if (result === key) return fallback + return result +} + export type PickOutcome = { kind: "submit" } | { kind: "advance" } | { kind: "stay" } /** diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 16c2cb03f96..7b280698c70 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -206,6 +206,10 @@ export interface QuestionOption { label: string description: string mode?: string + // Optional i18n keys — the backend fills these for strings it wants translated in the webview. + // The canonical English `label` stays on the reply wire, so server-side matching is unaffected. + labelKey?: string + descriptionKey?: string } export interface QuestionInfo { @@ -214,6 +218,9 @@ export interface QuestionInfo { options: QuestionOption[] multiple?: boolean custom?: boolean + // Optional i18n keys for question text and header (see QuestionOption for details). + questionKey?: string + headerKey?: string } export interface QuestionRequest { diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 7cc1fc60834..4f1d20fc9bd 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -265,7 +265,9 @@ export namespace PlanFollowup { questions: [ { question: "Ready to implement?", + questionKey: "plan.followup.question", header: "Implement", + headerKey: "plan.followup.header", // On CLI the main prompt input is hidden while a blocking question is active, // so we need the custom-answer row to allow a free-text reply. On VS Code the // main prompt input below the dock already routes typed text as a question @@ -275,11 +277,15 @@ export namespace PlanFollowup { options: [ { label: ANSWER_NEW_SESSION, + labelKey: "plan.followup.answer.newSession", description: "Implement in a fresh session with a clean context", + descriptionKey: "plan.followup.answer.newSession.description", }, { label: ANSWER_CONTINUE, + labelKey: "plan.followup.answer.continue", description: "Implement the plan in this session", + descriptionKey: "plan.followup.answer.continue.description", }, ], }, diff --git a/packages/opencode/src/kilocode/provider-options.ts b/packages/opencode/src/kilocode/provider-options.ts index 697e5b1f50a..4d91cb0f78e 100644 --- a/packages/opencode/src/kilocode/provider-options.ts +++ b/packages/opencode/src/kilocode/provider-options.ts @@ -1,3 +1,4 @@ +import type { AlibabaProviderOptions } from "@ai-sdk/alibaba" import type { AnthropicProviderOptions } from "@ai-sdk/anthropic" import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai" import type { OpenAICompatibleProviderOptions } from "@ai-sdk/openai-compatible" @@ -25,5 +26,8 @@ export function kiloProviderOptions(options: { [x: string]: any }) { openrouter.reasoning && "effort" in openrouter.reasoning ? openrouter.reasoning?.effort : undefined, textVerbosity: openrouter.verbosity, } satisfies OpenAICompatibleProviderOptions + result.alibaba = { + enableThinking: openrouter.reasoning?.enabled, + } satisfies AlibabaProviderOptions return result } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 7bf93ce851e..6a1e5e246fe 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -21,6 +21,15 @@ export namespace Question { description: Schema.String.annotate({ description: "Explanation of choice", }), + // kilocode_change start - optional i18n keys so clients can translate while still + // replying with the canonical English label (backend matches on `label`). + labelKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the label; clients translate and still reply with `label`", + }), + descriptionKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the description", + }), + // kilocode_change end }) { static readonly zod = zod(this) } @@ -38,6 +47,14 @@ export namespace Question { multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices", }), + // kilocode_change start - optional i18n keys for question text and header + questionKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the question text; clients fall back to `question` when missing", + }), + headerKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the header; clients fall back to `header` when missing", + }), + // kilocode_change end } export class Info extends Schema.Class("QuestionInfo")({ diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 27ff3834353..405c671e9d2 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -325,6 +325,44 @@ describe("plan follow-up", () => { } })) + test("ask - emits i18n keys alongside the canonical English labels", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Build" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const item = await waitQuestion(seeded.sessionID) + expect(item).toBeDefined() + if (!item) return + const q = item.questions[0] + expect(q).toBeDefined() + if (!q) return + + // i18n keys for question-level strings + expect(q.questionKey).toBe("plan.followup.question") + expect(q.headerKey).toBe("plan.followup.header") + + // i18n keys for option labels — order matters: newSession is first, continue second. + expect(q.options.map((o) => o.labelKey)).toEqual([ + "plan.followup.answer.newSession", + "plan.followup.answer.continue", + ]) + expect(q.options.map((o) => o.descriptionKey)).toEqual([ + "plan.followup.answer.newSession.description", + "plan.followup.answer.continue.description", + ]) + + // Canonical English labels stay on the wire — the server still matches on `label`, + // so translating the UI must not change the reply format. + expect(q.options.map((o) => o.label)).toEqual([PlanFollowup.ANSWER_NEW_SESSION, PlanFollowup.ANSWER_CONTINUE]) + + await question.reject(item.id) + await expect(pending).resolves.toBe("break") + })) + test("ask - returns continue and creates code message on Continue here", () => withInstance(async () => { const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2e495520a5c..eb1aef210c9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -369,6 +369,14 @@ export type QuestionOption = { * 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 = { @@ -388,6 +396,14 @@ export type QuestionInfo = { * 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) */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 85808234fca..155cdbd407d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11429,6 +11429,14 @@ "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"] @@ -11455,6 +11463,14 @@ "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"