From 2daebbd1cb1a35cc474abccbe4ec2aa15da0ca9c Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 14 Apr 2026 15:41:13 -0400 Subject: [PATCH] feat(jetbrains): implement basic agent chat and refactor backend into cli/ package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add basic chat functionality: session creation, mode/model/temperature config, prompt sending with streaming SSE responses. Includes shared DTOs, RPC interface extensions, backend chat manager, frontend service, and Swing-based chat UI (message list, input panel, toolbar). Refactor backend by creating cli/ package for CLI infrastructure (CliServer, KiloBackendCliManager, KiloBackendHttpClients) and introducing KiloCliDataParser — a stateless object that centralizes all CLI response parsing. Callers pass raw JSON, get typed DTOs back. Includes 30 test cases for the parser. --- .kilo/plans/1776187162542-shiny-falcon.md | 539 ++++++++++++++++++ .../backend/app/KiloBackendAppService.kt | 24 +- .../backend/app/KiloBackendChatManager.kt | 191 +++++++ .../app/KiloBackendConnectionService.kt | 18 +- .../backend/app/KiloBackendSessionManager.kt | 92 ++- .../backend/{app => cli}/CliServer.kt | 2 +- .../{app => cli}/KiloBackendCliManager.kt | 60 +- .../{util => cli}/KiloBackendHttpClients.kt | 2 +- .../kilocode/backend/cli/KiloCliDataParser.kt | 341 +++++++++++ .../backend/rpc/KiloSessionRpcApiImpl.kt | 52 +- .../backend/KiloBackendHttpClientsTest.kt | 2 +- .../backend/KiloBackendSessionManagerTest.kt | 2 +- .../kilocode/backend/KiloCliDataParserTest.kt | 475 +++++++++++++++ .../backend/KiloConnectionServiceTest.kt | 15 +- .../kilocode/backend/testing/FakeCliServer.kt | 2 +- .../ai/kilocode/client/KiloSessionService.kt | 122 +++- .../kilocode/client/KiloToolWindowFactory.kt | 36 +- .../ai/kilocode/client/chat/ChatInputPanel.kt | 75 +++ .../ai/kilocode/client/chat/ChatPanel.kt | 191 +++++++ .../ai/kilocode/client/chat/ChatToolbar.kt | 106 ++++ .../kilocode/client/chat/MessageListPanel.kt | 137 +++++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 21 + .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 144 +++++ 23 files changed, 2502 insertions(+), 147 deletions(-) create mode 100644 .kilo/plans/1776187162542-shiny-falcon.md create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt rename packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/{app => cli}/CliServer.kt (94%) rename packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/{app => cli}/KiloBackendCliManager.kt (85%) rename packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/{util => cli}/KiloBackendHttpClients.kt (98%) create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloCliDataParserTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatInputPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatToolbar.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/MessageListPanel.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt diff --git a/.kilo/plans/1776187162542-shiny-falcon.md b/.kilo/plans/1776187162542-shiny-falcon.md new file mode 100644 index 0000000000..7040986d42 --- /dev/null +++ b/.kilo/plans/1776187162542-shiny-falcon.md @@ -0,0 +1,539 @@ +# Basic Agent Chat for JetBrains Plugin + +## Goal + +Implement basic agent chat functionality: create sessions, change mode/model/temperature, send prompts and receive streaming responses. Tested in Ask mode, no permission handling. + +--- + +# Backend Refactor: `backend/cli` Package + `KiloCliDataParser` + +## Goal + +1. Create `backend/cli/` package and move CLI-related infrastructure there +2. Centralize all CLI response parsing into a single `KiloCliDataParser` class — callers pass raw JSON, get typed DTOs back, no JSON knowledge leaks outside the parser +3. Make the parser extensively testable so every new parsing issue gets a test case + +## What Moves + +### To `backend/cli/` (new package) + +| File | Current Location | Notes | +| --------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CliServer.kt` | `backend/app/` | Interface — no changes beyond package | +| `KiloBackendCliManager.kt` | `backend/app/` | Implementation — no changes beyond package | +| `KiloBackendHttpClients.kt` | `backend/util/` | HTTP client factory — belongs with CLI infra | +| `KiloCliDataParser.kt` | **new** | Central parser (see below) | +| `SseEvent.kt` | extract from `KiloBackendConnectionService.kt` | The `data class SseEvent` and `ConnectionState` sealed class stay in `app/` since they're connection-level, but `SseEvent` is also fine to move since it's CLI-level data | + +### Stays in `backend/app/` + +| File | Reason | +| --------------------------------- | ----------------------------------------------------------------- | +| `KiloBackendAppService.kt` | App lifecycle orchestrator — uses generated API client + parser | +| `KiloBackendConnectionService.kt` | SSE/connection management — uses parser for `extractType` | +| `KiloBackendChatManager.kt` | Chat orchestration — uses parser for SSE events + message history | +| `KiloBackendSessionManager.kt` | Session CRUD — uses parser for session creation + status events | +| `KiloAppState.kt` | State sealed class | + +## `KiloCliDataParser` Design + +A stateless object (no dependencies, no coroutines, no services) that owns ALL JSON-to-DTO conversion from CLI server responses. Every public method takes raw data in, returns a typed DTO out. + +```kotlin +package ai.kilocode.backend.cli + +object KiloCliDataParser { + + // ------ SSE event parsing ------ + + /** Extract the event type from raw SSE JSON data (fallback when OkHttp type is null). */ + fun extractEventType(data: String): String + + /** Parse an SSE chat event into a ChatEventDto. Returns null if unrecognized/malformed. */ + fun parseChatEvent(type: String, data: String): ChatEventDto? + + /** Extract session status from an SSE session.status event. Returns (sessionID, StatusDto)? */ + fun parseSessionStatus(data: String): Pair? + + // ------ HTTP response parsing ------ + + /** Parse a session creation response (POST /session) into SessionDto. */ + fun parseSession(raw: String): SessionDto + + /** Parse message history response (GET /session/{id}/message) into messages+parts. */ + fun parseMessages(raw: String): List + + // ------ JSON serialization (DTO → JSON for outgoing requests) ------ + + /** Build the JSON body for POST /session/{id}/prompt_async. */ + fun buildPromptJson(prompt: PromptDto): String + + /** Build the partial JSON body for PATCH /global/config. */ + fun buildConfigPartial(update: ConfigUpdateDto): String +} +``` + +### What gets consolidated + +All these scattered parsing helpers merge into the parser: + +| Current Location | Current Function | Destination | +| ------------------------------ | ------------------------------------ | ------------------------------------------- | +| `KiloBackendChatManager` | `parse(SseEvent)` | `parseChatEvent(type, data)` | +| `KiloBackendChatManager` | `parseMessages(raw)` | `parseMessages(raw)` | +| `KiloBackendChatManager` | `parseMessage(obj)` | private `parseMessage(obj)` | +| `KiloBackendChatManager` | `parsePart(obj)` | private `parsePart(obj)` | +| `KiloBackendChatManager` | `parseError(obj)` | private `parseError(obj)` | +| `KiloBackendChatManager` | `buildPromptJson(prompt)` | `buildPromptJson(prompt)` | +| `KiloBackendChatManager` | `buildConfigPartial(update)` | `buildConfigPartial(update)` | +| `KiloBackendChatManager` | `JsonObject.str/num/long` extensions | private extensions inside parser | +| `KiloBackendChatManager` | `jsonString(value)` | private `jsonString(value)` | +| `KiloBackendSessionManager` | `extractField(json, field)` | private `extractField(json, field)` | +| `KiloBackendSessionManager` | `extractNested(json, outer, inner)` | private `extractNested(json, outer, inner)` | +| `KiloBackendSessionManager` | `dtoFromJson(obj)` | `parseSession(raw)` | +| `KiloBackendSessionManager` | `handleStatus(data)` | `parseSessionStatus(data)` | +| `KiloBackendConnectionService` | `extractType(data)` | `extractEventType(data)` | + +### What callers look like after refactor + +**KiloBackendChatManager** (before): + +```kotlin +private fun parse(event: SseEvent): ChatEventDto? { + val obj = json.parseToJsonElement(event.data).jsonObject + val payload = obj["payload"]?.jsonObject ?: obj + val props = payload["properties"]?.jsonObject ?: return null + // ... 60 lines of when/JsonObject navigation +} +``` + +**KiloBackendChatManager** (after): + +```kotlin +// SSE watcher +sse.collect { event -> + if (event.type in CHAT_EVENTS) { + KiloCliDataParser.parseChatEvent(event.type, event.data)?.let { _events.emit(it) } + } +} + +// Message history +fun messages(id: String, dir: String): List { + // ... HTTP call ... + val raw = response.body?.string() ?: return emptyList() + return KiloCliDataParser.parseMessages(raw) +} + +// Prompt +val body = KiloCliDataParser.buildPromptJson(prompt) +``` + +**KiloBackendSessionManager** (after): + +```kotlin +fun create(dir: String): SessionDto { + // ... HTTP call ... + val raw = response.body?.string()!! + return KiloCliDataParser.parseSession(raw) +} + +// SSE status handling +private fun handleStatus(data: String) { + val (id, status) = KiloCliDataParser.parseSessionStatus(data) ?: return + _statuses.update { it + (id to status) } +} +``` + +**KiloBackendConnectionService** (after): + +```kotlin +override fun onEvent(src: EventSource, id: String?, type: String?, data: String) { + val kind = type ?: KiloCliDataParser.extractEventType(data) + cs.launch { _events.emit(SseEvent(type = kind, data = data)) } +} +``` + +## Test Strategy + +`KiloCliDataParserTest.kt` — pure unit tests with no mocks, no services, no coroutines. Just JSON in → DTO out. + +```kotlin +class KiloCliDataParserTest { + // SSE events + @Test fun `parseChatEvent - message updated`() + @Test fun `parseChatEvent - message part delta`() + @Test fun `parseChatEvent - message part updated`() + @Test fun `parseChatEvent - turn open`() + @Test fun `parseChatEvent - turn close`() + @Test fun `parseChatEvent - session error`() + @Test fun `parseChatEvent - message removed`() + @Test fun `parseChatEvent - unknown type returns null`() + @Test fun `parseChatEvent - malformed JSON returns null`() + @Test fun `parseChatEvent - missing properties returns null`() + @Test fun `parseChatEvent - GlobalEvent wrapper with payload`() + @Test fun `parseChatEvent - flat event without payload wrapper`() + + // Session status + @Test fun `parseSessionStatus - valid status event`() + @Test fun `parseSessionStatus - missing sessionID returns null`() + + // Session creation + @Test fun `parseSession - full session response`() + @Test fun `parseSession - minimal session response`() + + // Message history + @Test fun `parseMessages - empty array`() + @Test fun `parseMessages - user and assistant messages`() + @Test fun `parseMessages - message with text parts`() + @Test fun `parseMessages - message with tool parts`() + + // JSON builders + @Test fun `buildPromptJson - text only`() + @Test fun `buildPromptJson - with model override`() + @Test fun `buildPromptJson - with agent`() + @Test fun `buildConfigPartial - model only`() + @Test fun `buildConfigPartial - agent and temperature`() + + // Event type extraction + @Test fun `extractEventType - valid type`() + @Test fun `extractEventType - missing type returns unknown`() +} +``` + +Each test uses literal JSON strings as fixtures — easy to add a new test case when a parsing bug is discovered. + +## Execution Order + +1. Create `backend/cli/` package +2. Move `CliServer.kt` → `backend/cli/CliServer.kt` (update package) +3. Move `KiloBackendCliManager.kt` → `backend/cli/KiloBackendCliManager.kt` (update package + imports) +4. Move `KiloBackendHttpClients.kt` → `backend/cli/KiloBackendHttpClients.kt` (update package + imports) +5. Create `backend/cli/KiloCliDataParser.kt` — consolidate all parsing +6. Update `KiloBackendChatManager` — remove all parsing, delegate to `KiloCliDataParser` +7. Update `KiloBackendSessionManager` — remove `extractField/extractNested/dtoFromJson/handleStatus` parsing, delegate to `KiloCliDataParser` +8. Update `KiloBackendConnectionService` — remove `extractType`, delegate to `KiloCliDataParser` +9. Update all imports across backend (app service, workspace, rpc, tests) +10. Create `KiloCliDataParserTest.kt` with full test coverage +11. Update existing tests that reference moved classes +12. Verify build compiles + tests pass + +## Architecture Overview + +The flow mirrors the VS Code extension pattern: + +``` +Frontend (Swing UI) ←RPC→ Backend (services) ←HTTP/SSE→ CLI Backend (kilo serve) +``` + +Key difference from VS Code: JetBrains uses split-mode RPC instead of `postMessage`. SSE events already flow through `KiloBackendConnectionService.events: SharedFlow` — we just need to subscribe to chat-related events and forward them over RPC flows. + +## Data Flow + +### Send Message Flow + +``` +Frontend: KiloSessionService.prompt(sessionID, text, model?, agent?) + ↓ RPC +Backend: KiloBackendChatManager.prompt(sessionID, dir, parts, model?, agent?) + ↓ HTTP POST /session/{id}/prompt_async (fire-and-forget, 204) +Server: SessionPrompt.prompt() → AI runtime → Bus events + ↓ SSE via GET /global/event +Backend: SharedFlow → KiloBackendChatManager parses & emits + ↓ RPC Flow +Frontend: Collects Flow → updates UI +``` + +### Config Update Flow (mode/model/temperature) + +``` +Frontend: KiloSessionService.updateConfig(config) + ↓ RPC +Backend: KiloBackendChatManager.updateConfig(dir, config) + ↓ HTTP PATCH /config +Server: Updates config → emits global.config.updated SSE + ↓ Already handled in KiloBackendAppService +Backend: Config reloaded automatically +``` + +## Implementation Plan + +### Phase 1: Shared DTOs (in `shared/src/main/kotlin/ai/kilocode/rpc/dto/`) + +#### 1.1 `ChatDto.kt` — Message & Part DTOs for RPC transport + +These are simplified DTOs for the basic chat use case. We don't model the full Part union — just the types we need for a basic Ask-mode chat. + +```kotlin +// --- Messages --- + +@Serializable +data class MessageDto( + val id: String, + val sessionID: String, + val role: String, // "user" | "assistant" + val time: MessageTimeDto, + val agent: String? = null, + val providerID: String? = null, + val modelID: String? = null, + val parentID: String? = null, // assistant only + val cost: Double? = null, // assistant only + val tokens: TokensDto? = null, // assistant only + val error: MessageErrorDto? = null, +) + +@Serializable +data class MessageTimeDto( + val created: Double, + val completed: Double? = null, +) + +@Serializable +data class TokensDto( + val input: Long, + val output: Long, + val reasoning: Long, + val cacheRead: Long, + val cacheWrite: Long, +) + +@Serializable +data class MessageErrorDto( + val type: String, // "provider_auth", "api", "unknown", etc. + val message: String? = null, +) + +@Serializable +data class MessageWithPartsDto( + val info: MessageDto, + val parts: List, +) + +// --- Parts (simplified for basic chat) --- + +@Serializable +data class PartDto( + val id: String, + val sessionID: String, + val messageID: String, + val type: String, // "text", "tool", "reasoning", "step-start", "step-finish", etc. + val text: String? = null, // text & reasoning parts + val tool: String? = null, // tool parts + val state: String? = null, // tool state: "pending", "running", "completed", "error" + val title: String? = null, // tool title +) + +// --- Prompt Input --- + +@Serializable +data class PromptDto( + val parts: List, + val providerID: String? = null, + val modelID: String? = null, + val agent: String? = null, +) + +@Serializable +data class PromptPartDto( + val type: String, // "text" + val text: String, +) + +// --- Streaming Events --- + +@Serializable +sealed class ChatEventDto { + + @Serializable + data class MessageUpdated( + val sessionID: String, + val info: MessageDto, + ) : ChatEventDto() + + @Serializable + data class PartUpdated( + val sessionID: String, + val part: PartDto, + ) : ChatEventDto() + + @Serializable + data class PartDelta( + val sessionID: String, + val messageID: String, + val partID: String, + val field: String, + val delta: String, + ) : ChatEventDto() + + @Serializable + data class TurnOpen( + val sessionID: String, + ) : ChatEventDto() + + @Serializable + data class TurnClose( + val sessionID: String, + val reason: String, // "completed", "error", "interrupted" + ) : ChatEventDto() + + @Serializable + data class Error( + val sessionID: String?, + val error: MessageErrorDto? = null, + ) : ChatEventDto() + + @Serializable + data class MessageRemoved( + val sessionID: String, + val messageID: String, + ) : ChatEventDto() +} + +// --- Config Update --- + +@Serializable +data class ConfigUpdateDto( + val model: String? = null, // "provider/model" format + val agent: String? = null, // default agent name + val temperature: Double? = null, // temperature for the agent +) +``` + +### Phase 2: RPC Interface Extensions (in `shared/`) + +#### 2.1 Add chat methods to `KiloSessionRpcApi.kt` + +```kotlin +// Add to existing KiloSessionRpcApi: + +/** Send a prompt (fire-and-forget). */ +suspend fun prompt(id: String, directory: String, prompt: PromptDto) + +/** Abort ongoing processing for a session. */ +suspend fun abort(id: String, directory: String) + +/** Load message history for a session. */ +suspend fun messages(id: String, directory: String): List + +/** Subscribe to chat events for a specific session. */ +suspend fun events(id: String, directory: String): Flow + +/** Update config (model, agent/mode, temperature). */ +suspend fun updateConfig(directory: String, config: ConfigUpdateDto) +``` + +### Phase 3: Backend Chat Manager (in `backend/`) + +#### 3.1 `KiloBackendChatManager.kt` — New class owned by `KiloBackendAppService` + +Responsibilities: + +- Calls generated API client for `promptAsync`, `sessionMessages`, `sessionAbort`, `configUpdate` +- Subscribes to SSE `SharedFlow` and parses chat-relevant events +- Exposes per-session `Flow` for the frontend +- Maps generated API model types to RPC DTOs + +**SSE events to handle:** +| SSE Event Type | → ChatEventDto | +|---|---| +| `message.updated` | `ChatEventDto.MessageUpdated` | +| `message.part.updated` | `ChatEventDto.PartUpdated` | +| `message.part.delta` | `ChatEventDto.PartDelta` | +| `message.removed` | `ChatEventDto.MessageRemoved` | +| `session.turn.open` | `ChatEventDto.TurnOpen` | +| `session.turn.close` | `ChatEventDto.TurnClose` | +| `session.error` | `ChatEventDto.Error` | + +**Event parsing approach:** The SSE data arrives as raw JSON strings through `SharedFlow`. Since the generated OpenAPI models use `anyOf` mapped to `kotlin.Any`, we parse the relevant fields using the same regex extraction approach already used in `KiloBackendSessionManager.extractField()`. For the basic chat, we only need a few fields from each event. + +**Config update approach:** The CLI API's `PATCH /config` accepts the full `Config` object. Since we only need to change model/agent/temperature, we: + +1. Read current config from `KiloBackendAppService.config` +2. Apply the delta (model, default_agent, agent temperature) +3. Send the full config via the generated client's `configUpdate()` + +The `global.config.updated` SSE event is already handled by `KiloBackendAppService.startWatchingGlobalSseEvents()`, which re-fetches and updates `appState`. + +#### 3.2 Wire into `KiloBackendAppService` + +```kotlin +// In KiloBackendAppService: +val chat = KiloBackendChatManager(cs, log) + +// In load(), after sessions.start() and workspaces.start(): +chat.start(connection.api!!, connection.events) + +// In clear(): +chat.stop() +``` + +### Phase 4: RPC Implementation (in `backend/rpc/`) + +#### 4.1 Update `KiloSessionRpcApiImpl.kt` + +Add implementations for the new methods that delegate to `KiloBackendChatManager`. + +### Phase 5: Frontend Service (in `frontend/`) + +#### 5.1 Update `KiloSessionService.kt` + +Add methods that call the new RPC endpoints: + +- `prompt(sessionID, text, model?, agent?)` — send a message +- `abort(sessionID)` — cancel processing +- `messages(sessionID)` — load history +- `events(sessionID)` — subscribe to streaming events +- `updateConfig(config)` — change mode/model/temperature + +These will be called by the UI (Phase 6). + +### Phase 6: Basic Chat UI (in `frontend/`) + +A Swing-based tool window panel for chat. Minimal viable UI: + +1. **Message list** — scrollable panel showing user/assistant messages with streaming text +2. **Input area** — text field + send button at the bottom +3. **Toolbar** — mode selector (dropdown), model selector (dropdown), temperature input +4. **Status** — show session status (idle/busy) and abort button + +Uses standard IntelliJ Platform components (per AGENTS.md — no Compose, no JCEF): + +- `JBScrollPane` for message list +- `JBTextArea` for input +- `JBList` or custom panel for messages +- `ComboBox` for mode/model dropdowns +- Action system for abort + +## File Changes Summary + +| File | Change | +| ------------------------------------------- | ------------------------------------------------------------- | +| `shared/.../dto/ChatDto.kt` | **New** — Message, Part, Prompt, ChatEvent, ConfigUpdate DTOs | +| `shared/.../KiloSessionRpcApi.kt` | **Edit** — Add 5 chat methods | +| `backend/.../app/KiloBackendChatManager.kt` | **New** — Chat orchestration, SSE→DTO mapping, API calls | +| `backend/.../app/KiloBackendAppService.kt` | **Edit** — Wire chat manager lifecycle | +| `backend/.../rpc/KiloSessionRpcApiImpl.kt` | **Edit** — Implement new chat RPC methods | +| `frontend/.../KiloSessionService.kt` | **Edit** — Add chat RPC calls | +| `frontend/.../chat/ChatPanel.kt` | **New** — Main chat UI panel | +| `frontend/.../chat/MessageListPanel.kt` | **New** — Scrollable message display | +| `frontend/.../chat/ChatInputPanel.kt` | **New** — Text input + send | +| `frontend/.../chat/ChatToolbar.kt` | **New** — Mode/model/temperature controls | +| `frontend/.../KiloToolWindowFactory.kt` | **Edit** — Wire chat panel into tool window | + +## Testing Plan + +- **Unit tests** for `KiloBackendChatManager`: SSE event parsing, DTO mapping, prompt dispatch +- **Extend `MockCliServer`** to simulate `POST /session/{id}/prompt_async` (204) and SSE chat events +- **Manual test**: Run `./gradlew runIde`, open a project, select Ask mode, type a prompt, verify streaming response + +## Key Design Decisions + +1. **Flat PartDto instead of sealed hierarchy** — For the basic chat, a single `PartDto` with a `type` discriminator and optional fields is simpler than modeling the full 12-variant Part union. The UI only needs `type`, `text`, `tool`, `state`, `title` for now. Can be refined later. + +2. **Regex JSON parsing for SSE events** — The existing pattern in `KiloBackendSessionManager.extractField()` works well for extracting known fields from SSE JSON. Avoids dependency on the generated models' `anyOf → kotlin.Any` type mappings which don't deserialize cleanly. + +3. **Per-session event flow via RPC** — The frontend subscribes to `events(sessionID)` which returns a `Flow`. The backend filters the global SSE stream by sessionID and maps to DTOs. This keeps the frontend simple and the RPC boundary clean. + +4. **Config update via full PATCH** — Read current config, apply delta, send full object. The SSE `global.config.updated` event already triggers a reload in `KiloBackendAppService`, so the frontend gets the update automatically. + +5. **No permission handling** — Skipped for v1. Tool calls will show status but permission requests won't be forwarded to the UI. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 66a80d3c35..9cab6db5e0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -1,5 +1,7 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.KiloBackendCliManager import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager @@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import okhttp3.OkHttpClient import kotlinx.coroutines.sync.withLock import java.net.ConnectException import java.net.SocketTimeoutException @@ -89,8 +92,11 @@ class KiloBackendAppService private constructor( val events: SharedFlow get() = connection.events val api: DefaultApi? get() = connection.api + val http: OkHttpClient? get() = connection.apiClient + val port: Int get() = connection.port val sessions = KiloBackendSessionManager(cs, log) + val chat = KiloBackendChatManager(cs, log) val workspaces = KiloBackendWorkspaceManager(cs, sessions, log) @Volatile var profile: KiloProfile200Response? = null @@ -230,7 +236,8 @@ class KiloBackendAppService private constructor( profile = prof config = cfg notifications = notifs - sessions.start(connection.api!!, connection.events) + sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) + chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.events) _appState.value = KiloAppState.Ready( AppData( @@ -256,8 +263,12 @@ class KiloBackendAppService private constructor( /** * Fetch the user profile. Returns [FetchResult.ok] with the response - * on success, [FetchResult.ok] with `null` when not logged in (401), - * or [FetchResult.fail] on other errors. Never throws. + * on success, [FetchResult.ok] with `null` when not logged in or when + * the server cannot reach the profile endpoint. Never throws. + * + * Profile is optional — 401 (not logged in) and 5xx (gateway/network + * errors) are both non-fatal. Only unexpected client errors are treated + * as failures. */ private suspend fun fetchProfile(): FetchResult { val client = connection.api @@ -274,6 +285,12 @@ class KiloBackendAppService private constructor( log.warn("Profile fetch failed: HTTP ${e.statusCode}", e) logResponseBody("profile", e) FetchResult.fail("profile", e) + } catch (e: ServerException) { + // 5xx from the CLI — profile endpoint is unreachable (no auth, + // gateway down, etc.). Treat the same as not-logged-in. + log.warn("Profile fetch: server error (${e.statusCode}) — treating as unavailable", e) + logResponseBody("profile", e) + FetchResult.ok(null) } catch (e: Exception) { log.warn("Profile fetch failed: ${e.message}", e) logResponseBody("profile", e) @@ -399,6 +416,7 @@ class KiloBackendAppService private constructor( eventWatcher?.cancel() } workspaces.stop() + chat.stop() sessions.stop() profile = null config = null 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 new file mode 100644 index 0000000000..3424d75ace --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -0,0 +1,191 @@ +package ai.kilocode.backend.app + +import ai.kilocode.backend.cli.KiloCliDataParser +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.PromptDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Chat orchestrator that handles message sending, history loading, + * and SSE event routing for the agent chat UI. + * + * **Not an IntelliJ service** — owned by [KiloBackendAppService] which + * calls [start] after [KiloAppState.Ready] and [stop] on disconnect. + * + * All JSON parsing is delegated to [KiloCliDataParser]. + */ +class KiloBackendChatManager( + private val cs: CoroutineScope, + private val log: KiloLog, +) { + companion object { + private val JSON_TYPE = "application/json".toMediaType() + + private val CHAT_EVENTS = setOf( + "message.updated", + "message.removed", + "message.part.updated", + "message.part.delta", + "session.turn.open", + "session.turn.close", + "session.error", + ) + } + + private val _events = MutableSharedFlow(extraBufferCapacity = 128) + val events: SharedFlow = _events.asSharedFlow() + + private var client: OkHttpClient? = null + private var base: String? = null + private var watcher: Job? = null + + fun start(http: OkHttpClient, port: Int, sse: SharedFlow) { + client = http + base = "http://127.0.0.1:$port" + if (watcher?.isActive == true) return + watcher = cs.launch { + sse.collect { event -> + if (event.type in CHAT_EVENTS) { + log.info("SSE chat event: type=${event.type}, data=${event.data.take(200)}") + val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data) + if (parsed != null) { + log.info("SSE parsed → ${parsed::class.simpleName}") + _events.emit(parsed) + } else { + log.warn("SSE parse returned null for type=${event.type}") + } + } + } + } + log.info("Chat manager started") + } + + fun stop() { + watcher?.cancel() + watcher = null + client = null + base = null + log.info("Chat manager stopped") + } + + // ------ prompt ------ + + fun prompt(id: String, dir: String, prompt: PromptDto) { + log.info("prompt: session=$id, dir=$dir, parts=${prompt.parts.size}, agent=${prompt.agent}, model=${prompt.providerID}/${prompt.modelID}") + val http = requireClient() + val url = requireBase() + + val body = KiloCliDataParser.buildPromptJson(prompt) + log.info("prompt: request body=$body") + val target = "$url/session/$id/prompt_async?directory=${encode(dir)}" + log.info("prompt: POST $target") + val request = Request.Builder() + .url(target) + .post(body.toRequestBody(JSON_TYPE)) + .build() + + try { + http.newCall(request).execute().use { response -> + val code = response.code + val raw = response.body?.string() + log.info("prompt: response HTTP $code, body=${raw?.take(200)}") + if (!response.isSuccessful) { + log.warn("prompt_async failed: HTTP $code — $raw") + throw RuntimeException("prompt_async failed: HTTP $code") + } + log.info("prompt: success (HTTP $code)") + } + } catch (e: RuntimeException) { + throw e + } catch (e: Exception) { + log.warn("prompt: HTTP call threw exception", e) + throw RuntimeException("prompt_async HTTP call failed: ${e.message}", e) + } + } + + // ------ abort ------ + + fun abort(id: String, dir: String) { + val http = requireClient() + val url = requireBase() + + val request = Request.Builder() + .url("$url/session/$id/abort?directory=${encode(dir)}") + .post("".toRequestBody(JSON_TYPE)) + .build() + + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("abort failed: HTTP ${response.code}") + } + } + } + + // ------ messages ------ + + fun messages(id: String, dir: String): List { + val http = requireClient() + val url = requireBase() + + val request = Request.Builder() + .url("$url/session/$id/message?directory=${encode(dir)}") + .get() + .build() + + return http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("messages failed: HTTP ${response.code}") + return emptyList() + } + val raw = response.body?.string() ?: return emptyList() + KiloCliDataParser.parseMessages(raw) + } + } + + // ------ config update ------ + + fun updateConfig(dir: String, update: ConfigUpdateDto) { + val http = requireClient() + val url = requireBase() + + val partial = KiloCliDataParser.buildConfigPartial(update) + log.info("config update: PATCH /global/config body=$partial") + + val request = Request.Builder() + .url("$url/global/config") + .patch(partial.toRequestBody(JSON_TYPE)) + .build() + + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val msg = response.body?.string() ?: "unknown error" + log.warn("config update failed: HTTP ${response.code} — $msg") + } else { + log.info("Config updated: model=${update.model}, agent=${update.agent}, temp=${update.temperature}") + } + } + } + + // ------ utilities ------ + + private fun requireClient(): OkHttpClient = + client ?: throw IllegalStateException("Chat manager not started") + + private fun requireBase(): String = + base ?: throw IllegalStateException("Chat manager not started") + + private fun encode(value: String): String = + java.net.URLEncoder.encode(value, "UTF-8") +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index a2c4530317..aec18db414 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -1,7 +1,9 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.KiloBackendHttpClients +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.util.IntellijLog -import ai.kilocode.backend.util.KiloBackendHttpClients import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope @@ -64,7 +66,6 @@ class KiloConnectionService( private const val HEARTBEAT_TIMEOUT_MS = 15_000L private const val HEALTH_POLL_INTERVAL_MS = 10_000L private const val RECONNECT_DELAY_MS = 250L - private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""") } private val _state = MutableStateFlow(ConnectionState.Disconnected) @@ -77,9 +78,13 @@ class KiloConnectionService( var api: DefaultApi? = null private set - private var apiClient: OkHttpClient? = null + /** OkHttp client used for API calls — no call/read timeout. Null when disconnected. */ + var apiClient: OkHttpClient? = null + private set private var healthClient: OkHttpClient? = null - private var port = 0 + /** Port the CLI server is listening on. Zero when disconnected. */ + var port = 0 + private set private var password = "" private val source = AtomicReference(null) @@ -214,7 +219,7 @@ class KiloConnectionService( override fun onEvent(src: EventSource, id: String?, type: String?, data: String) { lastEvent.set(System.currentTimeMillis()) - val kind = type ?: extractType(data) + val kind = type ?: KiloCliDataParser.extractEventType(data) cs.launch { _events.emit(SseEvent(type = kind, data = data)) } } @@ -327,9 +332,6 @@ class KiloConnectionService( _state.value = next } - internal fun extractType(data: String): String = - TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown" - fun dispose() { disposed = true source.getAndSet(null)?.cancel() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index bf9ff22442..95d61bd211 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.SessionStatus @@ -16,6 +17,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.ConcurrentHashMap /** @@ -29,30 +34,13 @@ import java.util.concurrent.ConcurrentHashMap * * SSE `session.status` events are consumed directly from the events * flow passed to [start], keeping the live [statuses] map current. + * + * All raw JSON parsing is delegated to [KiloCliDataParser]. */ class KiloBackendSessionManager( private val cs: CoroutineScope, private val log: KiloLog, ) { - companion object { - private val FIELD_RE = ConcurrentHashMap() - - /** Extract a top-level string field from JSON without a full parser. */ - internal fun extractField(json: String, field: String): String? { - val re = FIELD_RE.getOrPut(field) { - Regex(""""$field"\s*:\s*"([^"]+)"""") - } - return re.find(json)?.groupValues?.get(1) - } - - /** Extract a string field nested one level deep. */ - internal fun extractNested(json: String, outer: String, inner: String): String? { - val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""") - .find(json)?.groupValues?.get(1) ?: return null - return extractField("{$block}", inner) - } - } - /** Per-session directory overrides (sessionId → worktree path). */ private val directories = ConcurrentHashMap() @@ -60,32 +48,34 @@ class KiloBackendSessionManager( val statuses: StateFlow> = _statuses.asStateFlow() private var client: DefaultApi? = null + private var http: OkHttpClient? = null + private var base: String? = null private var watcher: Job? = null - /** - * Activate the session manager with a connected API client and SSE stream. - * Called by [KiloBackendAppService] after [KiloAppState.Ready]. - */ - fun start(api: DefaultApi, events: SharedFlow) { + fun start(api: DefaultApi, httpClient: OkHttpClient, port: Int, events: SharedFlow) { client = api + http = httpClient + base = "http://127.0.0.1:$port" if (watcher?.isActive == true) return watcher = cs.launch { events.collect { event -> if (event.type == "session.status") { - handleStatus(event.data) + val pair = KiloCliDataParser.parseSessionStatus(event.data) + if (pair != null) { + _statuses.update { it + pair } + } } } } log.info("Session manager started") } - /** - * Deactivate the session manager. Called by [KiloBackendAppService] on disconnect. - */ fun stop() { watcher?.cancel() watcher = null client = null + http = null + base = null _statuses.value = emptyMap() log.info("Session manager stopped") } @@ -95,7 +85,6 @@ class KiloBackendSessionManager( // ------ session CRUD ------ - /** List root sessions for a directory and include current statuses. */ fun list(dir: String): SessionListDto { seed(dir) val raw = requireClient().sessionList(directory = dir, roots = true) @@ -105,16 +94,34 @@ class KiloBackendSessionManager( return SessionListDto(mapped, relevant) } - /** Create a new session in the given directory. */ - fun create(dir: String): SessionDto = - dto(requireClient().sessionCreate(directory = dir)) - /** - * Get a single session by ID. + * Create a new session in the given directory. * - * Uses the session list endpoint and filters by ID since - * [DefaultApi] does not expose the single-session GET. + * Uses raw HTTP because the generated client sends malformed JSON + * for the optional request body (Content-Type set but empty body). */ + fun create(dir: String): SessionDto { + val h = http ?: throw IllegalStateException("Session manager not started") + val url = base ?: throw IllegalStateException("Session manager not started") + val encoded = java.net.URLEncoder.encode(dir, "UTF-8") + log.info("Creating session: POST $url/session?directory=$encoded") + + val request = Request.Builder() + .url("$url/session?directory=$encoded") + .post("{}".toRequestBody("application/json".toMediaType())) + .build() + + h.newCall(request).execute().use { response -> + val raw = response.body?.string() + if (!response.isSuccessful) { + log.warn("Session create failed: HTTP ${response.code}, body=$raw") + throw RuntimeException("Session create failed: HTTP ${response.code} — $raw") + } + log.info("Session created: HTTP ${response.code}") + return KiloCliDataParser.parseSession(raw!!) + } + } + fun get(id: String, dir: String): SessionDto { val all = requireClient().sessionList(directory = dir) val raw = all.firstOrNull { it.id == id } @@ -122,13 +129,11 @@ class KiloBackendSessionManager( return dto(raw) } - /** Delete a session. */ fun delete(id: String, dir: String) { requireClient().sessionDelete(sessionID = id, directory = dir) directories.remove(id) } - /** Seed status map from the server for a specific directory. */ fun seed(dir: String) { try { val raw = requireClient().sessionStatus(directory = dir) @@ -149,16 +154,7 @@ class KiloBackendSessionManager( fun getDirectory(id: String, fallback: String): String = directories[id] ?: fallback - // ------ SSE event handling ------ - - private fun handleStatus(data: String) { - val id = extractField(data, "sessionID") ?: return - val type = extractNested(data, "status", "type") ?: "idle" - val msg = extractNested(data, "status", "message") - _statuses.update { it + (id to SessionStatusDto(type, msg)) } - } - - // ------ mapping ------ + // ------ mapping (generated API model → DTO) ------ private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = SessionDto( id = s.id, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/CliServer.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt similarity index 94% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/CliServer.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt index 959f81e259..f36f917acb 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/CliServer.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend.app +package ai.kilocode.backend.cli /** * Abstraction over the CLI process lifecycle. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt similarity index 85% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index c80ab7612b..b3e9d84412 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend.app +package ai.kilocode.backend.cli import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog @@ -43,21 +43,11 @@ class KiloBackendCliManager( private var process: Process? = null private var hook: Thread? = null - /** - * When true, the next [extractCli] call deletes and re-extracts the binary - * regardless of the size check. Reset to false after extraction. - */ @Volatile override var forceExtract = false override fun process(): Process? = process - /** - * Extract the CLI binary (if needed) and spawn `kilo serve`. - * - * Must be called under [KiloBackendAppService]'s mutex — no internal - * synchronization is performed. - */ override suspend fun init(): CliServer.State { return try { val path = extractCli() @@ -67,8 +57,6 @@ class KiloBackendCliManager( } } catch (e: Exception) { log.warn("CLI startup failed", e) - // If spawn started a process but timed out (or failed after start), - // kill the orphaned process so it doesn't leak. process?.let { proc -> log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})") process = null @@ -82,19 +70,12 @@ class KiloBackendCliManager( } } - /** - * Mark the given process as exited and clear state. - * Called from the process monitor when the CLI process dies. - */ override fun exited(proc: Process) { if (process != proc) return process = null uninstall() } - /** - * Kill the running CLI process and reset state so the next [init] spawns fresh. - */ override fun stop() { val proc = process ?: return process = null @@ -209,38 +190,27 @@ class KiloBackendCliManager( val proc = process ?: return process = null uninstall() - kill(proc, "Disposing") } private fun install(proc: Process) { uninstall() - val next = Thread({ log.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})") kill(proc, "Shutdown hook", wait = false) }, "kilo-cli-shutdown") - - val ok = runCatching { - Runtime.getRuntime().addShutdownHook(next) - } - + val ok = runCatching { Runtime.getRuntime().addShutdownHook(next) } if (ok.isFailure) { log.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull()) return } - hook = next } private fun uninstall() { val curr = hook ?: return hook = null - - val ok = runCatching { - Runtime.getRuntime().removeShutdownHook(curr) - } - + val ok = runCatching { Runtime.getRuntime().removeShutdownHook(curr) } if (ok.isFailure) { log.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}") } @@ -250,9 +220,7 @@ class KiloBackendCliManager( log.info("$source — killing CLI process tree (pid ${proc.pid()})") children(proc).forEach { it.destroy() } proc.destroy() - if (!wait) return - if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { log.warn("CLI process did not exit after SIGTERM, sending SIGKILL") children(proc).forEach { it.destroyForcibly() } @@ -260,22 +228,15 @@ class KiloBackendCliManager( } } - private fun children(proc: Process): List { - return proc.toHandle().descendants().toList().asReversed() - } + private fun children(proc: Process): List = + proc.toHandle().descendants().toList().asReversed() private fun platform(): String { val os = when { SystemInfo.isMac -> "darwin" SystemInfo.isLinux -> "linux" SystemInfo.isWindows -> "windows" - else -> throw IllegalStateException( - "Unsupported OS: ${ - System.getProperty( - "os.name" - ) - }" - ) + else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}") } val arch = when (CpuArch.CURRENT) { CpuArch.ARM64 -> "arm64" @@ -285,11 +246,6 @@ class KiloBackendCliManager( return "$os-$arch" } - /** - * Collect IDE-specific env vars for telemetry and gateway attribution. - * Catches all exceptions since these are best-effort — missing values - * won't prevent the CLI from starting. - */ private fun ideEnv(): Map = buildMap { runCatching { val info = ApplicationInfo.getInstance() @@ -310,10 +266,6 @@ class KiloBackendCliManager( }.onFailure { log.info("Could not read machine ID: ${it.message}") } } - /** - * Persistent machine ID stored in the IntelliJ system directory. - * Generated once and reused across restarts. - */ private fun machineId(): String { val file = File(PathManager.getSystemPath(), "kilo/machine-id") if (file.exists()) return file.readText().trim() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt similarity index 98% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt index bfb3397aaa..3e71cf2bf3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend.util +package ai.kilocode.backend.cli import okhttp3.ConnectionPool import okhttp3.Interceptor 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 new file mode 100644 index 0000000000..1fc64a61a8 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -0,0 +1,341 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +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.PromptDto +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.TokensDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.util.concurrent.ConcurrentHashMap + +/** + * Stateless parser that centralizes all CLI server response parsing. + * + * Callers pass raw JSON strings (SSE event data, HTTP response bodies) + * and get typed DTOs back. All JSON navigation, regex extraction, and + * manual serialization is contained here — no caller needs to know + * about [JsonObject] or kotlinx.serialization. + * + * Designed for testability: every public method is pure (no side effects, + * no dependencies). When a new parsing bug is found, add a test case + * with the raw JSON that caused the issue. + */ +object KiloCliDataParser { + + private val json = Json { ignoreUnknownKeys = true } + private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""") + private val FIELD_RE = ConcurrentHashMap() + + // ================================================================ + // SSE event parsing + // ================================================================ + + /** + * Extract the event type from raw SSE JSON data. + * Used as a fallback when OkHttp's EventSourceListener receives a null type. + */ + fun extractEventType(data: String): String = + TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown" + + /** + * Parse an SSE chat event into a [ChatEventDto]. + * Returns null if the event type is unrecognized or the JSON is malformed. + * + * Handles the GlobalEvent wrapper: `{ directory, payload: { type, properties } }` + * as well as flat events with top-level `properties`. + */ + fun parseChatEvent(type: String, data: String): ChatEventDto? { + val obj = tryParseObject(data) ?: return null + + // SSE data is a GlobalEvent: { directory, payload: { type, properties } } + val payload = obj["payload"]?.jsonObject ?: obj + val props = payload["properties"]?.jsonObject ?: return null + + return when (type) { + "message.updated" -> { + val sid = props.str("sessionID") ?: return null + val info = props["info"]?.jsonObject ?: return null + ChatEventDto.MessageUpdated(sid, parseMessage(info)) + } + + "message.removed" -> { + val sid = props.str("sessionID") ?: return null + val mid = props.str("messageID") ?: return null + ChatEventDto.MessageRemoved(sid, mid) + } + + "message.part.updated" -> { + val sid = props.str("sessionID") ?: return null + val part = props["part"]?.jsonObject ?: return null + ChatEventDto.PartUpdated(sid, parsePart(part)) + } + + "message.part.delta" -> { + val sid = props.str("sessionID") ?: return null + val mid = props.str("messageID") ?: return null + val pid = props.str("partID") ?: return null + val field = props.str("field") ?: return null + val delta = props.str("delta") ?: return null + ChatEventDto.PartDelta(sid, mid, pid, field, delta) + } + + "session.turn.open" -> { + val sid = props.str("sessionID") ?: return null + ChatEventDto.TurnOpen(sid) + } + + "session.turn.close" -> { + val sid = props.str("sessionID") ?: return null + val reason = props.str("reason") ?: "completed" + ChatEventDto.TurnClose(sid, reason) + } + + "session.error" -> { + val sid = props.str("sessionID") + val err = props["error"]?.jsonObject?.let { parseError(it) } + ChatEventDto.Error(sid, err) + } + + else -> null + } + } + + /** + * 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) + } + + // ================================================================ + // HTTP response parsing + // ================================================================ + + /** + * Parse a session creation response (`POST /session`) into [SessionDto]. + */ + fun parseSession(raw: String): SessionDto { + val obj = json.parseToJsonElement(raw).jsonObject + return parseSessionObject(obj) + } + + /** + * Parse message history response (`GET /session/{id}/message`) + * into a list of messages with their parts. + */ + fun parseMessages(raw: String): List { + val arr = tryParseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + val obj = elem.jsonObject + val info = obj["info"]?.jsonObject ?: return@mapNotNull null + val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList()) + MessageWithPartsDto( + info = parseMessage(info), + parts = parts.map { parsePart(it.jsonObject) }, + ) + } + } + + // ================================================================ + // JSON serialization (DTO → JSON for outgoing requests) + // ================================================================ + + /** + * Build the JSON body for `POST /session/{id}/prompt_async`. + */ + fun buildPromptJson(prompt: PromptDto): String { + val parts = prompt.parts.joinToString(",") { part -> + """{"type":"${part.type}","text":${escape(part.text)}}""" + } + val sb = StringBuilder() + sb.append("""{"parts":[$parts]""") + val pid = prompt.providerID + val mid = prompt.modelID + if (pid != null && mid != null) { + sb.append(""","model":{"providerID":${escape(pid)},"modelID":${escape(mid)}}""") + } + val ag = prompt.agent + if (ag != null) { + sb.append(""","agent":${escape(ag)}""") + } + sb.append("}") + return sb.toString() + } + + /** + * Build the partial JSON body for `PATCH /global/config`. + */ + fun buildConfigPartial(update: ConfigUpdateDto): String { + val sb = StringBuilder("{") + var first = true + fun sep() { if (!first) sb.append(","); first = false } + + val model = update.model + if (model != null) { + sep(); sb.append(""""model":${escape(model)}""") + } + val agent = update.agent + if (agent != null) { + sep(); sb.append(""""default_agent":${escape(agent)}""") + } + val temp = update.temperature + if (temp != null) { + val target = agent ?: "ask" + sep(); sb.append(""""agent":{"$target":{"temperature":$temp}}""") + } + sb.append("}") + return sb.toString() + } + + // ================================================================ + // Internal — message/part/session parsing + // ================================================================ + + internal fun parseMessage(obj: JsonObject): MessageDto { + val time = obj["time"]?.jsonObject + val tokens = obj["tokens"]?.jsonObject + val error = obj["error"]?.jsonObject + + return MessageDto( + id = obj.str("id") ?: "", + sessionID = obj.str("sessionID") ?: "", + role = obj.str("role") ?: "unknown", + time = MessageTimeDto( + created = time?.num("created") ?: 0.0, + completed = time?.num("completed"), + ), + agent = obj.str("agent"), + providerID = obj.str("providerID"), + modelID = obj.str("modelID"), + parentID = obj.str("parentID"), + cost = obj.num("cost"), + tokens = tokens?.let { + val cache = it["cache"]?.jsonObject + TokensDto( + input = it.long("input") ?: 0, + output = it.long("output") ?: 0, + reasoning = it.long("reasoning") ?: 0, + cacheRead = cache?.long("read") ?: 0, + cacheWrite = cache?.long("write") ?: 0, + ) + }, + error = error?.let { parseError(it) }, + ) + } + + internal fun parsePart(obj: JsonObject): PartDto { + val state = obj["state"]?.jsonObject + return PartDto( + id = obj.str("id") ?: "", + sessionID = obj.str("sessionID") ?: "", + messageID = obj.str("messageID") ?: "", + type = obj.str("type") ?: "unknown", + text = obj.str("text"), + tool = obj.str("tool"), + state = state?.str("status"), + title = state?.str("title"), + ) + } + + internal fun parseError(obj: JsonObject): MessageErrorDto { + val type = obj.str("type") ?: "unknown" + val msg = obj.str("message") ?: obj.str("error") + return MessageErrorDto(type, msg) + } + + private fun parseSessionObject(obj: JsonObject): SessionDto { + val time = obj["time"]?.jsonObject + val summary = obj["summary"]?.jsonObject + return SessionDto( + id = obj.str("id") ?: "", + projectID = obj.str("projectID") ?: "", + directory = obj.str("directory") ?: "", + parentID = obj.str("parentID"), + title = obj.str("title") ?: "", + version = obj.str("version") ?: "", + time = SessionTimeDto( + created = time?.num("created") ?: 0.0, + updated = time?.num("updated") ?: 0.0, + archived = time?.num("archived"), + ), + summary = summary?.let { + SessionSummaryDto( + additions = it.long("additions")?.toInt() ?: 0, + deletions = it.long("deletions")?.toInt() ?: 0, + files = it.long("files")?.toInt() ?: 0, + ) + }, + ) + } + + // ================================================================ + // Internal — regex-based field extraction (for status events) + // ================================================================ + + private fun extractField(raw: String, field: String): String? { + val re = FIELD_RE.getOrPut(field) { + Regex(""""$field"\s*:\s*"([^"]+)"""") + } + return re.find(raw)?.groupValues?.get(1) + } + + private fun extractNested(raw: String, outer: String, inner: String): String? { + val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""") + .find(raw)?.groupValues?.get(1) ?: return null + return extractField("{$block}", inner) + } + + // ================================================================ + // Internal — JSON helpers + // ================================================================ + + private fun tryParseObject(raw: String): JsonObject? = + try { json.parseToJsonElement(raw).jsonObject } catch (_: Exception) { null } + + private fun tryParseArray(raw: String): kotlinx.serialization.json.JsonArray? = + try { json.parseToJsonElement(raw).jsonArray } catch (_: Exception) { null } + + /** Escape and double-quote a string for manual JSON building. */ + private fun escape(value: String): String { + val escaped = value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + return "\"$escaped\"" + } +} + +// JsonObject convenience extensions +private fun JsonObject.str(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull + +private fun JsonObject.num(key: String): Double? = + this[key]?.jsonPrimitive?.doubleOrNull + +private fun JsonObject.long(key: String): Long? = + this[key]?.jsonPrimitive?.longOrNull 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 e2dcf4025d..c0c7c5c823 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 @@ -3,14 +3,21 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.KiloBackendChatManager import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager 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.PromptDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter /** * Backend implementation of [KiloSessionRpcApi]. @@ -18,9 +25,13 @@ import kotlinx.coroutines.flow.Flow * Session CRUD routes through the [KiloBackendWorkspaceManager] to * get the correct workspace for a directory. Status tracking and * worktree directory management go directly to the - * [KiloBackendSessionManager]. + * [KiloBackendSessionManager]. Chat operations delegate to + * [KiloBackendChatManager]. */ class KiloSessionRpcApiImpl : KiloSessionRpcApi { + companion object { + private val LOG = Logger.getInstance(KiloSessionRpcApiImpl::class.java) + } private val workspaces: KiloBackendWorkspaceManager get() = service().workspaces @@ -28,11 +39,16 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { private val sessions: KiloBackendSessionManager get() = service().sessions + private val chat: KiloBackendChatManager + get() = service().chat + override suspend fun list(directory: String): SessionListDto = workspaces.get(directory).sessions() - override suspend fun create(directory: String): SessionDto = - workspaces.get(directory).createSession() + override suspend fun create(directory: String): SessionDto { + LOG.info("create session: directory=$directory") + return workspaces.get(directory).createSession() + } override suspend fun get(id: String, directory: String): SessionDto { val dir = sessions.getDirectory(id, directory) @@ -52,4 +68,34 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { override suspend fun getDirectory(id: String, fallback: String): String = sessions.getDirectory(id, fallback) + + // ------ chat ------ + + override suspend fun prompt(id: String, directory: String, prompt: PromptDto) { + LOG.info("prompt RPC: session=$id, dir=$directory, parts=${prompt.parts.size}") + chat.prompt(id, directory, prompt) + } + + override suspend fun abort(id: String, directory: String) = + chat.abort(id, directory) + + override suspend fun messages(id: String, directory: String): List = + chat.messages(id, directory) + + override suspend fun events(id: String, directory: String): Flow = + chat.events.filter { event -> + val sid = when (event) { + is ChatEventDto.MessageUpdated -> event.sessionID + is ChatEventDto.PartUpdated -> event.sessionID + is ChatEventDto.PartDelta -> event.sessionID + is ChatEventDto.TurnOpen -> event.sessionID + is ChatEventDto.TurnClose -> event.sessionID + is ChatEventDto.Error -> event.sessionID + is ChatEventDto.MessageRemoved -> event.sessionID + } + sid == id + } + + override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = + chat.updateConfig(directory, config) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt index a9ed85d2a6..624929e083 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.backend -import ai.kilocode.backend.util.KiloBackendHttpClients +import ai.kilocode.backend.cli.KiloBackendHttpClients import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import java.util.Base64 diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt index 506a2b2e42..b953b460f1 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt @@ -303,7 +303,7 @@ class KiloBackendSessionManagerTest { assertFailsWith { app.sessions.list("/test") } // Re-start (simulate what app service does on reconnect) - app.sessions.start(app.api!!, app.events) + app.sessions.start(app.api!!, app.http!!, app.port, app.events) // CRUD should work again val result = app.sessions.list("/test") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloCliDataParserTest.kt new file mode 100644 index 0000000000..125eb0ba83 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloCliDataParserTest.kt @@ -0,0 +1,475 @@ +package ai.kilocode.backend + +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pure unit tests for [KiloCliDataParser]. + * + * No mocks, no services, no coroutines — just JSON in → DTO out. + * When a new parsing bug is found, copy the raw JSON that caused + * the issue and add a test case here. + */ +class KiloCliDataParserTest { + + // ================================================================ + // extractEventType + // ================================================================ + + @Test + fun `extractEventType - parses type from JSON data`() { + val result = KiloCliDataParser.extractEventType( + """{"type":"global.config.updated","payload":{}}""" + ) + assertEquals("global.config.updated", result) + } + + @Test + fun `extractEventType - returns unknown for missing type`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("""{"data":"something"}""")) + } + + @Test + fun `extractEventType - returns unknown for empty string`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("")) + } + + // ================================================================ + // parseChatEvent — GlobalEvent wrapper + // ================================================================ + + @Test + fun `parseChatEvent - message updated with GlobalEvent wrapper`() { + val data = """{ + "directory": "/tmp/test", + "payload": { + "type": "message.updated", + "properties": { + "sessionID": "ses_123", + "info": { + "id": "msg_1", + "sessionID": "ses_123", + "role": "assistant", + "time": { "created": 1000.0 } + } + } + } + }""" + + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_123", result.sessionID) + assertEquals("msg_1", result.info.id) + assertEquals("assistant", result.info.role) + } + + @Test + fun `parseChatEvent - flat event without payload wrapper`() { + val data = """{ + "type": "message.updated", + "properties": { + "sessionID": "ses_456", + "info": { + "id": "msg_2", + "sessionID": "ses_456", + "role": "user", + "time": { "created": 2000.0 } + } + } + }""" + + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_456", result.sessionID) + assertEquals("user", result.info.role) + } + + // ================================================================ + // parseChatEvent — specific event types + // ================================================================ + + @Test + fun `parseChatEvent - message part delta`() { + val data = globalEvent(""" + "type": "message.part.delta", + "properties": { + "sessionID": "ses_1", + "messageID": "msg_1", + "partID": "part_1", + "field": "text", + "delta": "Hello world" + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.delta", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartDelta) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + assertEquals("text", result.field) + assertEquals("Hello world", result.delta) + } + + @Test + fun `parseChatEvent - message part updated`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "text", + "text": "Hello" + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals("part_1", result.part.id) + assertEquals("text", result.part.type) + assertEquals("Hello", result.part.text) + } + + @Test + fun `parseChatEvent - turn open`() { + val data = globalEvent(""" + "type": "session.turn.open", + "properties": { "sessionID": "ses_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.turn.open", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnOpen) + assertEquals("ses_1", result.sessionID) + } + + @Test + fun `parseChatEvent - turn close`() { + val data = globalEvent(""" + "type": "session.turn.close", + "properties": { "sessionID": "ses_1", "reason": "completed" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.turn.close", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnClose) + assertEquals("ses_1", result.sessionID) + assertEquals("completed", result.reason) + } + + @Test + fun `parseChatEvent - session error`() { + val data = globalEvent(""" + "type": "session.error", + "properties": { + "sessionID": "ses_1", + "error": { "type": "provider_auth", "message": "Invalid key" } + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.error", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.Error) + assertEquals("ses_1", result.sessionID) + assertEquals("provider_auth", result.error?.type) + assertEquals("Invalid key", result.error?.message) + } + + @Test + fun `parseChatEvent - message removed`() { + val data = globalEvent(""" + "type": "message.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("message.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + } + + // ================================================================ + // parseChatEvent — error cases + // ================================================================ + + @Test + fun `parseChatEvent - unknown type returns null`() { + val data = globalEvent(""" + "type": "some.unknown.event", + "properties": { "sessionID": "ses_1" } + """) + assertNull(KiloCliDataParser.parseChatEvent("some.unknown.event", data)) + } + + @Test + fun `parseChatEvent - malformed JSON returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", "not json")) + } + + @Test + fun `parseChatEvent - missing properties returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", """{"payload":{"type":"x"}}""")) + } + + @Test + fun `parseChatEvent - missing sessionID returns null`() { + val data = globalEvent(""" + "type": "message.updated", + "properties": { "info": { "id": "msg_1", "role": "user", "time": {} } } + """) + assertNull(KiloCliDataParser.parseChatEvent("message.updated", data)) + } + + // ================================================================ + // parseSessionStatus + // ================================================================ + + @Test + fun `parseSessionStatus - valid status event`() { + val data = """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_abc", result.first) + assertEquals("busy", result.second.type) + assertEquals("Running...", result.second.message) + } + + @Test + fun `parseSessionStatus - missing sessionID returns null`() { + val data = """{"status":{"type":"idle"}}""" + assertNull(KiloCliDataParser.parseSessionStatus(data)) + } + + @Test + fun `parseSessionStatus - missing status defaults to idle`() { + val data = """{"sessionID":"ses_xyz"}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("idle", result.second.type) + assertNull(result.second.message) + } + + // ================================================================ + // parseSession + // ================================================================ + + @Test + fun `parseSession - full session response`() { + val raw = """{ + "id": "ses_abc", + "projectID": "proj_1", + "directory": "/tmp/project", + "parentID": null, + "title": "Test session", + "version": "1", + "time": { "created": 1000.0, "updated": 2000.0 }, + "summary": { "additions": 10, "deletions": 5, "files": 3 } + }""" + + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_abc", result.id) + assertEquals("proj_1", result.projectID) + assertEquals("/tmp/project", result.directory) + assertNull(result.parentID) + assertEquals("Test session", result.title) + assertEquals(1000.0, result.time.created) + assertEquals(2000.0, result.time.updated) + assertNotNull(result.summary) + assertEquals(10, result.summary?.additions) + assertEquals(5, result.summary?.deletions) + assertEquals(3, result.summary?.files) + } + + @Test + fun `parseSession - minimal session response`() { + val raw = """{ + "id": "ses_min", + "projectID": "proj_2", + "directory": "/tmp", + "title": "", + "version": "0", + "time": { "created": 0.0, "updated": 0.0 } + }""" + + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_min", result.id) + assertNull(result.summary) + } + + // ================================================================ + // parseMessages + // ================================================================ + + @Test + fun `parseMessages - empty array`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("[]")) + } + + @Test + fun `parseMessages - user and assistant messages`() { + val raw = """[ + { + "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, + "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] + }, + { + "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, + "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }] + } + ]""" + + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(2, result.size) + assertEquals("user", result[0].info.role) + assertEquals("Hello", result[0].parts[0].text) + assertEquals("assistant", result[1].info.role) + assertEquals("Hi there", result[1].parts[0].text) + } + + @Test + fun `parseMessages - message with tool parts`() { + val raw = """[{ + "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, + "parts": [{ + "id": "p1", + "sessionID": "s1", + "messageID": "m1", + "type": "tool", + "tool": "read_file", + "state": { "status": "completed", "title": "Read file.txt" } + }] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(1, result.size) + val part = result[0].parts[0] + assertEquals("tool", part.type) + assertEquals("read_file", part.tool) + assertEquals("completed", part.state) + assertEquals("Read file.txt", part.title) + } + + @Test + fun `parseMessages - malformed JSON returns empty`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json")) + } + + @Test + fun `parseMessages - message with tokens`() { + val raw = """[{ + "info": { + "id": "m1", "sessionID": "s1", "role": "assistant", + "time": { "created": 1.0, "completed": 2.0 }, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } }, + "cost": 0.005 + }, + "parts": [] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + val info = result[0].info + assertNotNull(info.tokens) + assertEquals(100L, info.tokens?.input) + assertEquals(50L, info.tokens?.output) + assertEquals(10L, info.tokens?.reasoning) + assertEquals(20L, info.tokens?.cacheRead) + assertEquals(5L, info.tokens?.cacheWrite) + assertEquals(0.005, info.cost) + assertEquals(2.0, info.time.completed) + } + + // ================================================================ + // buildPromptJson + // ================================================================ + + @Test + fun `buildPromptJson - text only`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "Hello"))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertEquals("""{"parts":[{"type":"text","text":"Hello"}]}""", result) + } + + @Test + fun `buildPromptJson - with model override`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + providerID = "anthropic", + modelID = "claude-4", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""model":{"providerID":"anthropic","modelID":"claude-4"}""")) + } + + @Test + fun `buildPromptJson - with agent`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + agent = "ask", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""agent":"ask"""")) + } + + @Test + fun `buildPromptJson - escapes special characters`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "line1\nline2\t\"quoted\""))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) + } + + // ================================================================ + // buildConfigPartial + // ================================================================ + + @Test + fun `buildConfigPartial - model only`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) + assertEquals("""{"model":"anthropic/claude-4"}""", result) + } + + @Test + fun `buildConfigPartial - agent and temperature`() { + val result = KiloCliDataParser.buildConfigPartial( + ConfigUpdateDto(agent = "code", temperature = 0.7) + ) + assertTrue(result.contains(""""default_agent":"code"""")) + assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) + } + + @Test + fun `buildConfigPartial - empty update`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) + assertEquals("{}", result) + } + + @Test + fun `buildConfigPartial - temperature without agent defaults to ask`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) + assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) + } + + // ================================================================ + // Helpers + // ================================================================ + + /** Wrap payload content in a GlobalEvent structure. */ + private fun globalEvent(payload: String): String = + """{"directory":"/tmp","payload":{$payload}}""" +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt index 92a3aa7147..d52c00a3e2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.backend -import ai.kilocode.backend.app.CliServer +import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.app.ConnectionState import ai.kilocode.backend.app.KiloConnectionService import ai.kilocode.backend.testing.FakeCliServer @@ -147,18 +147,7 @@ class KiloConnectionServiceTest { assertTrue(fake.forceExtract) } - @Test - fun `extractType parses type from JSON data`() { - val svc = KiloConnectionService(scope, fake, {}, log) - val result = svc.extractType("""{"type":"global.config.updated","payload":{}}""") - assertEquals("global.config.updated", result) - } - - @Test - fun `extractType returns unknown for missing type`() { - val svc = KiloConnectionService(scope, fake, {}, log) - assertEquals("unknown", svc.extractType("""{"data":"something"}""")) - } + // extractType tests moved to KiloCliDataParserTest @Test fun `dispose transitions to Disconnected`() = runBlocking { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt index 0659cf80b5..885b327be8 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt @@ -1,6 +1,6 @@ package ai.kilocode.backend.testing -import ai.kilocode.backend.app.CliServer +import ai.kilocode.backend.cli.CliServer /** * Fake [CliServer] that delegates to a [MockCliServer] instead of diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt index 1dee6d12d9..f69fb8c44d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt @@ -3,6 +3,11 @@ package ai.kilocode.client 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.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.Service @@ -10,21 +15,23 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import fleet.rpc.client.durable import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** - * Project-level frontend service for session management. + * Project-level frontend service for session management and chat. * - * Provides session CRUD, active session tracking, and live - * status updates via [KiloSessionRpcApi]. All operations are - * scoped to the project's [directory] by default, with support - * for per-session worktree directory overrides. + * Provides session CRUD, active session tracking, live status + * updates, and chat operations via [KiloSessionRpcApi]. All + * operations are scoped to the project's [directory] by default, + * with support for per-session worktree directory overrides. */ @Service(Service.Level.PROJECT) class KiloSessionService( @@ -35,7 +42,14 @@ class KiloSessionService( private val LOG = Logger.getInstance(KiloSessionService::class.java) } - private val directory: String get() = project.basePath ?: "" + private val directory: String + get() { + val path = project.basePath ?: "" + if (path.isEmpty()) { + LOG.warn("project.basePath is null/empty — session operations will likely fail") + } + return path + } private val _sessions = MutableStateFlow>(emptyList()) val sessions: StateFlow> = _sessions.asStateFlow() @@ -112,4 +126,100 @@ class KiloSessionService( } } } + + // ------ chat ------ + + /** + * Send a text prompt to the active session. Creates a session if needed. + * + * @param text The user's message text + * @param providerID Optional model override (provider part) + * @param modelID Optional model override (model part) + * @param agent Optional agent/mode override (e.g. "ask", "code") + */ + fun prompt(text: String, providerID: String? = null, modelID: String? = null, agent: String? = null) { + cs.launch { + try { + LOG.info("prompt: ensuring session exists (active=${_active.value?.id})") + val session = ensureSession() + LOG.info("prompt: session=${session.id}, dir=$directory, text=${text.take(80)}") + val prompt = PromptDto( + parts = listOf(PromptPartDto(type = "text", text = text)), + providerID = providerID, + modelID = modelID, + agent = agent, + ) + LOG.info("prompt: calling RPC prompt...") + durable { KiloSessionRpcApi.getInstance().prompt(session.id, directory, prompt) } + LOG.info("prompt: RPC returned successfully") + } catch (e: Exception) { + LOG.warn("prompt failed", e) + } + } + } + + /** Abort ongoing processing for the active session. */ + fun abort() { + cs.launch { + val session = _active.value ?: return@launch + try { + durable { KiloSessionRpcApi.getInstance().abort(session.id, directory) } + } catch (e: Exception) { + LOG.warn("abort failed", e) + } + } + } + + /** Load message history for the active session. */ + suspend fun messages(): List { + val session = _active.value ?: return emptyList() + return try { + durable { KiloSessionRpcApi.getInstance().messages(session.id, directory) } + } catch (e: Exception) { + LOG.warn("messages failed", e) + emptyList() + } + } + + /** + * Subscribe to streaming chat events for the active session. + * Returns an empty flow if no session is active. + */ + fun events(): Flow { + val session = _active.value ?: return emptyFlow() + return flow { + durable { + KiloSessionRpcApi.getInstance() + .events(session.id, directory) + .collect { emit(it) } + } + } + } + + /** Update config (model, agent/mode, temperature). */ + fun updateConfig(config: ConfigUpdateDto) { + cs.launch { + try { + durable { KiloSessionRpcApi.getInstance().updateConfig(directory, config) } + } catch (e: Exception) { + LOG.warn("config update failed", e) + } + } + } + + // ------ helpers ------ + + /** + * Ensure an active session exists. Creates one if needed. + */ + private suspend fun ensureSession(): SessionDto { + _active.value?.let { return it } + val dir = directory + LOG.info("ensureSession: creating new session in dir=$dir") + val session = durable { KiloSessionRpcApi.getInstance().create(dir) } + LOG.info("ensureSession: created session ${session.id}") + _active.value = session + refresh() + return session + } } 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 dad0fd0717..c9349339ea 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 @@ -1,5 +1,7 @@ package ai.kilocode.client +import ai.kilocode.client.chat.ChatPanel +import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger @@ -9,13 +11,13 @@ import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * Creates the Kilo Code tool window content. * - * Wires [KiloAppService] and [KiloProjectService] into a - * [KiloWelcomeUi] panel that shows app + workspace initialization - * status. All UI logic lives in [KiloWelcomeUi]. + * Starts with a [KiloWelcomeUi] status panel. Once the backend reaches + * [KiloAppStatusDto.READY], adds a [ChatPanel] tab and switches to it. */ class KiloToolWindowFactory : ToolWindowFactory { @@ -27,12 +29,32 @@ class KiloToolWindowFactory : ToolWindowFactory { try { val app = service() val workspace = project.service() + val sessions = project.service() val scope = CoroutineScope(SupervisorJob()) - val ui = KiloWelcomeUi(app, workspace, scope) - val content = ContentFactory.getInstance().createContent(ui, "", false) - content.setDisposer(ui) - toolWindow.contentManager.addContent(content) + // Welcome/status tab + val welcome = KiloWelcomeUi(app, workspace, scope) + val statusContent = ContentFactory.getInstance() + .createContent(welcome, "Status", false) + statusContent.setDisposer(welcome) + toolWindow.contentManager.addContent(statusContent) + + // Chat tab — added once the backend is ready + val chatScope = CoroutineScope(SupervisorJob()) + val chat = ChatPanel(sessions, workspace, chatScope) + val chatContent = ContentFactory.getInstance() + .createContent(chat, "Chat", false) + chatContent.setDisposer(chat) + toolWindow.contentManager.addContent(chatContent) + + // Switch to chat tab when ready + scope.launch { + app.state.collect { state -> + if (state.status == KiloAppStatusDto.READY) { + toolWindow.contentManager.setSelectedContent(chatContent) + } + } + } ActionManager.getInstance().getAction("Kilo.Settings")?.let { toolWindow.setTitleActions(listOf(it)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatInputPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatInputPanel.kt new file mode 100644 index 0000000000..1470678fd8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatInputPanel.kt @@ -0,0 +1,75 @@ +package ai.kilocode.client.chat + +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.JButton +import javax.swing.JPanel + +/** + * Chat input area with a text field and send/abort button. + * + * Enter sends the message. Shift+Enter inserts a newline. + * When busy, the button changes to an abort button. + */ +class ChatInputPanel( + private val onSend: (String) -> Unit, + private val onAbort: () -> Unit, +) : JPanel(BorderLayout()) { + + private val area = JBTextArea(3, 40).apply { + lineWrap = true + wrapStyleWord = true + border = JBUI.Borders.empty(4) + emptyText.text = "Type a message..." + } + + private val button = JButton("Send").apply { + addActionListener { handleClick() } + } + + @Volatile + private var busy = false + + init { + border = JBUI.Borders.empty(4, 8) + + area.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER && !e.isShiftDown) { + e.consume() + if (!busy) { + onSend(area.text.trim()) + } + } + } + }) + + add(area, BorderLayout.CENTER) + add(button, BorderLayout.EAST) + } + + fun setBusy(value: Boolean) { + busy = value + button.text = if (value) "Stop" else "Send" + button.icon = if (value) AllIcons.Actions.Suspend else null + } + + fun clearInput() { + area.text = "" + } + + private fun handleClick() { + if (busy) { + onAbort() + } else { + val text = area.text.trim() + if (text.isNotEmpty()) { + onSend(text) + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatPanel.kt new file mode 100644 index 0000000000..c7c85b2459 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatPanel.kt @@ -0,0 +1,191 @@ +package ai.kilocode.client.chat + +import ai.kilocode.client.KiloProjectService +import ai.kilocode.client.KiloSessionService +import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.awt.BorderLayout +import javax.swing.JPanel + +/** + * Main chat panel composing the toolbar, message list, and input area. + * + * Wires [KiloSessionService] for chat operations and [KiloProjectService] + * for provider/agent data. Subscribes to SSE chat events for streaming. + */ +class ChatPanel( + private val sessions: KiloSessionService, + private val workspace: KiloProjectService, + private val cs: CoroutineScope, +) : JPanel(BorderLayout()), Disposable { + + private val messages = MessageListPanel() + private val scroll = JBScrollPane(messages).apply { + border = JBUI.Borders.empty() + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + + private val toolbar = ChatToolbar( + onModeChanged = { agent -> sessions.updateConfig(ConfigUpdateDto(agent = agent)) }, + onModelChanged = { provider, model -> sessions.updateConfig(ConfigUpdateDto(model = "$provider/$model")) }, + ) + + private val input = ChatInputPanel( + onSend = { text -> send(text) }, + onAbort = { sessions.abort() }, + ) + + private var eventJob: Job? = null + private var statusJob: Job? = null + private var wsJob: Job? = null + + init { + add(toolbar, BorderLayout.NORTH) + add(scroll, BorderLayout.CENTER) + add(input, BorderLayout.SOUTH) + + // Watch workspace state for providers/agents + wsJob = cs.launch { + workspace.state.collect { state -> + if (state.status == KiloWorkspaceStatusDto.READY) { + edt { + state.providers?.let { toolbar.setProviders(it) } + state.agents?.let { toolbar.setAgents(it) } + } + } + } + } + + // Watch session statuses for busy/idle state + statusJob = cs.launch { + sessions.statuses.collect { statuses -> + val active = sessions.active.value?.id ?: return@collect + val status = statuses[active] + edt { input.setBusy(status?.type == "busy") } + } + } + + // Watch active session changes + cs.launch { + sessions.active.collect { session -> + edt { + messages.clear() + input.setBusy(false) + } + eventJob?.cancel() + if (session != null) { + loadHistory(session.id) + subscribeEvents() + } + } + } + } + + private fun send(text: String) { + if (text.isBlank()) return + sessions.prompt(text) + input.clearInput() + } + + private fun loadHistory(id: String) { + cs.launch { + val history = sessions.messages() + edt { + messages.clear() + for (msg in history) { + messages.addMessage(msg.info) + for (part in msg.parts) { + val txt = part.text + if (part.type == "text" && txt != null) { + messages.updatePartText(msg.info.id, part.id, txt) + } + } + } + scrollToBottom() + } + } + } + + private fun subscribeEvents() { + eventJob = cs.launch { + sessions.events().collect { event -> + edt { handleEvent(event) } + } + } + } + + private fun handleEvent(event: ChatEventDto) { + when (event) { + is ChatEventDto.MessageUpdated -> { + messages.addMessage(event.info) + scrollToBottom() + } + + is ChatEventDto.PartUpdated -> { + val txt = event.part.text + if (event.part.type == "text" && txt != null) { + messages.updatePartText(event.part.messageID, event.part.id, txt) + scrollToBottom() + } + } + + is ChatEventDto.PartDelta -> { + if (event.field == "text") { + messages.appendDelta(event.messageID, event.partID, event.delta) + scrollToBottom() + } + } + + is ChatEventDto.TurnOpen -> { + input.setBusy(true) + } + + is ChatEventDto.TurnClose -> { + input.setBusy(false) + } + + is ChatEventDto.Error -> { + val msg = event.error?.message ?: event.error?.type ?: "Unknown error" + messages.addError(msg) + input.setBusy(false) + scrollToBottom() + } + + is ChatEventDto.MessageRemoved -> { + messages.removeMessage(event.messageID) + } + } + } + + private fun scrollToBottom() { + val bar = scroll.verticalScrollBar + bar.value = bar.maximum + } + + private fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeLater(block) + } + + override fun dispose() { + eventJob?.cancel() + statusJob?.cancel() + wsJob?.cancel() + cs.cancel() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatToolbar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatToolbar.kt new file mode 100644 index 0000000000..d1dd45085b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/ChatToolbar.kt @@ -0,0 +1,106 @@ +package ai.kilocode.client.chat + +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.FlowLayout +import javax.swing.DefaultComboBoxModel +import javax.swing.JPanel + +/** + * Toolbar with mode (agent) and model selection dropdowns. + * + * Populated from workspace data (providers, agents). Changes + * are forwarded via callbacks to the session service for + * config updates. + */ +class ChatToolbar( + private val onModeChanged: (String) -> Unit, + private val onModelChanged: (String, String) -> Unit, +) : JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), JBUI.scale(2))) { + + private val modeLabel = JBLabel("Mode:") + private val modeCombo = ComboBox().apply { + addActionListener { + val item = selectedItem as? AgentItem ?: return@addActionListener + if (!updating) onModeChanged(item.name) + } + } + + private val modelLabel = JBLabel("Model:") + private val modelCombo = ComboBox().apply { + addActionListener { + val item = selectedItem as? ModelItem ?: return@addActionListener + if (!updating) onModelChanged(item.provider, item.id) + } + } + + @Volatile + private var updating = false + + init { + border = JBUI.Borders.empty(2, 8) + add(modeLabel) + add(modeCombo) + add(modelLabel) + add(modelCombo) + } + + fun setAgents(agents: AgentsDto) { + updating = true + try { + val model = DefaultComboBoxModel() + for (agent in agents.agents) { + model.addElement(AgentItem(agent.name, agent.displayName ?: agent.name)) + } + modeCombo.model = model + // Select the default agent + val idx = agents.agents.indexOfFirst { it.name == agents.default } + if (idx >= 0) modeCombo.selectedIndex = idx + } finally { + updating = false + } + } + + fun setProviders(providers: ProvidersDto) { + updating = true + try { + val model = DefaultComboBoxModel() + for (provider in providers.providers) { + if (provider.id !in providers.connected) continue + for ((id, info) in provider.models) { + model.addElement(ModelItem(provider.id, id, "${provider.name} / ${info.name}")) + } + } + modelCombo.model = model + + // Select the default model + val defaults = providers.defaults + if (defaults.isNotEmpty()) { + val entry = defaults.entries.firstOrNull() + if (entry != null) { + val idx = (0 until model.size).firstOrNull { i -> + val item = model.getElementAt(i) + item.provider == entry.key && item.id == entry.value + } + if (idx != null) modelCombo.selectedIndex = idx + } + } + } finally { + updating = false + } + } +} + +private data class AgentItem(val name: String, val display: String) { + override fun toString() = display +} + +private data class ModelItem(val provider: String, val id: String, val display: String) { + override fun toString() = display +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/MessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/MessageListPanel.kt new file mode 100644 index 0000000000..0efefffab1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/chat/MessageListPanel.kt @@ -0,0 +1,137 @@ +package ai.kilocode.client.chat + +import ai.kilocode.rpc.dto.MessageDto +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.Component +import javax.swing.BoxLayout +import javax.swing.JPanel +import javax.swing.JTextArea +import javax.swing.SwingConstants + +/** + * Scrollable panel displaying chat messages. + * + * Each message is rendered as a role label + text area block. + * Supports incremental text updates via part IDs for streaming. + */ +class MessageListPanel : JPanel() { + + /** Maps messageID to the panel for that message. */ + private val panels = LinkedHashMap() + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = true + background = UIUtil.getPanelBackground() + border = JBUI.Borders.empty(8) + } + + fun addMessage(info: MessageDto) { + if (panels.containsKey(info.id)) return + + val block = MessageBlock(info) + panels[info.id] = block + add(block) + revalidate() + repaint() + } + + fun updatePartText(messageID: String, partID: String, text: String) { + val block = panels[messageID] ?: return + block.setText(partID, text) + } + + fun appendDelta(messageID: String, partID: String, delta: String) { + val block = panels[messageID] ?: return + block.appendDelta(partID, delta) + } + + fun removeMessage(messageID: String) { + val block = panels.remove(messageID) ?: return + 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, 8) + alignmentX = Component.LEFT_ALIGNMENT + } + add(label) + revalidate() + repaint() + } + + fun clear() { + panels.clear() + removeAll() + revalidate() + repaint() + } +} + +/** + * A single message block: role header + text content area. + */ +private class MessageBlock(info: MessageDto) : JPanel(BorderLayout()) { + private val parts = LinkedHashMap() + private val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + } + + init { + isOpaque = false + border = JBUI.Borders.empty(6, 0) + alignmentX = Component.LEFT_ALIGNMENT + + val role = when (info.role) { + "user" -> "You" + "assistant" -> "Assistant" + else -> info.role + } + + val header = JBLabel(role).apply { + font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or java.awt.Font.BOLD) + foreground = when (info.role) { + "user" -> UIUtil.getLabelForeground() + else -> JBColor(0x4A90D9, 0x6CB4EE) + } + border = JBUI.Borders.empty(0, 0, 4, 0) + horizontalAlignment = SwingConstants.LEFT + } + + add(header, BorderLayout.NORTH) + add(body, BorderLayout.CENTER) + } + + fun setText(partID: String, text: String) { + val area = parts.getOrPut(partID) { createArea().also { body.add(it) } } + area.text = text + body.revalidate() + } + + fun appendDelta(partID: String, delta: String) { + val area = parts.getOrPut(partID) { createArea().also { body.add(it) } } + area.append(delta) + body.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 = Component.LEFT_ALIGNMENT + } +} 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 accadf58fc..a482176d67 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 @@ -1,5 +1,9 @@ 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.PromptDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -45,4 +49,21 @@ interface KiloSessionRpcApi : RemoteApi { /** Get the effective directory for a session (worktree or fallback). */ suspend fun getDirectory(id: String, fallback: String): String + + // ------ chat ------ + + /** Send a prompt to a session (fire-and-forget). */ + suspend fun prompt(id: String, directory: String, prompt: PromptDto) + + /** Abort ongoing processing for a session. */ + suspend fun abort(id: String, directory: String) + + /** Load message history for a session. */ + suspend fun messages(id: String, directory: String): List + + /** Subscribe to streaming chat events for a specific session. */ + suspend fun events(id: String, directory: String): Flow + + /** Update config (model, agent/mode, temperature). */ + suspend fun updateConfig(directory: String, config: ConfigUpdateDto) } 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 new file mode 100644 index 0000000000..06ead59e46 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -0,0 +1,144 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// --- Messages --- + +@Serializable +data class MessageDto( + val id: String, + val sessionID: String, + val role: String, + val time: MessageTimeDto, + val agent: String? = null, + val providerID: String? = null, + val modelID: String? = null, + val parentID: String? = null, + val cost: Double? = null, + val tokens: TokensDto? = null, + val error: MessageErrorDto? = null, +) + +@Serializable +data class MessageTimeDto( + val created: Double, + val completed: Double? = null, +) + +@Serializable +data class TokensDto( + val input: Long, + val output: Long, + val reasoning: Long, + val cacheRead: Long, + val cacheWrite: Long, +) + +@Serializable +data class MessageErrorDto( + val type: String, + val message: String? = null, +) + +@Serializable +data class MessageWithPartsDto( + val info: MessageDto, + val parts: List, +) + +// --- Parts (simplified for basic chat) --- + +@Serializable +data class PartDto( + val id: String, + val sessionID: String, + val messageID: String, + val type: String, + val text: String? = null, + val tool: String? = null, + val state: String? = null, + val title: String? = null, +) + +// --- Prompt Input --- + +@Serializable +data class PromptDto( + val parts: List, + val providerID: String? = null, + val modelID: String? = null, + val agent: String? = null, +) + +@Serializable +data class PromptPartDto( + val type: String, + val text: String, +) + +// --- Streaming Events --- + +@Serializable +sealed class ChatEventDto { + + @Serializable + @SerialName("message.updated") + data class MessageUpdated( + val sessionID: String, + val info: MessageDto, + ) : ChatEventDto() + + @Serializable + @SerialName("part.updated") + data class PartUpdated( + val sessionID: String, + val part: PartDto, + ) : ChatEventDto() + + @Serializable + @SerialName("part.delta") + data class PartDelta( + val sessionID: String, + val messageID: String, + val partID: String, + val field: String, + val delta: String, + ) : ChatEventDto() + + @Serializable + @SerialName("turn.open") + data class TurnOpen( + val sessionID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("turn.close") + data class TurnClose( + val sessionID: String, + val reason: String, + ) : ChatEventDto() + + @Serializable + @SerialName("error") + data class Error( + val sessionID: String?, + val error: MessageErrorDto? = null, + ) : ChatEventDto() + + @Serializable + @SerialName("message.removed") + data class MessageRemoved( + val sessionID: String, + val messageID: String, + ) : ChatEventDto() +} + +// --- Config Update --- + +@Serializable +data class ConfigUpdateDto( + val model: String? = null, + val agent: String? = null, + val temperature: Double? = null, +)