mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #10552 from Kilo-Org/quaint-airbus
feat(jetbrains): support plan follow-ups and todo rendering
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support starting implementation from completed planning sessions in JetBrains.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show todo updates as checklist cards in JetBrains session transcripts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Open completed plan file links from JetBrains session transcripts.
|
||||
+26
-9
@@ -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) {
|
||||
|
||||
+1
@@ -48,6 +48,7 @@ class KiloBackendChatManager(
|
||||
"message.part.removed",
|
||||
"session.turn.open",
|
||||
"session.turn.close",
|
||||
"session.created",
|
||||
"session.error",
|
||||
"session.status",
|
||||
"session.updated",
|
||||
|
||||
+25
-1
@@ -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
|
||||
}
|
||||
|
||||
+14
-1
@@ -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()
|
||||
|
||||
+69
-12
@@ -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> {
|
||||
|
||||
+2
-1
@@ -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") {
|
||||
|
||||
+84
@@ -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 =
|
||||
|
||||
+100
-2
@@ -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"}"""
|
||||
|
||||
+162
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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
-3
@@ -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
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -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)
|
||||
|
||||
+129
-7
@@ -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
@@ -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. */
|
||||
|
||||
+6
@@ -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,
|
||||
)
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+73
-4
@@ -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)
|
||||
|
||||
+9
-6
@@ -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)
|
||||
|
||||
+82
@@ -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)"
|
||||
}
|
||||
+3
-2
@@ -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()
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+7
-2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+140
@@ -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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -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
|
||||
|
||||
+34
@@ -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 }
|
||||
}
|
||||
}
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+8
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+69
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
+190
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
|
||||
+16
@@ -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()
|
||||
|
||||
|
||||
+21
-1
@@ -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,
|
||||
|
||||
+53
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+46
@@ -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)
|
||||
}
|
||||
|
||||
+49
@@ -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."
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
|
||||
+40
-2
@@ -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`() {
|
||||
|
||||
+21
-20
@@ -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 }
|
||||
})
|
||||
|
||||
+95
@@ -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 }
|
||||
}
|
||||
+6
@@ -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 {
|
||||
|
||||
+17
@@ -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 ---
|
||||
|
||||
+10
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user