Merge pull request #10552 from Kilo-Org/quaint-airbus

feat(jetbrains): support plan follow-ups and todo rendering
This commit is contained in:
Kirill Kalishev
2026-05-26 13:39:00 -04:00
committed by GitHub
58 changed files with 2065 additions and 102 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support starting implementation from completed planning sessions in JetBrains.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show todo updates as checklist cards in JetBrains session transcripts.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open completed plan file links from JetBrains session transcripts.
@@ -36,7 +36,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -70,6 +72,7 @@ class KiloBackendAppService private constructor(
private val cs: CoroutineScope,
private val server: CliServer,
private val log: KiloLog,
private val loadTimeoutMs: Long,
) : Disposable {
/** IntelliJ service injection entry point. */
@@ -77,24 +80,27 @@ class KiloBackendAppService private constructor(
cs,
KiloBackendCliManager(),
KiloLog.create(KiloBackendAppService::class.java),
APP_LOAD_TIMEOUT_MS,
)
companion object {
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
private const val APP_LOAD_TIMEOUT_MS = 30_000L
/** Test factory — no IntelliJ deps needed. */
internal fun create(
cs: CoroutineScope,
server: CliServer,
log: KiloLog,
) = KiloBackendAppService(cs, server, log)
loadTimeoutMs: Long = APP_LOAD_TIMEOUT_MS,
) = KiloBackendAppService(cs, server, log, loadTimeoutMs)
}
private val mutex = Mutex()
private val connection = KiloConnectionService(cs, server, onReconnect = {
cs.launch { reconnect() }
}, log = log)
}, appLoadTimeoutMs = loadTimeoutMs, log = log)
private var watcher: Job? = null
private var eventWatcher: Job? = null
@@ -282,7 +288,8 @@ class KiloBackendAppService private constructor(
var warns: List<ConfigWarning> = emptyList()
try {
coroutineScope {
withTimeout(loadTimeoutMs) {
coroutineScope {
launch {
val result = fetchProfile()
val status = when {
@@ -323,11 +330,11 @@ class KiloBackendAppService private constructor(
throw LoadFailure(err)
}
}
launch {
warns = fetchWarnings()
}
}
warns = fetchWarnings()
ensureActive()
profile = prof
config = cfg
@@ -346,6 +353,16 @@ class KiloBackendAppService private constructor(
)
log.info("Application started — config, profile, notifications loaded")
startWatchingGlobalSseEvents()
} catch (e: TimeoutCancellationException) {
val err = LoadError(
resource = "app",
detail = "Timed out loading app data after ${loadTimeoutMs}ms",
)
log.warn("Application start timed out after ${loadTimeoutMs}ms")
setAppError(
message = "Failed to load required data",
errors = errors.toList() + err,
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -392,7 +409,7 @@ class KiloBackendAppService private constructor(
* as failures.
*/
private suspend fun fetchProfile(): FetchResult<KiloProfile200Response?> {
val client = connection.api
val client = connection.appLoadApi
?: return FetchResult.ok(null)
return try {
val response = client.kiloProfile()
@@ -420,7 +437,7 @@ class KiloBackendAppService private constructor(
}
private suspend fun fetchConfig(): FetchResult<Config> {
val client = connection.api
val client = connection.appLoadApi
?: return FetchResult.fail("config", detail = "Not connected")
return try {
FetchResult.ok(client.globalConfigGet())
@@ -432,7 +449,7 @@ class KiloBackendAppService private constructor(
}
private suspend fun fetchNotifications(): FetchResult<List<KiloNotifications200ResponseInner>> {
val client = connection.api
val client = connection.appLoadApi
?: return FetchResult.fail("notifications", detail = "Not connected")
return try {
FetchResult.ok(client.kiloNotifications())
@@ -444,7 +461,7 @@ class KiloBackendAppService private constructor(
}
private suspend fun fetchWarnings(): List<ConfigWarning> {
val client = connection.api ?: return emptyList()
val client = connection.appLoadApi ?: return emptyList()
return try {
client.configWarnings().map(::warning)
} catch (e: Exception) {
@@ -48,6 +48,7 @@ class KiloBackendChatManager(
"message.part.removed",
"session.turn.open",
"session.turn.close",
"session.created",
"session.error",
"session.status",
"session.updated",
@@ -44,6 +44,7 @@ data class SseEvent(val type: String, val data: String)
*
* Uses two separate OkHttp clients mirroring the VS Code architecture:
* - [apiClient]: no call/read timeout — used for the generated API client and SSE
* - app-load client: bounded timeout — used for startup REST calls
* - [healthClient]: 3 s timeout — used only for `/global/health` polling
*
* The generated [DefaultApi] is configured with [apiClient] and exposed via [api]
@@ -59,9 +60,23 @@ class KiloConnectionService(
private val cs: CoroutineScope,
private val server: CliServer,
private val onReconnect: () -> Unit,
private val log: KiloLog = KiloLog.create(KiloConnectionService::class.java),
private val log: KiloLog,
private val appLoadTimeoutMs: Long,
) {
constructor(
cs: CoroutineScope,
server: CliServer,
onReconnect: () -> Unit,
) : this(cs, server, onReconnect, KiloLog.create(KiloConnectionService::class.java), 30_000L)
constructor(
cs: CoroutineScope,
server: CliServer,
onReconnect: () -> Unit,
log: KiloLog,
) : this(cs, server, onReconnect, log, 30_000L)
companion object {
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
@@ -81,6 +96,9 @@ class KiloConnectionService(
/** OkHttp client used for API calls — no call/read timeout. Null when disconnected. */
var apiClient: OkHttpClient? = null
private set
var appLoadApi: DefaultApi? = null
private set
private var appLoadClient: OkHttpClient? = null
private var healthClient: OkHttpClient? = null
/** Port the CLI server is listening on. Zero when disconnected. */
var port = 0
@@ -175,12 +193,15 @@ class KiloConnectionService(
// Create dual OkHttp clients (bundled — no IntelliJ platform deps)
val ac = KiloBackendHttpClients.api(password)
val lc = KiloBackendHttpClients.appLoad(password, appLoadTimeoutMs)
val hc = KiloBackendHttpClients.health(password)
apiClient = ac
appLoadClient = lc
healthClient = hc
// Configure generated API client with the no-timeout api client
api = DefaultApi(basePath = "http://127.0.0.1:$port", client = ac)
appLoadApi = DefaultApi(basePath = "http://127.0.0.1:$port", client = lc)
startSse()
startHeartbeatWatcher()
@@ -327,8 +348,11 @@ class KiloConnectionService(
private fun close() {
api = null
appLoadApi = null
apiClient?.let { KiloBackendHttpClients.shutdown(it) }
apiClient = null
appLoadClient?.let { KiloBackendHttpClients.shutdown(it) }
appLoadClient = null
healthClient?.let { KiloBackendHttpClients.shutdown(it) }
healthClient = null
}
@@ -7,10 +7,11 @@ import java.util.Base64
import java.util.concurrent.TimeUnit
/**
* Factory for the two OkHttp clients used by the plugin.
* Factory for the OkHttp clients used by the plugin.
*
* Mirrors the VS Code architecture:
* - [api] client has no call/read timeout (streaming ops like prompt/SSE can run long)
* - [appLoad] client has a bounded timeout for startup REST calls
* - [health] client has a short 3 s timeout and a small dedicated connection pool
*
* Both clients bundle Basic Auth via an interceptor and are fully independent
@@ -30,6 +31,18 @@ object KiloBackendHttpClients {
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
/** App-load client — bounded timeout for required startup REST calls. */
fun appLoad(password: String, timeoutMs: Long): OkHttpClient {
val timeout = timeoutMs.coerceAtLeast(1L)
return OkHttpClient.Builder()
.addInterceptor(auth(password))
.connectTimeout(CONNECT_TIMEOUT_MS.coerceAtMost(timeout), TimeUnit.MILLISECONDS)
.callTimeout(timeout, TimeUnit.MILLISECONDS)
.readTimeout(timeout, TimeUnit.MILLISECONDS)
.connectionPool(ConnectionPool(2, 30, TimeUnit.SECONDS))
.build()
}
/** Health client — short timeout, dedicated connection pool. */
fun health(password: String): OkHttpClient =
OkHttpClient.Builder()
@@ -32,6 +32,7 @@ import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionSummaryDto
import ai.kilocode.rpc.dto.SessionTimeDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TodoViewDto
import ai.kilocode.rpc.dto.TokensDto
import ai.kilocode.rpc.dto.ToolRefDto
import kotlinx.serialization.json.Json
@@ -132,6 +133,13 @@ object KiloCliDataParser {
ChatEventDto.TurnClose(sid, reason)
}
"session.created" -> {
val info = props["info"]?.jsonObject ?: return null
val dto = parseSessionObject(info)
val sid = props.str("sessionID") ?: dto.id.takeIf { it.isNotBlank() } ?: return null
ChatEventDto.SessionCreated(sid, dto)
}
"session.error" -> {
val sid = props.str("sessionID")
val err = props["error"]?.jsonObject?.let { parseError(it) }
@@ -216,14 +224,7 @@ object KiloCliDataParser {
"todo.updated" -> {
val sid = props.str("sessionID") ?: return null
val todos = props["todos"]?.jsonArray?.map { elem ->
val t = elem.jsonObject
TodoDto(
content = t.str("content") ?: "",
status = t.str("status") ?: "pending",
priority = t.str("priority") ?: "medium",
)
} ?: emptyList()
val todos = parseTodos(props["todos"])
ChatEventDto.TodoUpdated(sid, todos)
}
@@ -460,6 +461,15 @@ object KiloCliDataParser {
val tokens = obj["tokens"]?.jsonObject
val top = obj.map("metadata")
val meta = state.map("metadata") + top
val input = state?.get("input").obj()
val stateMeta = state?.get("metadata").obj()
val topMeta = obj["metadata"].obj()
val todos = sequenceOf(topMeta?.get("todos"), stateMeta?.get("todos"), input?.get("todos"))
.firstNotNullOfOrNull(::parseTodosOrNull)
?: emptyList()
val view = sequenceOf(topMeta?.get("view"), stateMeta?.get("view"))
.mapNotNull(::parseTodoView)
.firstOrNull()
return PartDto(
id = obj.str("id") ?: "",
sessionID = obj.str("sessionID") ?: "",
@@ -475,12 +485,46 @@ object KiloCliDataParser {
output = state?.str("output"),
error = state?.str("error"),
time = obj.time("time") ?: state.time("time"),
todos = todos,
todoView = view,
reason = obj.str("reason"),
cost = obj.num("cost"),
tokens = tokens?.let(::parseTokens),
)
}
internal fun parseTodos(raw: JsonElement?): List<TodoDto> {
return parseTodosOrNull(raw) ?: emptyList()
}
private fun parseTodosOrNull(raw: JsonElement?): List<TodoDto>? {
val arr = runCatching { raw?.jsonArray }.getOrNull() ?: return null
return arr.mapNotNull { elem ->
val obj = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null
parseTodo(obj)
}
}
private fun parseTodo(obj: JsonObject) = TodoDto(
content = obj.str("content") ?: "",
status = obj.str("status") ?: "pending",
priority = obj.str("priority") ?: "medium",
changed = obj.flag("changed", false),
)
internal fun parseTodoView(raw: JsonElement?): TodoViewDto? {
val obj = runCatching { raw?.jsonObject }.getOrNull() ?: return null
val rawTodos = runCatching { obj["todos"]?.jsonArray }.getOrNull() ?: return null
val todos = parseTodos(rawTodos)
return TodoViewDto(
mode = obj.str("mode") ?: "full",
todos = todos,
hiddenBefore = obj.long("hiddenBefore")?.safeInt() ?: 0,
hiddenAfter = obj.long("hiddenAfter")?.safeInt() ?: 0,
changed = obj.long("changed")?.safeInt() ?: 0,
)
}
private fun parseTokens(obj: JsonObject): TokensDto {
val cache = obj["cache"]?.jsonObject
return TokensDto(
@@ -542,18 +586,26 @@ object KiloCliDataParser {
val qo = q.jsonObject
val options = qo["options"]?.jsonArray?.map { o ->
val oo = o.jsonObject
QuestionOptionDto(oo.str("label") ?: "", oo.str("description") ?: "")
QuestionOptionDto(
label = oo.str("label") ?: "",
description = oo.str("description") ?: "",
labelKey = oo.str("labelKey"),
descriptionKey = oo.str("descriptionKey"),
mode = oo.str("mode"),
)
} ?: emptyList()
QuestionInfoDto(
question = qo.str("question") ?: "",
header = qo.str("header") ?: "",
options = options,
multiple = qo.str("multiple") == "true",
custom = qo.str("custom") != "false",
multiple = qo.flag("multiple", false),
custom = qo.flag("custom", true),
questionKey = qo.str("questionKey"),
headerKey = qo.str("headerKey"),
)
} ?: emptyList()
val ref = toolRef(obj)
return QuestionRequestDto(id, sid, questions, ref)
return QuestionRequestDto(id, sid, questions, ref, blocking = obj.flag("blocking", false))
}
internal fun parseModelFavorites(raw: JsonElement?): List<ModelSelectionDto> {
@@ -869,6 +921,11 @@ private fun JsonObject.long(key: String): Long? =
private fun JsonObject?.bool(key: String): Boolean =
this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false
private fun JsonObject.flag(key: String, default: Boolean): Boolean {
val prim = this[key]?.jsonPrimitive ?: return default
return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull() ?: default
}
private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
private fun JsonObject?.map(key: String): Map<String, String> {
@@ -124,6 +124,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
is ChatEventDto.PartRemoved -> event.sessionID
is ChatEventDto.TurnOpen -> event.sessionID
is ChatEventDto.TurnClose -> event.sessionID
is ChatEventDto.SessionCreated -> event.sessionID
is ChatEventDto.Error -> event.sessionID
is ChatEventDto.MessageRemoved -> event.sessionID
is ChatEventDto.PermissionAsked -> event.sessionID
@@ -138,7 +139,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
is ChatEventDto.SessionDiffChanged -> event.sessionID
is ChatEventDto.TodoUpdated -> event.sessionID
}
val passes = sid == null || sid == id
val passes = event is ChatEventDto.SessionCreated || sid == null || sid == id
if (passes) LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" }
else LOG.debug { "${ChatLogSummary.sid(id)} pass=false srcSid=$sid ${ChatLogSummary.eventBody(event)}" }
if (passes && event is ChatEventDto.SessionStatusChanged && event.status.type != "busy") {
@@ -15,6 +15,7 @@ import ai.kilocode.backend.workspace.ModelInfo
import ai.kilocode.backend.workspace.ProviderData
import ai.kilocode.backend.workspace.ProviderInfo
import ai.kilocode.backend.workspace.SkillInfo
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.AgentsDto
@@ -28,14 +29,28 @@ import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.ProviderDto
import ai.kilocode.rpc.dto.ProvidersDto
import ai.kilocode.rpc.dto.SkillDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.project.Project
import com.intellij.openapi.components.service
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.LocalFileSystem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.coroutines.resume
/**
* Backend implementation of [KiloWorkspaceRpcApi].
@@ -45,6 +60,9 @@ import kotlinx.coroutines.flow.map
* directory (including worktrees) can get a workspace.
*/
class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
companion object {
private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java)
}
private val app: KiloBackendAppService get() = service()
@@ -83,6 +101,72 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
manager.get(directory).reload()
}
override suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
val item = clean(path) ?: return emptyList()
val file = file(item) ?: return emptyList()
val bases = listOf(directory) + ProjectManager.getInstance().openProjects
.asSequence()
.filter { !it.isDefault }
.mapNotNull { it.basePath }
.filter { it != directory }
.toList()
val paths = if (file.isAbsolute) listOf(file) else bases.mapNotNull { base ->
file(base)?.resolve(file)?.normalize()
}
val found = linkedMapOf<String, WorkspaceFileDto>()
for (target in paths) {
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: continue
found[vf.path] = WorkspaceFileDto(vf.path, vf.name, vf.isDirectory)
}
return found.values.toList()
}
override suspend fun openFile(path: String): Boolean {
val item = clean(path) ?: return false
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false
val project = project(target) ?: run {
LOG.warn("No project available to open file: $path")
return false
}
navigate(project, vf)
return true
}
private fun clean(path: String): String? {
val raw = path.trim().takeIf { it.isNotBlank() } ?: return null
return try {
val cut = raw.substringBefore('#').substringBefore('?')
val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8)
Path.of(decoded.replace('\\', '/')).normalize().toString()
} catch (e: Exception) {
LOG.debug { "Failed to normalize workspace file path: $path (${e.message})" }
null
}
}
private fun file(path: String): Path? = try {
Path.of(path).normalize()
} catch (e: InvalidPathException) {
LOG.debug { "Invalid workspace file path: $path (${e.message})" }
null
}
private suspend fun navigate(project: Project, file: VirtualFile) = suspendCancellableCoroutine { cont ->
ApplicationManager.getApplication().invokeLater({
OpenFileDescriptor(project, file).navigate(true)
if (cont.isActive) cont.resume(Unit)
}, ModalityState.any())
}
private fun project(path: Path): Project? {
val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault }
return projects.firstOrNull { item ->
val base = item.basePath?.let(::file) ?: return@firstOrNull false
path.startsWith(base)
} ?: projects.firstOrNull()
}
// ------ mapping: domain model → DTO ------
private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto =
@@ -37,8 +37,8 @@ class KiloBackendAppServiceTest {
mock.close()
}
private fun create(): KiloBackendAppService =
KiloBackendAppService.create(scope, FakeCliServer(mock), log)
private fun create(loadTimeoutMs: Long = 30_000L): KiloBackendAppService =
KiloBackendAppService.create(scope, FakeCliServer(mock), log, loadTimeoutMs)
@Test
fun `full lifecycle reaches Ready`() = runBlocking {
@@ -404,6 +404,104 @@ class KiloBackendAppServiceTest {
}
}
@Test
fun `hung app load transitions from Loading to Error`() = runBlocking {
val gate = CountDownLatch(1)
mock.responseGate = gate
val svc = create(loadTimeoutMs = 300L)
try {
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Loading }
}
val err = withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Error }
} as KiloAppState.Error
assertEquals("Failed to load required data", err.message)
assertTrue(err.errors.any { it.detail?.contains("timeout", ignoreCase = true) == true })
} finally {
gate.countDown()
}
}
@Test
fun `hung warnings do not prevent Ready`() = runBlocking {
val gate = CountDownLatch(1)
mock.warningsGate = gate
val svc = create(loadTimeoutMs = 300L)
try {
svc.connect()
val ready = withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
} as KiloAppState.Ready
assertTrue(ready.data.warnings.isEmpty())
assertTrue(svc.warnings.isEmpty())
} finally {
gate.countDown()
}
}
@Test
fun `restart during Loading cancels stale load and reaches Ready`() = runBlocking {
val gate = CountDownLatch(1)
mock.responseGate = gate
val svc = create(loadTimeoutMs = 500L)
try {
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Loading }
}
gate.countDown()
svc.restart()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertIs<KiloAppState.Ready>(svc.appState.value)
assertFalse(log.messages.any { it.contains("Application start timed out") })
} finally {
gate.countDown()
}
}
@Test
fun `reinstall during Loading cancels stale load and reaches Ready`() = runBlocking {
val gate = CountDownLatch(1)
mock.responseGate = gate
val svc = create(loadTimeoutMs = 500L)
try {
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Loading }
}
gate.countDown()
svc.reinstall()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertIs<KiloAppState.Ready>(svc.appState.value)
assertFalse(log.messages.any { it.contains("Application start timed out") })
} finally {
gate.countDown()
}
}
@Test
fun `SSE config updated event refreshes config`() = runBlocking {
mock.config = """{"model":"initial"}"""
@@ -199,6 +199,93 @@ class KiloCliDataParserTest {
assertEquals(12.0, result.part.time?.end)
}
@Test
fun `parseChatEvent - todowrite part parses typed todo metadata`() {
val data = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "ses_1",
"part": {
"id": "part_todo",
"sessionID": "ses_1",
"messageID": "msg_1",
"type": "tool",
"tool": "todowrite",
"callID": "call_todo",
"metadata": {
"todos": [
{"content": "Top wins", "status": "completed", "priority": "high", "changed": true}
],
"view": {
"mode": "compact",
"hiddenBefore": 1,
"hiddenAfter": 2,
"changed": 1,
"todos": [
{"content": "Visible", "status": "pending", "priority": "medium", "changed": true}
]
}
},
"state": {
"status": "completed",
"input": {
"todos": [
{"content": "Input fallback", "status": "pending", "priority": "low"}
]
},
"metadata": {
"todos": [
{"content": "State fallback", "status": "in_progress", "priority": "medium"}
]
}
}
}
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated
assertEquals("Top wins", result.part.todos.single().content)
assertEquals(true, result.part.todos.single().changed)
assertEquals("compact", result.part.todoView?.mode)
assertEquals(1, result.part.todoView?.hiddenBefore)
assertEquals(2, result.part.todoView?.hiddenAfter)
assertEquals(1, result.part.todoView?.changed)
assertEquals("Visible", result.part.todoView?.todos?.single()?.content)
assertEquals(true, result.part.todoView?.todos?.single()?.changed)
assertEquals("[{\"content\":\"Input fallback\",\"status\":\"pending\",\"priority\":\"low\"}]", result.part.input["todos"])
assertTrue(result.part.metadata["view"]?.contains("compact") == true)
}
@Test
fun `parseChatEvent - empty top metadata todos overrides fallback todos`() {
val data = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "ses_1",
"part": {
"id": "part_todo",
"sessionID": "ses_1",
"messageID": "msg_1",
"type": "tool",
"tool": "todowrite",
"metadata": { "todos": [] },
"state": {
"status": "completed",
"metadata": {
"todos": [
{"content": "Fallback", "status": "pending", "priority": "medium"}
]
}
}
}
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated
assertEquals(emptyList(), result.part.todos)
}
@Test
fun `parseChatEvent - bash tool part preserves command output and error`() {
val data = globalEvent("""
@@ -415,6 +502,30 @@ class KiloCliDataParserTest {
assertEquals(2, result.session.summary?.files)
}
@Test
fun `parseChatEvent - session created`() {
val data = globalEvent("""
"type": "session.created",
"properties": {
"sessionID": "ses_new",
"info": {
"id": "ses_new",
"projectID": "proj_1",
"directory": "/test",
"title": "Implementation",
"version": "1",
"time": { "created": 1.0, "updated": 2.0 }
}
}
""")
val result = KiloCliDataParser.parseChatEvent("session.created", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionCreated)
assertEquals("ses_new", result.sessionID)
assertEquals("/test", result.info.directory)
}
@Test
fun `parseChatEvent - session diff`() {
val data = globalEvent("""
@@ -455,7 +566,7 @@ class KiloCliDataParserTest {
"properties": {
"sessionID": "ses_1",
"todos": [
{"content": "Write tests", "status": "in_progress", "priority": "high"},
{"content": "Write tests", "status": "in_progress", "priority": "high", "changed": true},
{"content": "Review PR", "status": "pending", "priority": "medium"}
]
}
@@ -468,6 +579,8 @@ class KiloCliDataParserTest {
assertEquals(2, result.todos.size)
assertEquals("Write tests", result.todos[0].content)
assertEquals("high", result.todos[0].priority)
assertEquals(true, result.todos[0].changed)
assertEquals(false, result.todos[1].changed)
}
// ---- session status events ----
@@ -597,6 +710,48 @@ class KiloCliDataParserTest {
assertEquals("A", result.request.questions[0].options[0].label)
}
@Test
fun `parseChatEvent - plan follow-up question preserves fields`() {
val data = globalEvent("""
"type": "question.asked",
"properties": {
"id": "q_plan",
"sessionID": "ses_1",
"blocking": true,
"questions": [{
"question": "Ready to implement?",
"questionKey": "plan.followup.question",
"header": "Implement",
"headerKey": "plan.followup.header",
"multiple": false,
"custom": true,
"options": [{
"label": "Continue here",
"labelKey": "plan.followup.answer.continue",
"description": "Implement the plan in this session",
"descriptionKey": "plan.followup.answer.continue.description",
"mode": "code"
}]
}],
"tool": null
}
""")
val result = KiloCliDataParser.parseChatEvent("question.asked", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.QuestionAsked)
assertEquals(true, result.request.blocking)
val item = result.request.questions.single()
assertEquals("plan.followup.question", item.questionKey)
assertEquals("plan.followup.header", item.headerKey)
assertEquals(false, item.multiple)
assertEquals(true, item.custom)
val opt = item.options.single()
assertEquals("plan.followup.answer.continue", opt.labelKey)
assertEquals("plan.followup.answer.continue.description", opt.descriptionKey)
assertEquals("code", opt.mode)
}
@Test
fun `parseChatEvent - question replied`() {
val data = globalEvent("""
@@ -732,11 +887,16 @@ class KiloCliDataParserTest {
@Test
fun `parseQuestionRequests - parses list`() {
val raw = """[
{"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]}
{"id": "q1", "sessionID": "s1", "blocking": true, "questions": [{"question": "pick", "questionKey": "q.key", "header": "h", "headerKey": "h.key", "multiple": true, "custom": false, "options": [{"label": "A", "description": "B", "mode": "code"}]}]}
]"""
val result = KiloCliDataParser.parseQuestionRequests(raw)
assertEquals(1, result.size)
assertEquals("q1", result[0].id)
assertEquals(true, result[0].blocking)
assertEquals("q.key", result[0].questions[0].questionKey)
assertEquals(true, result[0].questions[0].multiple)
assertEquals(false, result[0].questions[0].custom)
assertEquals("code", result[0].questions[0].options[0].mode)
}
}
@@ -94,6 +94,9 @@ class MockCliServer : AutoCloseable {
/** Optional gate for REST responses; SSE stays unblocked so the app can enter Loading. */
@Volatile var responseGate: CountDownLatch? = null
/** Optional gate for config warnings only. */
@Volatile var warningsGate: CountDownLatch? = null
/** Request counts by bare path (e.g. "/session" or "/global/config"). Thread-safe. */
private val counts = ConcurrentHashMap<String, AtomicInteger>()
@@ -223,6 +226,7 @@ class MockCliServer : AutoCloseable {
val delay = responseDelay
if (delay > 0) Thread.sleep(delay)
if (bare != "/global/event") responseGate?.await()
if (bare.startsWith("/config/warnings")) warningsGate?.await()
when {
path == "/global/health" -> respond(output, 200, health)
@@ -1,7 +1,6 @@
package ai.kilocode.client.actions
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
@@ -13,7 +12,6 @@ class ReinstallKiloAction : AnAction(), DumbAware {
}
override fun update(e: AnActionEvent) {
val status = service<KiloAppService>().state.value.status
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
e.presentation.isEnabled = true
}
}
@@ -1,7 +1,6 @@
package ai.kilocode.client.actions
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
@@ -13,7 +12,6 @@ class RestartKiloAction : AnAction(), DumbAware {
}
override fun update(e: AnActionEvent) {
val status = service<KiloAppService>().state.value.status
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
e.presentation.isEnabled = true
}
}
@@ -5,6 +5,7 @@ package ai.kilocode.client.app
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.openapi.components.Service
import ai.kilocode.log.KiloLog
import fleet.rpc.client.durable
@@ -98,4 +99,23 @@ class KiloWorkspaceService internal constructor(
}
}
}
suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
return try {
call { files(directory, path) }
} catch (e: Exception) {
LOG.warn("workspace file lookup failed for directory=$directory path=$path", e)
emptyList()
}
}
suspend fun openPath(directory: String, path: String): Boolean {
val match = files(directory, path).firstOrNull() ?: return false
return try {
call { openFile(match.path) }
} catch (e: Exception) {
LOG.warn("workspace file open failed for path=${match.path}", e)
false
}
}
}
@@ -2,6 +2,7 @@ package ai.kilocode.client.session
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.migration.KiloMigrationService
import ai.kilocode.client.migration.MigrationUiController
@@ -36,11 +37,11 @@ import ai.kilocode.log.ChatLogSummary
import com.intellij.util.ui.JBUI
import ai.kilocode.log.KiloLog
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.colors.EditorColorsListener
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
@@ -72,6 +73,7 @@ class SessionUi(
ref: SessionRef? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
private val manager: SessionManager? = null,
private val workspaces: KiloWorkspaceService = service(),
private val migration: MigrationUiController = service<KiloMigrationService>(),
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget {
@@ -212,7 +214,7 @@ class SessionUi(
progressBody = load
question = QuestionView(
project = project,
reply = { id, dto -> controller.replyQuestion(id, dto) },
reply = { id, dto, opts -> controller.replyQuestion(id, dto, opts) },
reject = { id -> controller.rejectQuestion(id) },
scroll = { scroll.followBottom(true) },
)
@@ -220,7 +222,7 @@ class SessionUi(
reply = { id, dto -> controller.replyPermission(id, dto) },
)
login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() })
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login)
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -416,6 +418,12 @@ class SessionUi(
prompt.clear()
}
private fun openFile(path: String) {
cs.launch {
workspaces.openPath(workspace.directory, path)
}
}
private fun onStateChanged(state: SessionState) {
prompt.setBusy(state.isBusy())
load.setState(state)
@@ -26,6 +26,8 @@ import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.ProfileStatusDto
@@ -50,6 +52,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import java.awt.Component
import java.nio.file.Path
/**
* Session lifecycle orchestrator for a single session.
@@ -82,11 +85,15 @@ class SessionController(
) : Disposable {
private data class OrganizationTarget(val org: String?)
private data class Followup(val dir: String, val time: Long)
private data class Pref(val agent: String?, val model: String?, val variants: List<String>, val variant: String?, val reset: Boolean)
companion object {
private val LOG = KiloLog.create(SessionController::class.java)
internal const val RECENT_LIMIT = 5
internal const val DISPLAY_DELAY_MS = 1_000L
private const val FOLLOWUP_TTL_MS = 30_000L
private const val FOLLOWUP_NEW_SESSION = "Start new session"
}
init {
@@ -126,6 +133,11 @@ class SessionController(
private var lastProfile: ProfileDto? = null
private var target: OrganizationTarget? = null
private var loginRetry: PromptDto? = null
private var followup: Followup? = null
private var agentTime: Double? = null
private var prefModel: String? = null
private var prefAgent: String? = null
private var modelTime: Double? = null
val ready: Boolean get() = model.isReady()
internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession
@@ -271,6 +283,10 @@ class SessionController(
fun selectAgent(name: String) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name" }
agentTime = null
modelTime = null
prefModel = null
prefAgent = null
cs.launch {
try {
sessions.updateConfig(directory, ConfigUpdateDto(agent = name))
@@ -290,6 +306,9 @@ class SessionController(
val agent = model.agent ?: return
val key = "$provider/$id"
if (item(key) == null && model.workspace.providers != null) return
modelTime = null
prefModel = null
prefAgent = null
app.selectModel(agent, provider, id)
selectResolvedModel(key)
model.modelOverride = model.defaultModel != model.model
@@ -350,14 +369,22 @@ class SessionController(
updateModel { model.setState(SessionState.AwaitingPermission(perm)) }
}
fun replyQuestion(requestId: String, answers: QuestionReplyDto) {
fun replyQuestion(requestId: String, answers: QuestionReplyDto, options: List<List<String>> = answers.answers) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" }
val current = model.state
followup = if (current is SessionState.AwaitingQuestion
&& current.question.id == requestId
&& options.any { labels -> labels.any { it.trim() == FOLLOWUP_NEW_SESSION } }
) {
Followup(directory, System.currentTimeMillis())
} else null
cs.launch {
try {
sessions.replyQuestion(requestId, directory, answers)
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" }
} catch (e: Exception) {
edt { followup = null }
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
@@ -365,6 +392,7 @@ class SessionController(
fun rejectQuestion(requestId: String) {
assertEdt()
followup = null
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true" }
cs.launch {
try {
@@ -489,6 +517,7 @@ class SessionController(
if (sid != id) return@runEdt
updateModel {
this@SessionController.model.loadHistory(items)
syncHistoryAgent(items)
if (session != null) this@SessionController.model.setSession(session)
}
}
@@ -536,6 +565,7 @@ class SessionController(
setRecentSessionsState(RecentsState.Idle)
updateModel {
this@SessionController.model.loadHistory(items)
syncHistoryAgent(items)
this@SessionController.model.setSession(session)
}
}
@@ -701,6 +731,7 @@ class SessionController(
when (event) {
is ChatEventDto.MessageUpdated -> {
val added = model.upsertMessage(event.info)
syncMessagePrefs(event.info)
if (added) showSession()
}
@@ -733,10 +764,9 @@ class SessionController(
is ChatEventDto.TurnClose -> {
partType = null
tool = null
// "completed" always transitions to idle.
// Other reasons: don't clobber a more specific terminal state (Error,
// AwaitingPermission, AwaitingQuestion, LoginRequired) that arrived just before close.
// Keep pending questions visible for follow-up flows that arrive just before close.
val current = model.state
if (current is SessionState.AwaitingQuestion) return
val clobberOk = event.reason == "completed"
|| current is SessionState.Busy
|| current is SessionState.Retry
@@ -744,6 +774,8 @@ class SessionController(
if (clobberOk) model.setState(SessionState.Idle)
}
is ChatEventDto.SessionCreated -> adoptFollowup(event.info)
is ChatEventDto.Error -> {
partType = null
tool = null
@@ -900,10 +932,11 @@ class SessionController(
val selected = selectedModel(agent, auto)
model.defaultModel = auto
selectResolvedModel(selected)
model.modelOverride = selected != auto
model.modelOverride = messageSelection(agent) == null && selected != auto
}
private fun selectedModel(agent: String, auto: String?): String? {
messageSelection(agent)?.let { return it.key }
val saved = app.models.value.model[agent]
val cfg = model.app.config
if (cfg != null) return resolveModelSelection(
@@ -949,12 +982,84 @@ class SessionController(
private fun item(key: String): ModelItem? = model.models.firstOrNull { it.key == key }
private fun messageSelection(agent: String): ModelSelectionDto? {
if (prefAgent != null && prefAgent != agent) return null
return valid(model.workspace.providers, prefModel?.let(::selection))
}
private fun handle(events: List<ChatEventDto>) {
updateModel {
for (event in events) handle(event)
}
}
private fun adoptFollowup(session: SessionDto) {
assertEdt()
val item = followup ?: return
if (System.currentTimeMillis() - item.time > FOLLOWUP_TTL_MS) {
followup = null
return
}
if (pathKey(item.dir) != pathKey(session.directory)) return
followup = null
open(SessionRef.Local(session))
}
private fun syncHistoryAgent(items: List<MessageWithPartsDto>) {
val before = model.prefs()
val agent = items
.map { it.info }
.filter { messageAgent(it) != null }
.maxByOrNull { it.time.created }
val msg = items
.map { it.info }
.filter { it.role == "user" && messageModel(it) != null }
.maxByOrNull { it.time.created }
agentTime = agent?.time?.created
modelTime = msg?.time?.created
messageAgent(agent)?.let { model.agent = it }
prefModel = messageModel(msg)
prefAgent = messageAgent(msg) ?: model.agent
syncModelSelection()
if (model.prefs() != before) fire(SessionControllerEvent.WorkspaceReady)
}
private fun syncMessagePrefs(info: MessageDto) {
val before = model.prefs()
val agent = messageAgent(info)
val prior = agentTime
if (agent != null && (info.time.created >= (prior ?: Double.NEGATIVE_INFINITY))) {
agentTime = info.time.created
model.agent = agent
}
val key = messageModel(info)
val last = modelTime
if (info.role == "user" && key != null && (info.time.created >= (last ?: Double.NEGATIVE_INFINITY))) {
modelTime = info.time.created
prefModel = key
prefAgent = agent ?: model.agent
}
syncModelSelection()
if (model.prefs() != before) fire(SessionControllerEvent.WorkspaceReady)
}
private fun messageAgent(info: MessageDto?): String? {
val agent = info?.agent?.trim()?.takeIf { it.isNotEmpty() } ?: return null
if (model.agents.isNotEmpty() && model.agents.none { it.name == agent }) return null
return agent
}
private fun messageModel(info: MessageDto?): String? {
val msg = info ?: return null
val provider = msg.providerID?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val id = msg.modelID?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val key = "$provider/$id"
if (item(key) == null && model.workspace.providers != null) return null
return key
}
private fun SessionModel.prefs(): Pref = Pref(agent, model, variants, variant, modelOverride)
private fun updateModel(block: () -> Unit) {
assertEdt()
if (disposed) return
@@ -1345,6 +1450,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve
is ChatEventDto.PartRemoved -> event.sessionID == id
is ChatEventDto.TurnOpen -> event.sessionID == id
is ChatEventDto.TurnClose -> event.sessionID == id
is ChatEventDto.SessionCreated -> true
is ChatEventDto.Error -> event.sessionID == null || event.sessionID == id
is ChatEventDto.MessageRemoved -> event.sessionID == id
is ChatEventDto.PermissionAsked -> event.sessionID == id
@@ -1413,6 +1519,12 @@ private fun parseModel(value: String): Pair<String, String>? {
return value.substring(0, slash) to value.substring(slash + 1)
}
private fun pathKey(value: String): String = runCatching {
Path.of(value).normalize().toString().trimEnd('/', '\\')
}.getOrElse {
value.replace('\\', '/').trimEnd('/')
}
private sealed interface RecentsState {
data object Idle : RecentsState
data class Loading(val id: Any = Any()) : RecentsState
@@ -1506,12 +1618,22 @@ private fun toQuestion(dto: QuestionRequestDto): Question {
QuestionItem(
question = it.question,
header = it.header,
options = it.options.map { opt -> QuestionOption(opt.label, opt.description) },
options = it.options.map { opt ->
QuestionOption(
label = opt.label,
description = opt.description,
labelKey = opt.labelKey,
descriptionKey = opt.descriptionKey,
mode = opt.mode,
)
},
multiple = it.multiple,
custom = it.custom,
questionKey = it.questionKey,
headerKey = it.headerKey,
)
}
return Question(id = dto.id, items = items, tool = ref)
return Question(id = dto.id, items = items, tool = ref, blocking = dto.blocking)
}
private fun String.toDumpText(): String {
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.PartTimeDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TodoViewDto
import ai.kilocode.rpc.dto.TokensDto
data class SessionHeaderSnapshot(
@@ -75,6 +76,8 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var output: String? = null
var error: String? = null
var time: PartTimeDto? = null
var todos: List<TodoDto> = emptyList()
var todoView: TodoViewDto? = null
}
/** Context compaction marker. */
@@ -7,6 +7,7 @@ data class Question(
val items: List<QuestionItem>,
val tool: ToolCallRef? = null,
val state: QuestionRequestState = QuestionRequestState.PENDING,
val blocking: Boolean = false,
)
data class QuestionItem(
@@ -15,9 +16,14 @@ data class QuestionItem(
val options: List<QuestionOption>,
val multiple: Boolean,
val custom: Boolean,
val questionKey: String? = null,
val headerKey: String? = null,
)
data class QuestionOption(
val label: String,
val description: String,
val labelKey: String? = null,
val descriptionKey: String? = null,
val mode: String? = null,
)
@@ -359,6 +359,8 @@ class SessionModel {
existing.output = dto.output
existing.error = dto.error
existing.time = dto.time
existing.todos = dto.todos
existing.todoView = dto.todoView
}
is Compaction -> return
is StepFinish -> {
@@ -391,6 +393,8 @@ class SessionModel {
output = dto.output
error = dto.error
time = dto.time
todos = dto.todos
todoView = dto.todoView
}
"compaction" -> Compaction(dto.id)
"step-finish" -> StepFinish(dto.id).apply {
@@ -46,6 +46,7 @@ class SessionMessageListPanel(
private val question: QuestionView? = null,
private val permission: PermissionView? = null,
private val login: LoginRequiredView? = null,
private val openFile: (String) -> Unit,
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -173,7 +174,7 @@ class SessionMessageListPanel(
// ------ private event handlers ------
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
val tv = TurnView(turn.id, style)
val tv = TurnView(turn.id, openFile, style)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -224,7 +225,7 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, style)
val tv = TurnView(turn.id, openFile, style)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -6,9 +6,13 @@ import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.todo.TodoListPanel
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.icons.AllIcons
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.IconLoader
@@ -52,6 +56,8 @@ class SessionHeaderPanel(
private val cost = JBLabel()
private val context = JBLabel()
private val todos = JBLabel()
private val todoArrow = JBLabel(AllIcons.General.ArrowRight)
private val todoList = TodoListPanel()
private val compact = HoverIcon().apply {
icon = COMPRESS_ICON
toolTipText = KiloBundle.message("session.header.compact.description")
@@ -111,11 +117,20 @@ class SessionHeaderPanel(
add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
add(cacheWrite)
}
private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0)).apply {
private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
toolTipText = KiloBundle.message("session.header.todos.toggle")
accessibleContext.accessibleName = KiloBundle.message("session.header.todos.toggle")
add(todoArrow)
add(todos)
}
private val todoBox = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(todoRow)
}
private val body = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
@@ -123,7 +138,7 @@ class SessionHeaderPanel(
add(viewport)
add(tokens)
add(bar)
add(todoRow)
add(todoBox)
}
private var style = SessionEditorStyle.current()
@@ -151,6 +166,15 @@ class SessionHeaderPanel(
})
timeline.addMouseWheelListener { scroll(it) }
viewport.addMouseWheelListener { scroll(it) }
val todoClick = object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
toggleTodos()
}
}
listOf(todoRow, todoArrow, todos).forEach {
it.cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
it.addMouseListener(todoClick)
}
controller.model.addListener(parent) { event ->
when (event) {
@@ -207,8 +231,7 @@ class SessionHeaderPanel(
set(context, contextText(header.context))
context.toolTipText = contextTip(header.context)
setTokens(header.tokens)
set(todos, todo(header.todos.completed, header.todos.total))
todoRow.isVisible = todos.isVisible
syncTodos(header.todos.items)
compact.isEnabled = header.canCompact
val appended = timeline.setItems(header.timeline)
@@ -227,6 +250,7 @@ class SessionHeaderPanel(
right.background = style.editorBackground
tokens.background = style.editorBackground
todoRow.background = style.editorBackground
todoBox.background = style.editorBackground
body.background = style.editorBackground
viewport.background = style.editorBackground
title.font = style.boldFont
@@ -237,6 +261,8 @@ class SessionHeaderPanel(
context.foreground = style.editorForeground
todos.font = style.smallFont
todos.foreground = style.editorForeground
todoArrow.foreground = style.editorForeground
todoList.applyStyle(style)
tokenTitle.font = style.smallFont
tokenTitle.foreground = style.editorForeground
input.font = style.smallFont
@@ -278,6 +304,14 @@ class SessionHeaderPanel(
internal fun todoVisible() = todoRow.isVisible && todos.isVisible
internal fun todoListVisible() = todoList.parent === todoBox
internal fun todoRowPanel() = todoRow
internal fun todoLabel() = todos
internal fun todoListPanel() = todoList
internal fun compactButton() = compact
internal fun expandButton() = expand
@@ -358,6 +392,41 @@ class SessionHeaderPanel(
tokens.isVisible = total > 0
}
private fun syncTodos(items: List<TodoDto>) {
val total = items.size
val done = items.count { it.status == "completed" }
set(todos, todo(done, total))
todos.foreground = if (total > 0 && done == total) SessionUiStyle.Timeline.SUCCESS else style.editorForeground
todoArrow.isVisible = total > 0
todoBox.isVisible = total > 0
todoRow.isVisible = total > 0
todoList.update(items)
if (total == 0) collapseTodos()
}
private fun toggleTodos() {
if (!todoBox.isVisible) return
if (todoListVisible()) collapseTodos() else expandTodos()
refresh()
}
private fun expandTodos(): Boolean {
if (todoListVisible()) return false
todoBox.add(todoList)
todoArrow.icon = AllIcons.General.ArrowDown
return true
}
private fun collapseTodos(): Boolean {
if (!todoListVisible()) {
todoArrow.icon = AllIcons.General.ArrowRight
return false
}
todoBox.remove(todoList)
todoArrow.icon = AllIcons.General.ArrowRight
return true
}
private fun toggle() {
val next = !isExpanded()
syncExpanded(next)
@@ -27,12 +27,13 @@ import com.intellij.util.ui.JBUI
*/
class MessageView(
val msg: Message,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
) : ai.kilocode.client.session.ui.SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
), SessionEditorStyleTarget, SessionView {
constructor(msg: Message) : this(msg, SessionEditorStyle.current())
constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current())
val role: String get() = msg.info.role
@@ -54,7 +55,7 @@ class MessageView(
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -94,7 +95,7 @@ class MessageView(
refresh()
return
}
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -106,7 +107,7 @@ class MessageView(
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
parts.remove(content.id)
remove(existing)
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view, at)
@@ -127,8 +128,10 @@ class MessageView(
* pending/running question tool part linked to the active question.
*/
private fun isHidden(content: Content): Boolean {
val ref = hidden ?: return false
if (content !is Tool) return false
if (content.name == "todoread") return true
if (content.name == "todowrite" && content.state != ToolExecState.COMPLETED) return true
val ref = hidden ?: return false
if (content.name != "question") return false
if (content.state != ToolExecState.PENDING && content.state != ToolExecState.RUNNING) return false
return msg.info.id == ref.messageId && content.callId == ref.callId
@@ -144,7 +147,7 @@ class MessageView(
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content)
val view = ViewFactory.create(content, openFile)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -0,0 +1,82 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.md.MdView
import java.awt.BorderLayout
class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() {
companion object {
fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED
}
override val contentId: String = tool.id
private var item = tool
private val md = MdView.html()
init {
layout = BorderLayout()
isOpaque = false
md.addLinkListener { openFile(it.href) }
add(md.component, BorderLayout.CENTER)
applyStyle(SessionEditorStyle.current())
sync()
}
override fun update(content: Content) {
if (content !is Tool) return
item = content
sync()
}
override fun applyStyle(style: SessionEditorStyle) {
val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily
if (md.font != style.transcriptFont) md.font = style.transcriptFont
if (md.codeFont != style.editorFamily) md.codeFont = style.editorFamily
if (!changed) return
refresh()
}
fun markdown(): String = md.markdown()
internal fun simulateLink(href: String) = md.simulateLink(href)
private fun sync() {
val plan = plan(item)
val text = listOf(KiloBundle.message("session.part.plan.ready"), link(plan))
.filterNotNull()
.joinToString(" ")
md.set(text)
refresh()
}
private fun refresh() {
revalidate()
repaint()
}
override fun dumpLabel() = "PlanExitView#$contentId"
}
private fun plan(tool: Tool): String {
tool.metadata["plan"]?.takeIf { it.isNotBlank() }?.let { return it }
val out = tool.output ?: return ""
return Regex("Plan is ready at (.+?)(?:\\. Ending planning turn\\.|$)")
.find(out)
?.groupValues
?.getOrNull(1)
?.trim()
?: ""
}
private fun link(plan: String): String? {
if (plan.isBlank()) return null
val text = plan.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
val href = plan.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
return "[$text]($href)"
}
@@ -18,10 +18,11 @@ import com.intellij.util.ui.JBUI
*/
class TurnView(
val id: String,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget {
constructor(id: String) : this(id, SessionEditorStyle.current())
constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current())
private val messages = LinkedHashMap<String, MessageView>()
@@ -31,7 +32,7 @@ class TurnView(
/** Add a new [MessageView] for [msg] at the end of this turn. */
fun addMessage(msg: Message): MessageView {
val view = MessageView(msg, style)
val view = MessageView(msg, openFile, style)
messages[msg.info.id] = view
add(view)
revalidate()
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.StepFinish
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.views.todo.TodoWriteView
/**
* Creates the appropriate [PartView] for a given [Content] subtype.
@@ -20,10 +21,15 @@ import ai.kilocode.client.session.model.Tool
* 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error.
*/
object ViewFactory {
fun create(content: Content): PartView = when (content) {
fun create(content: Content, openFile: (String) -> Unit): PartView = when (content) {
is Text -> TextView(content)
is Reasoning -> ReasoningView(content)
is Tool -> if (QuestionResultView.canRender(content)) QuestionResultView(content) else ToolView(content)
is Tool -> when {
TodoWriteView.canRender(content) -> TodoWriteView(content)
PlanExitView.canRender(content) -> PlanExitView(content, openFile)
QuestionResultView.canRender(content) -> QuestionResultView(content)
else -> ToolView(content)
}
is Compaction -> CompactionView(content)
is StepFinish -> error("step-finish is timeline-only")
is Generic -> GenericView(content)
@@ -36,6 +42,10 @@ object ViewFactory {
*/
fun shouldReplace(view: PartView, content: Content): Boolean {
if (content !is Tool) return false
if (view is TodoWriteView) return !TodoWriteView.canRender(content)
if (view !is TodoWriteView && TodoWriteView.canRender(content)) return true
if (view is PlanExitView) return !PlanExitView.canRender(content)
if (view !is PlanExitView && PlanExitView.canRender(content)) return true
if (view is QuestionResultView) return !QuestionResultView.canRender(content)
if (view is ToolView) return QuestionResultView.canRender(content)
return false
@@ -42,7 +42,7 @@ import com.intellij.openapi.editor.event.DocumentListener
/** Question tool form rendered inside the session transcript. */
class QuestionView(
private val project: Project,
private val reply: (String, QuestionReplyDto) -> Unit,
private val reply: (String, QuestionReplyDto, List<List<String>>) -> Unit,
private val reject: (String) -> Unit,
private val scroll: () -> Unit = {},
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
@@ -186,7 +186,9 @@ class QuestionView(
val shown = minOf(idx + 1, total)
summary.text = KiloBundle.message("session.question.summary", shown, total)
summary.foreground = UiStyle.Colors.weak()
summary.isVisible = total > 1
nav.isVisible = total > 1
topPanel.isVisible = total > 1
}
private fun syncFooter(q: Question) {
@@ -256,6 +258,8 @@ class QuestionView(
}
}
private fun optionAnswers(i: Int): List<String> = selections.getOrNull(i)?.toList() ?: emptyList()
private fun addContent(item: QuestionItem, set: MutableSet<String>) {
val opts = optionList(item, set)
opts.alignmentX = Component.LEFT_ALIGNMENT
@@ -710,7 +714,8 @@ class QuestionView(
val id = request ?: return
if ((question?.items?.indices ?: return).any { !isReady(it) }) return
val answers = (question?.items?.indices ?: return).map { effectiveAnswers(it) }
reply(id, QuestionReplyDto(answers))
val opts = (question?.items?.indices ?: return).map { optionAnswers(it) }
reply(id, QuestionReplyDto(answers), opts)
hideView()
}
@@ -0,0 +1,140 @@
package ai.kilocode.client.session.views.todo
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.TodoDto
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.JPanel
class TodoListPanel(
todos: List<TodoDto> = emptyList(),
private var before: Int = 0,
private var after: Int = 0,
) : JPanel() {
private var items = todos
private var style = SessionEditorStyle.current()
private val rows = mutableListOf<Row>()
private val prior = JBLabel()
private val later = JBLabel()
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md())
add(prior)
add(later)
applyStyle(style)
sync()
}
fun update(todos: List<TodoDto>, hiddenBefore: Int = 0, hiddenAfter: Int = 0) {
val size = todos.size != items.size
items = todos
before = hiddenBefore
after = hiddenAfter
if (size) sync()
rows.forEachIndexed { index, row -> row.update(items[index], style) }
syncHidden()
revalidate()
repaint()
}
fun applyStyle(style: SessionEditorStyle) {
this.style = style
prior.font = style.smallFont
later.font = style.smallFont
prior.foreground = UiStyle.Colors.weak()
later.foreground = UiStyle.Colors.weak()
rows.forEachIndexed { index, row -> row.update(items[index], style) }
syncHidden()
}
internal fun rowCount() = rows.size
internal fun rowText(index: Int) = rows[index].text.text
internal fun rowChecked(index: Int) = rows[index].check.isSelected
internal fun rowCheckboxOpaque(index: Int) = rows[index].check.isOpaque
internal fun rowFont(index: Int) = rows[index].text.font
internal fun rowForeground(index: Int) = rows[index].text.foreground
internal fun hiddenText() = listOf(prior, later).filter { it.isVisible }.joinToString(" ") { it.text }
private fun sync() {
removeAll()
rows.clear()
add(prior)
items.forEach { todo ->
val row = Row(todo, style)
rows.add(row)
add(row.panel)
}
add(later)
syncHidden()
}
private fun syncHidden() {
prior.text = hidden(before, true)
prior.isVisible = before > 0
later.text = hidden(after, false)
later.isVisible = after > 0
}
private fun hidden(count: Int, earlier: Boolean): String {
if (count <= 0) return ""
val key = when {
earlier && count == 1 -> "session.part.todo.hidden.earlier.one"
earlier -> "session.part.todo.hidden.earlier.many"
count == 1 -> "session.part.todo.hidden.later.one"
else -> "session.part.todo.hidden.later.many"
}
return KiloBundle.message(key, count)
}
private class Row(todo: TodoDto, style: SessionEditorStyle) {
val check = JBCheckBox().apply {
isFocusable = false
isEnabled = false
isOpaque = false
}
val text = JBLabel()
val panel = JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0)
add(check, BorderLayout.WEST)
add(text, BorderLayout.CENTER)
}
init {
update(todo, style)
}
fun update(todo: TodoDto, style: SessionEditorStyle) {
val done = todo.status == "completed"
check.isSelected = done
text.text = label(todo.content, done)
text.font = if (todo.changed) style.boldFont else style.regularFont
text.foreground = when {
!done -> style.editorForeground
todo.changed -> style.editorForeground
else -> UiStyle.Colors.weak()
}
}
private fun label(value: String, done: Boolean): String {
val text = XmlStringUtil.escapeString(value)
if (!done) return "<html>$text</html>"
return "<html><s>$text</s></html>"
}
}
}
@@ -0,0 +1,209 @@
package ai.kilocode.client.session.views.todo
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Cursor
import java.awt.Font
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Box
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingUtilities
class TodoWriteView(tool: Tool) : PartView() {
override val contentId = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
private val root = JPanel(BorderLayout()).apply {
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = true
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val glyph = JBLabel(AllIcons.Actions.Checked)
private val title = JBLabel(KiloBundle.message("session.part.todo.title"))
private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
private val arrow = JBLabel(AllIcons.General.ArrowDown)
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
}
private val controls: JComponent = Box.createHorizontalBox().apply { add(arrow) }
private val list = TodoListPanel()
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
toggle()
}
}
private val mouse = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
setHover(true)
}
override fun mouseExited(e: MouseEvent) {
if (inside(e)) return
setHover(false)
}
}
init {
layout = BorderLayout()
isOpaque = false
center.add(title, BorderLayout.WEST)
center.add(sub, BorderLayout.CENTER)
header.add(glyph, BorderLayout.WEST)
header.add(center, BorderLayout.CENTER)
header.add(controls, BorderLayout.EAST)
root.add(header, BorderLayout.NORTH)
root.add(list, BorderLayout.CENTER)
list.border = JBUI.Borders.compound(
SessionUiStyle.View.cardTop(),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()),
)
listOf(header, glyph, title, sub, arrow, center, controls).forEach {
bind(it)
it.addMouseListener(click)
}
applyStyle(style)
add(root, BorderLayout.CENTER)
sync()
}
override fun update(content: Content) {
if (content !is Tool) return
item = content
sync()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(title, style.boldEditorFont) || changed
changed = setFont(sub, style.transcriptFont) || changed
list.applyStyle(style)
if (changed) refresh()
}
fun toggle() {
val changed = if (isExpanded()) detach() else attach()
if (!changed) return
syncArrow()
refresh()
}
fun isExpanded() = list.parent === root
fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ")
internal fun rowCount() = list.rowCount()
internal fun rowText(index: Int) = list.rowText(index)
internal fun rowChecked(index: Int) = list.rowChecked(index)
internal fun rowCheckboxOpaque(index: Int) = list.rowCheckboxOpaque(index)
internal fun rowForeground(index: Int) = list.rowForeground(index)
internal fun hiddenText() = list.hiddenText()
internal fun titleFont() = title.font
internal fun subtitleFont() = sub.font
override fun dumpLabel() = "TodoWriteView#$contentId(${labelText()})"
private fun sync() {
sub.text = subtitle(item)
val view = item.todoView
val compact = view?.mode == "compact"
val rows = if (compact) view.todos else item.todos
list.update(
rows,
hiddenBefore = if (compact) view.hiddenBefore else 0,
hiddenAfter = if (compact) view.hiddenAfter else 0,
)
syncArrow()
refresh()
}
private fun attach(): Boolean {
if (isExpanded()) return false
root.add(list, BorderLayout.CENTER)
return true
}
private fun detach(): Boolean {
if (!isExpanded()) return false
root.remove(list)
return true
}
private fun syncArrow() {
arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
}
private fun setHover(value: Boolean) {
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (same(header.background, color)) return
header.background = color
header.repaint()
}
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, header)
return header.contains(point)
}
private fun bind(component: Component) {
component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
component.addMouseListener(mouse)
}
private fun refresh() {
revalidate()
repaint()
}
companion object {
fun canRender(tool: Tool) = tool.name == "todowrite" && tool.state == ToolExecState.COMPLETED
}
}
private fun subtitle(tool: Tool): String {
val total = tool.todos.size
if (total == 0) return ""
val done = tool.todos.count { it.status == "completed" }
return "$done/$total"
}
private fun setFont(component: JComponent, font: Font): Boolean {
if (component.font == font) return false
component.font = font
return true
}
private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb
@@ -83,6 +83,12 @@ session.part.tool.read=Read
session.part.tool.running=Running
session.part.tool.shell=Shell
session.part.tool.truncated=Output truncated in preview. Full output remains in session data.
session.part.plan.ready=Plan is ready
session.part.todo.title=To-dos
session.part.todo.hidden.earlier.one={0} earlier to-do hidden
session.part.todo.hidden.earlier.many={0} earlier to-dos hidden
session.part.todo.hidden.later.one={0} later to-do hidden
session.part.todo.hidden.later.many={0} later to-dos hidden
session.error.prompt=Prompt failed
session.error.compact=Session compact failed
@@ -105,6 +111,7 @@ session.header.expand=Show session metrics
session.header.collapse=Hide session metrics
session.header.todos.progress={0}/{1} todos complete
session.header.todos.done=All {0} todos complete
session.header.todos.toggle=Toggle to-dos
session.header.context.tooltip.percent={0} tokens ({1}% of context)
session.header.context.tooltip.tokens={0} tokens
session.header.context.used={0} / {1} tokens used
@@ -0,0 +1,34 @@
package ai.kilocode.client.actions
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class KiloRecoveryActionsTest : BasePlatformTestCase() {
fun `test restart action stays enabled for all app states`() {
val action = RestartKiloAction()
val event = event(action)
ActionUtil.updateAction(action, event)
assertTrue("Restart should force-enable recovery action", event.presentation.isEnabled)
}
fun `test reinstall action stays enabled for all app states`() {
val action = ReinstallKiloAction()
val event = event(action)
ActionUtil.updateAction(action, event)
assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled)
}
private fun event(action: AnAction): AnActionEvent {
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
presentation.isEnabled = false
return AnActionEvent.createFromDataContext("", presentation) { null }
}
}
@@ -0,0 +1,70 @@
package ai.kilocode.client.app
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@Suppress("UnstableApiUsage")
class KiloWorkspaceServiceTest : BasePlatformTestCase() {
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeWorkspaceRpcApi
private lateinit var service: KiloWorkspaceService
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob())
rpc = FakeWorkspaceRpcApi()
service = KiloWorkspaceService(scope, rpc)
}
override fun tearDown() {
try {
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test openPath opens first file match`() = runBlocking {
rpc.fileMatches = listOf(
WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md"),
WorkspaceFileDto("/other/.kilo/plans/a.md", "a.md"),
)
val ok = withContext(Dispatchers.Default) {
service.openPath("/test", ".kilo/plans/a.md")
}
assertTrue(ok)
assertEquals(listOf("/test" to ".kilo/plans/a.md"), rpc.fileCalls)
assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened)
}
fun `test openPath returns false when no match exists`() = runBlocking {
val ok = withContext(Dispatchers.Default) {
service.openPath("/test", ".kilo/plans/missing.md")
}
assertFalse(ok)
assertEquals(listOf("/test" to ".kilo/plans/missing.md"), rpc.fileCalls)
assertTrue(rpc.opened.isEmpty())
}
fun `test openPath returns false when backend open fails`() = runBlocking {
rpc.fileMatches = listOf(WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md"))
rpc.openResult = false
val ok = withContext(Dispatchers.Default) {
service.openPath("/test", ".kilo/plans/a.md")
}
assertFalse(ok)
assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened)
}
}
@@ -382,7 +382,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
created.add(workspace.directory to id)
refs.add(ref)
SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner).also {
SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner, workspaces = workspaces).also {
ui.add(it)
Disposer.register(it) { ui.remove(it) }
}
@@ -23,6 +23,7 @@ import kotlinx.coroutines.cancel
class SessionUiFactoryTest : BasePlatformTestCase() {
private lateinit var scope: CoroutineScope
private lateinit var workspace: Workspace
private lateinit var workspaces: KiloWorkspaceService
private lateinit var sessions: KiloSessionService
private lateinit var app: KiloAppService
@@ -33,7 +34,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
app = KiloAppService(scope, FakeAppRpcApi().also {
it.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
})
val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also {
workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also {
it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)
})
workspace = workspaces.workspace("/test")
@@ -56,7 +57,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
fun `test factory wires open callback`() {
val manager = FakeManager()
val rpc = session("ses_1")
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
@@ -69,7 +70,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
fun `test empty panel opens through SessionRef via controller`() {
val manager = FakeManager()
val rpc = session("ses_1")
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc))
@@ -81,7 +82,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
fun `test empty panel show history routes through manager`() {
val manager = FakeManager()
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(
testRootDisposable,
@@ -91,7 +91,14 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
override fun openSession(ref: SessionRef) = fn(ref)
}
}
return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager, migration = migration).apply {
return SessionUi(
project, workspace, sessions, app, scope,
ref = SessionRef.from(id),
displayMs = displayMs,
manager = manager,
workspaces = workspaces,
migration = migration,
).apply {
setSize(800, 600)
}
}
@@ -1,7 +1,14 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.ProviderDto
class HistoryLoadingTest : SessionControllerTestBase() {
@@ -76,4 +83,66 @@ class HistoryLoadingTest : SessionControllerTestBase() {
c,
)
}
fun `test loaded history derives agent from latest message`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady(agents = agents(), default = "plan")
rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user").copy(agent = "plan", time = MessageTimeDto(1.0)), emptyList()))
rpc.history.add(MessageWithPartsDto(msg("msg2", "ses_test", "assistant").copy(agent = "code", time = MessageTimeDto(2.0)), emptyList()))
val c = controller("ses_test")
flush()
assertEquals("code", c.model.agent)
}
fun `test loaded history derives model from latest user message`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady(
agents = agents(),
default = "plan",
providers = listOf(
ProviderDto(
id = "kilo",
name = "Kilo",
models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")),
),
ProviderDto(
id = "anthropic",
name = "Anthropic",
models = mapOf("claude" to ModelDto(id = "claude", name = "Claude")),
),
),
connected = listOf("kilo", "anthropic"),
defaults = mapOf("plan" to "kilo/gpt-5", "code" to "kilo/gpt-5"),
)
rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user").copy(
agent = "code",
providerID = "anthropic",
modelID = "claude",
time = MessageTimeDto(1.0),
), emptyList()))
val c = controller("ses_test")
flush()
assertEquals("code", c.model.agent)
assertEquals("anthropic/claude", c.model.model)
assertFalse(c.model.modelOverride)
}
fun `test empty loaded history keeps workspace default agent`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady(agents = agents(), default = "plan")
val c = controller("ses_test")
flush()
assertEquals("plan", c.model.agent)
}
private fun agents() = listOf(
AgentDto(name = "plan", displayName = "Plan", mode = "plan"),
AgentDto(name = "code", displayName = "Code", mode = "code"),
)
}
@@ -3,12 +3,16 @@ package ai.kilocode.client.session.controller
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionFileDiffDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.ProviderDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionReplyDto
@@ -169,6 +173,153 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertEquals("q1", rpc.questionReplies[0].first)
}
fun `test plan follow-up question enters awaiting state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
assertSession(
"""
question#q_plan
tool: <none>
header: Implement
prompt: Ready to implement?
option: Start new session - Implement in a fresh session with a clean context
option: Continue here - Implement the plan in this session
multiple: false
custom: true
[code] [kilo/gpt-5] [awaiting-question]
""",
m,
)
}
fun `test continue here reflects CLI mode after canonical reply`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = planWorkspace()
val m = controller()
val events = collect(m)
flush()
edt { m.prompt("go") }
flush()
events.clear()
edt { m.model.agent = "plan" }
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Continue here"))),
listOf(listOf("Continue here")),
)
}
flush()
assertEquals("plan", m.model.agent)
assertTrue(rpc.configs.none { it.second.agent == "code" })
assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies)
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_code", "ses_test", "user").copy(
agent = "code",
providerID = "anthropic",
modelID = "claude",
)))
assertEquals("code", m.model.agent)
assertEquals("anthropic/claude", m.model.model)
assertFalse(m.model.modelOverride)
assertTrue(rpc.configs.none { it.second.agent == "code" })
assertControllerEvents("WorkspaceReady", events)
}
fun `test custom plan follow-up answer does not switch mode`() {
val (m, _, _) = prompted()
edt { m.model.agent = "plan" }
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Need to adjust scope"))),
listOf(emptyList()),
)
}
flush()
assertEquals("plan", m.model.agent)
assertTrue(rpc.configs.none { it.second.agent == "code" })
assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies)
}
fun `test start new session adopts matching created session from selected option`() {
val opened = mutableListOf<SessionRef>()
val m = controller(open = { opened.add(it) })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
flush()
edt { m.prompt("go") }
flush()
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Use a fresh implementation session"))),
listOf(listOf("Start new session")),
)
}
emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test")))
flush()
assertEquals("ses_new", (opened.last() as SessionRef.Local).id)
assertEquals(1, rpc.prompts.size)
}
fun `test start new session reply text without selected option is ignored`() {
val opened = mutableListOf<SessionRef>()
val m = controller(open = { opened.add(it) })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
flush()
edt { m.prompt("go") }
flush()
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Start new session"))),
listOf(emptyList()),
)
}
emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test")))
assertTrue(opened.none { it is SessionRef.Local && it.id == "ses_new" })
}
fun `test unrelated session created is ignored`() {
val opened = mutableListOf<SessionRef>()
val m = controller(open = { opened.add(it) })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
flush()
edt { m.prompt("go") }
flush()
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Start new session"))),
listOf(listOf("Start new session")),
)
}
emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/other")))
assertTrue(opened.none { it is SessionRef.Local && it.id == "ses_new" })
}
fun `test rejectQuestion calls RPC`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
@@ -348,4 +499,43 @@ class PromptLifecycleTest : SessionControllerTestBase() {
),
tool = ToolRefDto("msg1", "call1"),
)
private fun planQuestion(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Ready to implement?",
header = "Implement",
options = listOf(
QuestionOptionDto("Start new session", "Implement in a fresh session with a clean context"),
QuestionOptionDto("Continue here", "Implement the plan in this session", mode = "code"),
),
multiple = false,
custom = true,
),
),
)
private fun planWorkspace() = workspaceReady(
agents = listOf(
AgentDto(name = "plan", displayName = "Plan", mode = "plan"),
AgentDto(name = "code", displayName = "Code", mode = "code"),
),
default = "plan",
providers = listOf(
ProviderDto(
id = "kilo",
name = "Kilo",
models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")),
),
ProviderDto(
id = "anthropic",
name = "Anthropic",
models = mapOf("claude" to ModelDto(id = "claude", name = "Claude")),
),
),
connected = listOf("kilo", "anthropic"),
defaults = mapOf("plan" to "kilo/gpt-5", "code" to "kilo/gpt-5"),
)
}
@@ -128,8 +128,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
id: String? = null,
flushMs: Long = Long.MAX_VALUE,
displayMs: Long = Long.MAX_VALUE,
open: (SessionRef) -> Unit = {},
): SessionController {
return controller(id, flushMs, true, displayMs = displayMs)
return controller(id, flushMs, true, displayMs = displayMs, open = open)
}
protected fun controller(
@@ -148,6 +149,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
session: SessionDto? = null,
beforeUpdate: () -> Boolean = { false },
afterUpdate: (Boolean) -> Unit = {},
open: (SessionRef) -> Unit = {},
ref: SessionRef? = if (session != null) SessionRef.Local(session) else SessionRef.from(id),
): SessionController {
val root = Root()
@@ -162,6 +164,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
flushMs,
condense,
displayMs,
open = open,
beforeUpdate = beforeUpdate,
afterUpdate = afterUpdate,
)
@@ -10,6 +10,8 @@ import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
class TurnLifecycleTest : SessionControllerTestBase() {
@@ -72,6 +74,20 @@ class TurnLifecycleTest : SessionControllerTestBase() {
)
}
fun `test TurnClose completed preserves AwaitingQuestion state`() {
val (m, _, _) = prompted()
emit(
ChatEventDto.QuestionAsked(
"ses_test",
QuestionRequestDto("q1", "ses_test", listOf(QuestionInfoDto("Pick one", "Choice"))),
),
)
emit(ChatEventDto.TurnClose("ses_test", "completed"))
assertTrue(m.model.state is SessionState.AwaitingQuestion)
}
fun `test Error fires StateChanged to Error`() {
val (m, _, _) = prompted()
@@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.PartTimeDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionTimeDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TodoViewDto
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
@@ -171,6 +172,8 @@ class SessionModelTest : UsefulTestCase() {
fun `test updateContent tool stores rich fields`() {
model.addMessage(msg("m1", "assistant"))
val todos = listOf(TodoDto("Write tests", "completed", "high", changed = true))
val view = TodoViewDto("compact", todos, hiddenBefore = 1, hiddenAfter = 2, changed = 1)
model.updateContent(
"m1",
@@ -184,6 +187,8 @@ class SessionModelTest : UsefulTestCase() {
output = "abc123 init",
error = "failed",
time = PartTimeDto(1.0, 2.0),
todos = todos,
todoView = view,
),
)
@@ -195,6 +200,8 @@ class SessionModelTest : UsefulTestCase() {
assertEquals("failed", p.error)
assertEquals(1.0, p.time?.start)
assertEquals(2.0, p.time?.end)
assertEquals(todos, p.todos)
assertEquals(view, p.todoView)
}
fun `test updateContent tool updates lifecycle`() {
@@ -214,15 +221,24 @@ class SessionModelTest : UsefulTestCase() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending"))
events.clear()
val todos = listOf(TodoDto("Review", "pending", "medium"))
model.updateContent(
"m1",
part("p1", "m1", "tool", tool = "bash", state = "completed", input = mapOf("command" to "git remote -v"), output = "origin"),
part(
"p1", "m1", "tool",
tool = "bash",
state = "completed",
input = mapOf("command" to "git remote -v"),
output = "origin",
todos = todos,
),
)
val p = model.message("m1")!!.parts["p1"] as Tool
assertEquals("git remote -v", p.input["command"])
assertEquals("origin", p.output)
assertEquals(todos, p.todos)
assertTrue(events.single() is SessionModelEvent.ContentUpdated)
}
@@ -808,6 +824,8 @@ class SessionModelTest : UsefulTestCase() {
reason: String? = null,
cost: Double? = null,
tokens: TokensDto? = null,
todos: List<TodoDto> = emptyList(),
todoView: TodoViewDto? = null,
) = PartDto(
id = id,
sessionID = "ses",
@@ -822,6 +840,8 @@ class SessionModelTest : UsefulTestCase() {
output = output,
error = error,
time = time,
todos = todos,
todoView = todoView,
reason = reason,
cost = cost,
tokens = tokens,
@@ -10,15 +10,18 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.PlanExitView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.session.views.ToolView
import ai.kilocode.client.session.views.todo.TodoWriteView
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.TodoDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -37,12 +40,13 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
private lateinit var model: SessionModel
private lateinit var parent: Disposable
private lateinit var panel: SessionMessageListPanel
private val openFile: (String) -> Unit = {}
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("test")
model = SessionModel()
panel = SessionMessageListPanel(model, parent)
panel = SessionMessageListPanel(model, parent, openFile = openFile)
}
override fun tearDown() {
@@ -426,6 +430,27 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(mv.part("tp1") is ToolView)
}
fun `test todo tools are suppressed until todowrite completes`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", toolPart("read", "a1", "todoread", "call1", state = "completed"))
model.updateContent("a1", toolPart("write", "a1", "todowrite", "call2", state = "running"))
val mv = item.findMessage("a1")!!
assertEquals(emptyList<String>(), mv.partIds())
model.updateContent(
"a1",
toolPart(
"write", "a1", "todowrite", "call2", state = "completed",
todos = listOf(TodoDto("Done", "completed", "high")),
),
)
assertEquals(listOf("write"), mv.partIds())
assertTrue(mv.part("write") is TodoWriteView)
}
fun `test completed question update replaces generic tool view with question result view`() {
val item = panelWithPrompts()
model.upsertMessage(msg("a1", "assistant"))
@@ -449,19 +474,42 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertEquals(listOf("tp1"), mv.partIds())
}
fun `test completed plan update replaces tool view and keeps open file action`() {
val opened = mutableListOf<String>()
val item = SessionMessageListPanel(model, parent, openFile = { opened.add(it) })
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", toolPart("tp1", "a1", "plan_exit", "call1", state = "running"))
val mv = item.findMessage("a1")!!
assertTrue(mv.part("tp1") is ToolView)
model.updateContent(
"a1",
toolPart(
"tp1", "a1", "plan_exit", "call1", state = "completed",
metadata = mapOf("plan" to ".kilo/plans/x.md"),
),
)
val view = mv.part("tp1") as PlanExitView
view.simulateLink(".kilo/plans/x.md")
assertEquals(listOf(".kilo/plans/x.md"), opened)
}
// ------ helpers ------
private fun panelWithPrompts(): SessionMessageListPanel {
val q = QuestionView(
project = project,
reply = { _, _ -> },
reply = { _, _, _ -> },
reject = { _ -> },
)
val p = PermissionView(
reply = { _, _ -> },
)
val l = LoginRequiredView(openProfile = {}, dismiss = {})
return SessionMessageListPanel(model, parent, q, p, l)
return SessionMessageListPanel(model, parent, q, p, l, openFile)
}
private inline fun <reified T> find(root: Container): T? = findCls(root, T::class.java)
@@ -517,8 +565,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
state: String = "running",
input: Map<String, String> = emptyMap(),
metadata: Map<String, String> = emptyMap(),
todos: List<TodoDto> = emptyList(),
) = PartDto(
id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state,
input = input, metadata = metadata,
input = input, metadata = metadata, todos = todos,
)
}
@@ -28,7 +28,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
super.setUp()
parent = Disposer.newDisposable("test")
model = SessionModel()
panel = SessionMessageListPanel(model, parent)
panel = SessionMessageListPanel(model, parent, openFile = {})
}
override fun tearDown() {
@@ -90,6 +90,39 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
assertEquals(1, rpc.compacts.size)
}
fun `test todo list starts collapsed and toggles independently`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
panel.expandButton().doClick()
assertTrue(panel.isExpanded())
assertTrue(panel.todoVisible())
assertFalse(panel.todoListVisible())
click(panel.todoRowPanel())
assertTrue(panel.isExpanded())
assertTrue(panel.todoListVisible())
assertEquals(2, panel.todoListPanel().rowCount())
assertTrue(panel.todoListPanel().rowText(0).contains("Write tests"))
assertTrue(panel.todoListPanel().rowChecked(0))
assertFalse(panel.todoListPanel().rowChecked(1))
click(panel.todoLabel())
assertTrue(panel.isExpanded())
assertFalse(panel.todoListVisible())
}
fun `test all done todos use success foreground`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
emit(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("Done", "completed", "high"))))
assertEquals("All 1 todos complete", panel.todoText())
assertEquals(ai.kilocode.client.session.ui.style.SessionUiStyle.Timeline.SUCCESS, panel.foregrounds()[3])
}
fun `test retained labels update on later header event`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
@@ -571,6 +604,19 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
))
}
private fun click(component: java.awt.Component) {
component.dispatchEvent(MouseEvent(
component,
MouseEvent.MOUSE_CLICKED,
System.currentTimeMillis(),
0,
1,
1,
1,
false,
))
}
private fun reset() {
PropertiesComponent.getInstance().unsetValue(SessionHeaderPanel.EXPANDED_KEY)
}
@@ -0,0 +1,49 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class PlanExitViewTest : BasePlatformTestCase() {
fun `test completed plan exit renders ready transcript text and path`() {
val tool = tool(ToolExecState.COMPLETED).apply {
metadata = mapOf("plan" to ".kilo/plans/x.md")
}
val view = PlanExitView(tool) {}
assertEquals("Plan is ready [.kilo/plans/x.md](.kilo/plans/x.md)", view.markdown())
}
fun `test view factory replaces running tool with plan exit view when completed`() {
val running = tool(ToolExecState.RUNNING)
val existing = ViewFactory.create(running) {}
assertTrue(existing is ToolView)
val done = tool(ToolExecState.COMPLETED).apply {
metadata = mapOf("plan" to ".kilo/plans/x.md")
}
assertTrue(ViewFactory.shouldReplace(existing, done))
assertTrue(ViewFactory.create(done) {} is PlanExitView)
}
fun `test clicking plan link opens href`() {
val opened = mutableListOf<String>()
val tool = tool(ToolExecState.COMPLETED).apply {
metadata = mapOf("plan" to ".kilo/plans/my%20plan.md")
}
val view = PlanExitView(tool) { opened.add(it) }
view.simulateLink(".kilo/plans/my%20plan.md")
assertEquals(listOf(".kilo/plans/my%20plan.md"), opened)
}
private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply {
this.state = state
output = "Plan is ready at .kilo/plans/x.md. Ending planning turn."
}
}
@@ -129,7 +129,7 @@ class QuestionResultViewTest : BasePlatformTestCase() {
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = ViewFactory.create(tool)
val view = ViewFactory.create(tool) {}
assertTrue(view is QuestionResultView)
}
@@ -139,14 +139,14 @@ class QuestionResultViewTest : BasePlatformTestCase() {
input = emptyMap(),
metadata = emptyMap(),
)
val view = ViewFactory.create(tool)
val view = ViewFactory.create(tool) {}
assertTrue(view is ToolView)
}
fun `test view factory falls back to tool view for running question`() {
val tool = runningTool("question")
val view = ViewFactory.create(tool)
val view = ViewFactory.create(tool) {}
assertTrue(view is ToolView)
}
@@ -25,7 +25,7 @@ import javax.swing.SwingUtilities
@Suppress("UnstableApiUsage")
class QuestionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, QuestionReplyDto>>()
private val replies = mutableListOf<Triple<String, QuestionReplyDto, List<List<String>>>>()
private val rejects = mutableListOf<String>()
private var scrolls = 0
private lateinit var view: QuestionView
@@ -34,7 +34,7 @@ class QuestionViewTest : BasePlatformTestCase() {
super.setUp()
view = QuestionView(
project = project,
reply = { id, dto -> replies.add(id to dto) },
reply = { id, dto, opts -> replies.add(Triple(id, dto, opts)) },
reject = { id -> rejects.add(id) },
scroll = { scrolls++ },
)
@@ -128,6 +128,12 @@ class QuestionViewTest : BasePlatformTestCase() {
assertTrue(findAll<JBCheckBox>(view).isEmpty())
}
fun `test single question hides progress summary`() {
view.show(singleSelectQuestion("req_summary"))
assertTrue(findAll<JBLabel>(view).none { it.text == "1 of 1 questions" && it.isVisible })
}
fun `test single question submit sends selected answer`() {
view.show(singleSelectQuestion("req_2"))
@@ -139,6 +145,7 @@ class QuestionViewTest : BasePlatformTestCase() {
assertEquals(1, replies.size)
assertEquals("req_2", replies.single().first)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
assertEquals(listOf(listOf("Minimal")), replies.single().third)
}
fun `test submit is disabled until question is answered`() {
@@ -594,6 +601,37 @@ class QuestionViewTest : BasePlatformTestCase() {
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers)
assertEquals(listOf(emptyList<String>()), replies.single().third)
}
fun `test plan follow-up sends selected option labels separately`() {
view.show(
Question(
id = "q_plan",
items = listOf(
QuestionItem(
question = "Ready to implement?",
header = "Implement",
options = listOf(
QuestionOption("Start new session", "Implement in a fresh session with a clean context"),
QuestionOption("Continue here", "Implement the plan in this session", mode = "code"),
),
multiple = false,
custom = true,
)
),
)
)
assertLabelsContain(view, "Ready to implement?")
assertLabelsContain(view, "Start new session")
assertLabelsContain(view, "Continue here")
assertLabelsContain(view, "Add your own response")
option<JBRadioButton>(view, "Continue here").doClick()
button(view, "Submit").doClick()
assertEquals(listOf(listOf("Continue here")), replies.single().second.answers)
assertEquals(listOf(listOf("Continue here")), replies.single().third)
}
fun `test custom editor grows for wrapped input`() {
@@ -17,23 +17,24 @@ import com.intellij.util.ui.JBUI
*/
@Suppress("UnstableApiUsage")
class TurnViewTest : BasePlatformTestCase() {
private val openFile: (String) -> Unit = {}
// ------ TurnView ------
fun `test new TurnView is empty`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
assertTrue(tv.messageIds().isEmpty())
}
fun `test addMessage appends and returns view`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
val mv = tv.addMessage(msg("u1", "user"))
assertEquals("u1", mv.msg.info.id)
assertEquals(listOf("u1"), tv.messageIds())
}
fun `test addMessage preserves insertion order`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
tv.addMessage(msg("a1", "assistant"))
tv.addMessage(msg("a2", "assistant"))
@@ -41,7 +42,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test messageView returns the view for a given id`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
val mv = tv.messageView("u1")
assertNotNull(mv)
@@ -49,12 +50,12 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test messageView returns null for unknown id`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
assertNull(tv.messageView("missing"))
}
fun `test removeMessage removes the view`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
tv.addMessage(msg("a1", "assistant"))
@@ -65,14 +66,14 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test removeMessage unknown id is noop`() {
val tv = TurnView("t1")
val tv = TurnView("t1", openFile)
tv.addMessage(msg("u1", "user"))
tv.removeMessage("nope")
assertEquals(listOf("u1"), tv.messageIds())
}
fun `test dump produces correct format`() {
val tv = TurnView("u1")
val tv = TurnView("u1", openFile)
tv.addMessage(msg("u1", "user"))
tv.addMessage(msg("a1", "assistant"))
assertEquals("user#u1, assistant#a1", tv.dump())
@@ -81,22 +82,22 @@ class TurnViewTest : BasePlatformTestCase() {
// ------ MessageView ------
fun `test new MessageView is empty`() {
val mv = MessageView(msg("u1", "user"))
val mv = MessageView(msg("u1", "user"), openFile)
assertTrue(mv.partIds().isEmpty())
}
fun `test MessageView for user message has user role`() {
val mv = MessageView(msg("u1", "user"))
val mv = MessageView(msg("u1", "user"), openFile)
assertEquals("user", mv.role)
}
fun `test MessageView for assistant message has assistant role`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
assertEquals("assistant", mv.role)
}
fun `test upsertPart adds a new TextView for Text content`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
val text = ai.kilocode.client.session.model.Text("p1")
text.content.append("hello")
mv.upsertPart(text)
@@ -106,7 +107,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test upsertPart updates existing part rather than adding duplicate`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
val t1 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v1") }
mv.upsertPart(t1)
@@ -119,7 +120,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test removePart removes the renderer`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("x") })
mv.removePart("p1")
@@ -128,13 +129,13 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test removePart unknown id is noop`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
mv.removePart("none")
assertTrue(mv.partIds().isEmpty())
}
fun `test appendDelta reaches TextView`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("hello ") })
mv.appendDelta("p1", "world")
@@ -144,7 +145,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test appendDelta for unknown part id is noop`() {
val mv = MessageView(msg("a1", "assistant"))
val mv = MessageView(msg("a1", "assistant"), openFile)
// Must not throw
mv.appendDelta("unknown", "delta")
}
@@ -154,7 +155,7 @@ class TurnViewTest : BasePlatformTestCase() {
val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") }
message.parts["p1"] = text
val mv = MessageView(message)
val mv = MessageView(message, openFile)
assertEquals(listOf("p1"), mv.partIds())
assertTrue(mv.part("p1") is TextView)
@@ -166,7 +167,7 @@ class TurnViewTest : BasePlatformTestCase() {
val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED }
message.parts["r1"] = reasoning
message.parts["t1"] = tool
val mv = MessageView(message)
val mv = MessageView(message, openFile)
mv.setSize(400, 200)
mv.doLayout()
@@ -178,7 +179,7 @@ class TurnViewTest : BasePlatformTestCase() {
}
fun `test consecutive messages use shared compact gap`() {
val tv = TurnView("u1")
val tv = TurnView("u1", openFile)
tv.addMessage(msg("u1", "user").also { msg ->
msg.parts["t1"] = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED }
})
@@ -0,0 +1,95 @@
package ai.kilocode.client.session.views.todo
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TodoViewDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Color
@Suppress("UnstableApiUsage")
class TodoWriteViewTest : BasePlatformTestCase() {
fun `test canRender only completed todowrite`() {
assertTrue(TodoWriteView.canRender(tool("todowrite", ToolExecState.COMPLETED)))
assertFalse(TodoWriteView.canRender(tool("todowrite", ToolExecState.PENDING)))
assertFalse(TodoWriteView.canRender(tool("todowrite", ToolExecState.RUNNING)))
assertFalse(TodoWriteView.canRender(tool("bash", ToolExecState.COMPLETED)))
}
fun `test renders title subtitle and rows`() {
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(
TodoDto("Done", "completed", "high"),
TodoDto("Next", "pending", "medium"),
)
})
assertTrue(view.labelText().contains("To-dos"))
assertTrue(view.labelText().contains("1/2"))
assertTrue(view.isExpanded())
assertEquals(2, view.rowCount())
assertTrue(view.rowChecked(0))
assertFalse(view.rowChecked(1))
assertTrue(view.rowText(0).contains("<s>Done</s>"))
assertFalse(view.rowCheckboxOpaque(0))
assertFalse(view.rowCheckboxOpaque(1))
}
fun `test pending rows keep normal foreground`() {
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(
TodoDto("Done", "completed", "high"),
TodoDto("Next", "pending", "medium"),
)
})
val style = SessionEditorStyle.current().copy(editorForeground = Color(1, 2, 3))
view.applyStyle(style)
assertEquals(style.editorForeground, view.rowForeground(1))
}
fun `test compact view renders hidden labels and visible rows`() {
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(
TodoDto("Done", "completed", "high"),
TodoDto("Next", "pending", "medium"),
TodoDto("Later", "pending", "low"),
)
it.todoView = TodoViewDto(
mode = "compact",
todos = listOf(TodoDto("Changed", "pending", "high", changed = true)),
hiddenBefore = 1,
hiddenAfter = 1,
changed = 1,
)
})
assertTrue(view.labelText().contains("1/3"))
assertEquals(1, view.rowCount())
assertTrue(view.rowText(0).contains("Changed"))
assertTrue(view.hiddenText().contains("earlier to-do hidden"))
assertTrue(view.hiddenText().contains("later to-do hidden"))
}
fun `test update reuses root and updates rows`() {
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(TodoDto("Old", "pending", "medium"))
})
val root = view.components.single()
view.update(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(TodoDto("New", "completed", "high"))
})
assertSame(root, view.components.single())
assertTrue(view.labelText().contains("1/1"))
assertTrue(view.rowChecked(0))
assertTrue(view.rowText(0).contains("New"))
}
private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state }
}
@@ -35,6 +35,10 @@ class FakeAppRpcApi : KiloAppRpcApi {
private set
var retries = 0
private set
var restarts = 0
private set
var reinstalls = 0
private set
override suspend fun connect() {
assertNotEdt("connect")
@@ -58,10 +62,12 @@ class FakeAppRpcApi : KiloAppRpcApi {
override suspend fun restart() {
assertNotEdt("restart")
restarts += 1
}
override suspend fun reinstall() {
assertNotEdt("reinstall")
reinstalls += 1
}
override suspend fun modelState(): ModelStateDto {
@@ -3,6 +3,7 @@ package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +21,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING))
var reloads = 0
private set
var fileMatches = emptyList<WorkspaceFileDto>()
var openResult = true
val fileCalls = mutableListOf<Pair<String, String>>()
val opened = mutableListOf<String>()
override suspend fun resolveProjectDirectory(hint: String): String {
assertNotEdt("resolveProjectDirectory")
@@ -35,4 +40,16 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
assertNotEdt("reload")
reloads += 1
}
override suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
assertNotEdt("files")
fileCalls.add(directory to path)
return fileMatches
}
override suspend fun openFile(path: String): Boolean {
assertNotEdt("openFile")
opened.add(path)
return openResult
}
}
@@ -21,6 +21,7 @@ object ChatLogSummary {
is ChatEventDto.PartRemoved -> event.sessionID
is ChatEventDto.TurnOpen -> event.sessionID
is ChatEventDto.TurnClose -> event.sessionID
is ChatEventDto.SessionCreated -> event.sessionID
is ChatEventDto.Error -> event.sessionID
is ChatEventDto.MessageRemoved -> event.sessionID
is ChatEventDto.PermissionAsked -> event.sessionID
@@ -133,6 +134,12 @@ object ChatLogSummary {
"reason=${event.reason}",
)
is ChatEventDto.SessionCreated -> join(
sid(event.sessionID),
"evt=session.created",
"title=${event.info.title.length}",
)
is ChatEventDto.Error -> join(
sid(event.sessionID),
"evt=session.error",
@@ -1,6 +1,7 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
@@ -36,4 +37,10 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
/** Trigger a full reload of workspace data. */
suspend fun reload(directory: String)
/** Resolve [path] to matching files, scoped primarily to [directory]. */
suspend fun files(directory: String, path: String): List<WorkspaceFileDto>
/** Open an absolute backend file path in the IDE. */
suspend fun openFile(path: String): Boolean
}
@@ -67,6 +67,8 @@ data class PartDto(
val output: String? = null,
val error: String? = null,
val time: PartTimeDto? = null,
val todos: List<TodoDto> = emptyList(),
val todoView: TodoViewDto? = null,
val reason: String? = null,
val cost: Double? = null,
val tokens: TokensDto? = null,
@@ -147,6 +149,13 @@ sealed class ChatEventDto {
val reason: String,
) : ChatEventDto()
@Serializable
@SerialName("session.created")
data class SessionCreated(
val sessionID: String,
val info: SessionDto,
) : ChatEventDto()
@Serializable
@SerialName("session.error")
data class Error(
@@ -291,6 +300,7 @@ data class QuestionRequestDto(
val sessionID: String,
val questions: List<QuestionInfoDto>,
val tool: ToolRefDto? = null,
val blocking: Boolean = false,
)
@Serializable
@@ -300,12 +310,17 @@ data class QuestionInfoDto(
val options: List<QuestionOptionDto> = emptyList(),
val multiple: Boolean = false,
val custom: Boolean = true,
val questionKey: String? = null,
val headerKey: String? = null,
)
@Serializable
data class QuestionOptionDto(
val label: String,
val description: String,
val labelKey: String? = null,
val descriptionKey: String? = null,
val mode: String? = null,
)
@Serializable
@@ -320,6 +335,16 @@ data class TodoDto(
val content: String,
val status: String,
val priority: String,
val changed: Boolean = false,
)
@Serializable
data class TodoViewDto(
val mode: String = "full",
val todos: List<TodoDto> = emptyList(),
val hiddenBefore: Int = 0,
val hiddenAfter: Int = 0,
val changed: Int = 0,
)
// --- Diff DTO ---
@@ -0,0 +1,10 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
data class WorkspaceFileDto(
val path: String,
val name: String,
val directory: Boolean = false,
)
@@ -285,7 +285,7 @@ export namespace PlanFollowup {
// main prompt input below the dock already routes typed text as a question
// reply, so "Type your own answer" would be redundant (originally hidden in
// 65566af7f8, flipped back during the v1.4.4 upstream merge).
custom: Flag.KILO_CLIENT === "cli",
custom: Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "jetbrains",
options: [
{
label: ANSWER_NEW_SESSION,
@@ -28,7 +28,7 @@ export namespace KiloSessionPrompt {
*/
export function shouldAskPlanFollowup(input: { messages: MessageV2.WithParts[]; abort: AbortSignal }) {
if (input.abort.aborted) return false
if (!["cli", "vscode"].includes(Flag.KILO_CLIENT)) return false
if (!["cli", "vscode", "jetbrains"].includes(Flag.KILO_CLIENT)) return false
const idx = input.messages.findLastIndex((m) => m.info.role === "user")
return input.messages
.slice(idx + 1)
@@ -145,6 +145,49 @@ describe("plan_exit detection", () => {
await expect(pending).resolves.toBe("break")
}))
test("JetBrains client enables plan follow-up with custom answer", () =>
withInstance(async () => {
const prev = process.env.KILO_CLIENT
try {
process.env.KILO_CLIENT = "jetbrains"
const seeded = await seed({
text: "Here is the plan",
tools: [
{
tool: "plan_exit",
input: {},
output: "Plan is ready. Ending planning turn.",
},
],
})
expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(true)
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const question = await waitQuestion(seeded.sessionID)
expect(question).toBeDefined()
if (!question) return
expect(question.questions[0].question).toBe("Ready to implement?")
expect(question.questions[0].header).toBe("Implement")
expect(question.questions[0].custom).toBe(true)
expect(question.questions[0].options.map((item) => item.label)).toEqual([
PlanFollowup.ANSWER_NEW_SESSION,
PlanFollowup.ANSWER_CONTINUE,
])
expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe("code")
await Question.reject(question.id)
await expect(pending).resolves.toBe("break")
} finally {
if (prev === undefined) delete process.env.KILO_CLIENT
else process.env.KILO_CLIENT = prev
}
}))
test("PlanFollowup.ask triggers and continue works with plan_exit", () =>
withInstance(async () => {
const seeded = await seed({