Merge pull request #12612 from Kilo-Org/lovely-wallflower

feat(jetbrains): improve session changes and diff review
This commit is contained in:
Kirill Kalishev
2026-07-31 11:04:23 -04:00
committed by GitHub
77 changed files with 4667 additions and 210 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Improve JetBrains session change tracking: show the files each assistant turn modified with expandable per-file diffs, open inline and branch diffs in a refreshable diff viewer, and surface branch changes in the session header.
@@ -27,6 +27,7 @@ import ai.kilocode.rpc.dto.CustomProviderSaveDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageSummaryDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.McpConfigDto
@@ -261,16 +262,7 @@ object KiloCliDataParser {
"session.diff" -> {
val sid = props.str("sessionID") ?: return null
val diffs = props["diff"]?.jsonArray?.mapNotNull { elem ->
val d = elem.jsonObject
val file = d.str("file") ?: return@mapNotNull null
DiffFileDto(
file = file,
additions = d.long("additions")?.safeInt() ?: 0,
deletions = d.long("deletions")?.safeInt() ?: 0,
patch = d.str("patch"),
)
} ?: emptyList()
val diffs = parseDiffs(props["diff"])
ChatEventDto.SessionDiffChanged(sid, diffs)
}
@@ -1047,6 +1039,8 @@ object KiloCliDataParser {
val time = obj["time"]?.jsonObject
val tokens = obj["tokens"]?.jsonObject
val error = obj["error"]?.jsonObject
val raw = obj["summary"].obj()?.get("diffs")
val summary = if (raw == null) null else MessageSummaryDto(parseDiffs(raw))
return MessageDto(
id = obj.str("id") ?: "",
@@ -1063,9 +1057,25 @@ object KiloCliDataParser {
cost = obj.num("cost"),
tokens = tokens?.let(::parseTokens),
error = error?.let { parseError(it) },
summary = summary,
)
}
private fun parseDiffs(raw: JsonElement?): List<DiffFileDto> {
val arr = raw.arr() ?: return emptyList()
return arr.mapNotNull { elem ->
val item = elem.obj() ?: return@mapNotNull null
val file = item.str("file") ?: return@mapNotNull null
DiffFileDto(
file = file,
additions = item.long("additions")?.safeInt() ?: 0,
deletions = item.long("deletions")?.safeInt() ?: 0,
patch = item.str("patch"),
status = item.str("status"),
)
}
}
internal fun parsePart(obj: JsonObject): PartDto {
val state = obj["state"]?.jsonObject
val tokens = obj["tokens"]?.jsonObject
@@ -11,6 +11,7 @@ import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
@@ -26,6 +27,8 @@ import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.components.service
import ai.kilocode.log.KiloLog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onCompletion
@@ -141,6 +144,17 @@ class KiloSessionRpcApiImpl internal constructor(
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
ready { chat.messages(id, directory) }
override suspend fun diff(id: String, directory: String): List<DiffFileDto> = ready {
// GET /session/:id/diff returns the cumulative, deduplicated, unquoted snapshot diff. Prefer it
// over concatenating per-message summaries (which duplicate files per turn and skip unquoting).
val api = app.api ?: throw IllegalStateException("Kilo API is unavailable")
withContext(Dispatchers.IO) { api.sessionDiff(sessionID = id, directory = directory) }
.mapNotNull { file ->
val path = file.file ?: return@mapNotNull null
DiffFileDto(path, file.additions.toInt(), file.deletions.toInt(), file.patch, file.status?.value)
}
}
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? =
ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) }
@@ -15,6 +15,7 @@ import ai.kilocode.jetbrains.api.model.Agent
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.isManagedWorktreeStorage
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
@@ -55,6 +56,10 @@ import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.fileSize
import kotlin.io.path.inputStream
import kotlin.io.path.isRegularFile
import kotlin.io.path.readBytes
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
@@ -76,6 +81,7 @@ class KiloWorkspaceRpcApiImpl internal constructor(
private val GLOBAL = MODERN + LEGACY + "config.json"
private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode")
private const val DIFF_CAP = 200_000
private const val LARGE_FILE = 2 * 1024 * 1024L
private val JSON = Json { ignoreUnknownKeys = true }
private val CONFIG = """{
"${'$'}schema": "$SCHEMA"
@@ -248,6 +254,39 @@ class KiloWorkspaceRpcApiImpl internal constructor(
text.takeIf { it.isNotBlank() }?.take(DIFF_CAP)
}
override suspend fun branchDiff(directory: String, patches: Boolean): List<DiffFileDto> = withContext(Dispatchers.IO) {
val base = file(clean(directory) ?: directory) ?: return@withContext emptyList()
if (!gitAvailable(base)) return@withContext emptyList()
val anc = mergeBase(base)
// --relative scopes diff output to the opened directory and emits project-relative paths, so
// tracked entries match the untracked list (ls-files is already cwd-relative) in monorepos.
val stats = parseNumstat(git(base, "-c", "core.quotepath=false", "diff", "--numstat", "--relative", "--no-color", "--no-renames", anc))
val status = parseNameStatus(git(base, "-c", "core.quotepath=false", "diff", "--name-status", "--relative", "--no-color", "--no-renames", anc))
val untrackedPaths = git(base, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard")
.lineSequence()
.filter { it.isNotBlank() }
.toList()
// Stats-only DTOs (empty patch); the badge path stops here. untracked() streams a line
// count on this path instead of materializing each file as a String.
val files = stats.map { DiffFileDto(it.path, it.additions, it.deletions, "", status[it.path] ?: "modified") } +
untrackedPaths.map { untracked(base, it, withPatch = false) }
if (!patches) return@withContext files
// Fetch patches lazily and stop once the running total reaches DIFF_CAP, so a branch with
// hundreds of changed files doesn't spawn a git subprocess (or read a file) per entry.
capDiff(files, DIFF_CAP) { file ->
if (file.status == "untracked") untracked(base, file.file, withPatch = true).patch.orEmpty()
else fileDiff(base, anc, file.file)
}
}
override suspend fun branchName(directory: String): String? = withContext(Dispatchers.IO) {
val base = file(clean(directory) ?: directory) ?: return@withContext null
if (!gitAvailable(base)) return@withContext null
git(base, "branch", "--show-current").trim().ifBlank {
git(base, "rev-parse", "--short", "HEAD").trim()
}.ifBlank { null }
}
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
val item = clean(path) ?: return false
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
@@ -368,6 +407,55 @@ class KiloWorkspaceRpcApiImpl internal constructor(
return runWorkspaceGit(base, *args)
}
/** Merge-base of the resolved default branch and HEAD, or HEAD when no base can be determined. */
private fun mergeBase(base: Path): String {
val ref = defaultBranch(base) ?: return "HEAD"
return git(base, "merge-base", ref, "HEAD").trim().ifBlank { "HEAD" }
}
/**
* Resolve the base branch ref, preferring the remote's declared default (origin HEAD), then the
* common origin and local main or master branches, so repos whose default is develop or trunk —
* or worktrees where only the remote branch exists locally — still resolve. Fully-qualified refs
* are used so a tag named "main" can't be mistaken for the branch.
*/
private fun defaultBranch(base: Path): String? {
git(base, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD").trim()
.removePrefix("refs/remotes/")
.takeIf { it.isNotBlank() }
?.let { return it }
return listOf(
"refs/remotes/origin/main" to "origin/main",
"refs/remotes/origin/master" to "origin/master",
"refs/heads/main" to "main",
"refs/heads/master" to "master",
).firstOrNull { git(base, "rev-parse", "--verify", "--quiet", it.first).isNotBlank() }?.second
}
private fun fileDiff(base: Path, anc: String, path: String): String =
git(base, "-c", "core.quotepath=false", "diff", "--relative", "--no-color", "--no-ext-diff", "--no-renames", "--unified=2147483647", anc, "--", path)
private fun untracked(base: Path, rel: String, withPatch: Boolean): DiffFileDto {
return runCatching {
val path = base.resolve(rel).normalize()
if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > LARGE_FILE) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked")
if (!withPatch) {
// Badge path (runs on every turn end / revert): count lines by streaming bytes rather
// than allocating the whole file. null = binary (NUL byte), reported as 0/0.
val count = countLines(path) ?: return@runCatching DiffFileDto(rel, 0, 0, "", "untracked")
return@runCatching DiffFileDto(rel, count, 0, "", "untracked")
}
val bytes = path.readBytes()
if (bytes.any { it == 0.toByte() }) return@runCatching DiffFileDto(rel, 0, 0, "", "untracked")
val text = bytes.toString(StandardCharsets.UTF_8)
val additions = lines(text).size
DiffFileDto(rel, additions, 0, untrackedPatch(rel, text, additions), "untracked")
}.getOrElse { err ->
LOG.debug { "Failed to read untracked file for branch diff: $rel (${err.message})" }
DiffFileDto(rel, 0, 0, "", "untracked")
}
}
private fun agent(a: Agent) = AgentInfo(
name = a.name,
displayName = a.displayName,
@@ -427,6 +515,106 @@ internal fun resolveProjectDirectoryHint(hint: String, bases: List<String>): Str
return bases.firstOrNull() ?: hint
}
/**
* Assemble capped diff DTOs. [fetch] lazily produces each file's full-context patch. Oversized
* patches are skipped, but a single generated/large file should not blank every later small file;
* after a bounded number of misses, stop fetching so large branches still don't run a git subprocess
* (or read a file) per entry only to discard the output. Files past the cap keep their stats but
* carry an empty patch, which the client renders from stats alone.
*/
internal fun capDiff(files: List<DiffFileDto>, cap: Int, fetch: (DiffFileDto) -> String): List<DiffFileDto> {
var used = 0
var misses = 0
var full = false
return files.map { file ->
if (full) return@map file.copy(patch = "")
val text = fetch(file)
when {
text.isBlank() -> file.copy(patch = "")
used + text.length <= cap -> {
used += text.length
if (used >= cap) full = true
file.copy(patch = text)
}
else -> {
misses++
full = misses >= MAX_OVERSIZED_PATCHES || used >= cap
file.copy(patch = "")
}
}
}
}
private const val MAX_OVERSIZED_PATCHES = 3
private fun untrackedPatch(path: String, text: String, additions: Int): String = buildString {
appendLine("diff --git a/$path b/$path")
appendLine("new file mode 100644")
appendLine("--- /dev/null")
appendLine("+++ b/$path")
appendLine("@@ -0,0 +1,$additions @@")
lines(text).forEach { line -> appendLine("+$line") }
if (text.isNotEmpty() && !text.endsWith("\n")) appendLine("\\ No newline at end of file")
}.removeSuffix("\n")
private fun lines(text: String): List<String> {
if (text.isEmpty()) return emptyList()
return text.removeSuffix("\n").split('\n')
}
/**
* Count lines the way [lines] does (trailing newline ignored, empty file = 0) by streaming bytes,
* so the stats-only untracked path doesn't allocate the whole file. Returns null for binary content
* (a NUL byte), matching the with-patch path's binary guard.
*/
private fun countLines(path: Path): Int? {
var newlines = 0
var last = 0
var any = false
path.inputStream().buffered().use { input ->
val buf = ByteArray(8192)
while (true) {
val n = input.read(buf)
if (n <= 0) break
any = true
for (i in 0 until n) {
val b = buf[i].toInt()
if (b == 0) return null
if (b == '\n'.code) newlines++
}
last = buf[n - 1].toInt()
}
}
if (!any) return 0
return if (last == '\n'.code) newlines else newlines + 1
}
internal data class DiffStat(val path: String, val additions: Int, val deletions: Int)
internal fun parseNameStatus(text: String): Map<String, String> = text.lineSequence()
.mapNotNull { line ->
val parts = line.split('\t')
if (parts.size < 2) return@mapNotNull null
val path = parts.drop(1).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null
val status = when (parts[0].firstOrNull()) {
'A' -> "added"
'D' -> "deleted"
'M' -> "modified"
else -> null
} ?: return@mapNotNull null
path to status
}
.toMap()
internal fun parseNumstat(text: String): List<DiffStat> = text.lineSequence()
.mapNotNull { line ->
val parts = line.split('\t')
if (parts.size < 3) return@mapNotNull null
val path = parts.drop(2).joinToString("\t").takeIf { it.isNotBlank() } ?: return@mapNotNull null
DiffStat(path, parts[0].toIntOrNull() ?: 0, parts[1].toIntOrNull() ?: 0)
}
.toList()
internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap<String, Boolean> = ConcurrentHashMap()): Boolean {
if (Files.exists(base.resolve(".git"))) return true
return cache.getOrPut(base.toString()) {
@@ -1,8 +1,10 @@
package ai.kilocode.backend.cli
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageSummaryDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PartTimeDto
@@ -15,6 +17,7 @@ import ai.kilocode.rpc.dto.SessionStatusDto
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
@@ -233,6 +236,34 @@ class ChatDtoSerializationTest {
assertEquals("a.png", decoded.filename)
}
@Test
fun `MessageDto summary diffs are preserved in round-trip`() {
val msg = msg("msg_1").copy(
summary = MessageSummaryDto(
diffs = listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch", "modified")),
),
)
val encoded = json.encodeToString(MessageDto.serializer(), msg)
assertTrue(encoded.contains(""""summary""""))
assertTrue(encoded.contains(""""diffs""""))
val decoded = json.decodeFromString(MessageDto.serializer(), encoded)
val diff = decoded.summary?.diffs?.single()
assertEquals("src/A.kt", diff?.file)
assertEquals("@@ patch", diff?.patch)
assertEquals("modified", diff?.status)
}
@Test
fun `MessageDto summary defaults to null`() {
val encoded = json.encodeToString(MessageDto.serializer(), msg("msg_1"))
val decoded = json.decodeFromString(MessageDto.serializer(), encoded)
assertNull(decoded.summary)
}
@Test
fun `PromptDto variant is preserved in round-trip`() {
val prompt = PromptDto(
@@ -86,7 +86,10 @@ class KiloCliDataParserTest {
"id": "msg_1",
"sessionID": "ses_123",
"role": "assistant",
"time": { "created": 1000.0 }
"time": { "created": 1000.0 },
"summary": {
"diffs": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}]
}
}
}
}
@@ -98,6 +101,11 @@ class KiloCliDataParserTest {
assertEquals("ses_123", result.sessionID)
assertEquals("msg_1", result.info.id)
assertEquals("assistant", result.info.role)
val diff = result.info.summary?.diffs?.single()
assertEquals("src/A.kt", diff?.file)
assertEquals(3, diff?.additions)
assertEquals(1, diff?.deletions)
assertEquals("@@ ...", diff?.patch)
}
@Test
@@ -120,6 +128,7 @@ class KiloCliDataParserTest {
assertTrue(result is ChatEventDto.MessageUpdated)
assertEquals("ses_456", result.sessionID)
assertEquals("user", result.info.role)
assertNull(result.info.summary)
}
// ---- parseChatEvent — specific event types ----
@@ -1358,11 +1367,14 @@ class KiloCliDataParserTest {
fun `parseMessages - user and assistant messages`() {
val raw = """[
{
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
"info": {
"id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 },
"summary": { "diffs": [{"file": "src/A.kt", "additions": 2, "deletions": 1, "patch": "@@ patch"}] }
},
"parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }]
},
{
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } },
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 }, "summary": true },
"parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }]
}
]"""
@@ -1370,8 +1382,11 @@ class KiloCliDataParserTest {
val result = KiloCliDataParser.parseMessages(raw)
assertEquals(2, result.size)
assertEquals("user", result[0].info.role)
assertEquals("src/A.kt", result[0].info.summary?.diffs?.single()?.file)
assertEquals("@@ patch", result[0].info.summary?.diffs?.single()?.patch)
assertEquals("Hello", result[0].parts[0].text)
assertEquals("assistant", result[1].info.role)
assertNull(result[1].info.summary)
assertEquals("Hi there", result[1].parts[0].text)
}
@@ -0,0 +1,105 @@
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.dto.DiffFileDto
import kotlin.test.Test
import kotlin.test.assertEquals
class BranchDiffTest {
private fun stat(path: String, status: String = "modified") = DiffFileDto(path, 1, 0, "", status)
@Test
fun `capDiff fills patches in order until the cap is reached`() {
val files = listOf(stat("a.txt"), stat("b.txt"), stat("c.txt"))
val diff = capDiff(files, cap = 5) { "12345" }
assertEquals(listOf("a.txt", "b.txt", "c.txt"), diff.map { it.file })
assertEquals("12345", diff[0].patch)
assertEquals("", diff[1].patch)
assertEquals("", diff[2].patch)
}
@Test
fun `capDiff skips one oversized patch and keeps later small patches`() {
val fetched = mutableListOf<String>()
val files = listOf(stat("big.txt"), stat("small.txt"), stat("tiny.txt"))
val diff = capDiff(files, cap = 4) { file ->
fetched += file.file
if (file.file == "big.txt") "0123456789" else "x"
}
assertEquals(listOf("big.txt", "small.txt", "tiny.txt"), fetched)
assertEquals(listOf("", "x", "x"), diff.map { it.patch })
}
@Test
fun `capDiff stops fetching after bounded oversized misses`() {
val fetched = mutableListOf<String>()
val files = listOf(stat("big1.txt"), stat("big2.txt"), stat("big3.txt"), stat("later.txt"))
val diff = capDiff(files, cap = 4) { file ->
fetched += file.file
"0123456789"
}
assertEquals(listOf("big1.txt", "big2.txt", "big3.txt"), fetched)
assertEquals(listOf("", "", "", ""), diff.map { it.patch })
}
@Test
fun `capDiff keeps stats and skips blank patches without exhausting the budget`() {
val fetched = mutableListOf<String>()
val files = listOf(stat("empty.txt"), stat("kept.txt"))
val diff = capDiff(files, cap = 10) { file ->
fetched += file.file
if (file.file == "empty.txt") "" else "patch"
}
assertEquals(listOf("empty.txt", "kept.txt"), fetched)
assertEquals("", diff[0].patch)
assertEquals("patch", diff[1].patch)
}
@Test
fun `capDiff dispatches fetch per file so tracked and untracked share the budget`() {
val files = listOf(stat("A.kt", "modified"), stat("New.kt", "untracked"))
val diff = capDiff(files, cap = 100) { file ->
if (file.status == "untracked") "untracked-patch" else "tracked-patch"
}
assertEquals("tracked-patch", diff[0].patch)
assertEquals("untracked-patch", diff[1].patch)
assertEquals("untracked", diff[1].status)
}
@Test
fun `parses git numstat output`() {
val stats = parseNumstat("1\t2\tsrc/A.kt\n0\t3\tsrc/B.kt\n-\t-\tbin.png\n")
assertEquals(
listOf(
DiffStat("src/A.kt", 1, 2),
DiffStat("src/B.kt", 0, 3),
DiffStat("bin.png", 0, 0),
),
stats,
)
}
@Test
fun `parses git name status output`() {
val status = parseNameStatus("M\tsrc/A.kt\nA\tsrc/B.kt\nD\tsrc/Old.kt\n??\tsrc/Skip.kt\n")
assertEquals(
mapOf(
"src/A.kt" to "modified",
"src/B.kt" to "added",
"src/Old.kt" to "deleted",
),
status,
)
}
}
@@ -8,6 +8,7 @@ import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
@@ -214,6 +215,10 @@ class KiloSessionService internal constructor(
call { messages(id, dir) }
.also { log.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } }
// Errors propagate so the diff editor can distinguish a real failure (retry link) from "no changes".
suspend fun diff(id: String, dir: String): List<DiffFileDto> =
call { diff(id, dir) }
suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? =
call { attachmentPart(id, dir, message, part, key) }
@@ -4,6 +4,7 @@ package ai.kilocode.client.app
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
@@ -159,6 +160,25 @@ class KiloWorkspaceService internal constructor(
}
}
/**
* Committed branch changes vs the default-branch merge-base. Errors propagate so the diff editor
* can surface a retry (a swallowed failure is indistinguishable from "no changes"); pass
* [patches] = false on the badge path to fetch stats only and skip materializing patch text.
*/
suspend fun branchDiff(directory: String, patches: Boolean = true): List<DiffFileDto> =
call { branchDiff(directory, patches) }
suspend fun branchName(directory: String): String? {
return try {
call { branchName(directory) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LOG.warn("branch name lookup failed for directory=$directory", e)
null
}
}
suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean {
val match = files(directory, path).firstOrNull() ?: return false
return try {
@@ -0,0 +1,39 @@
package ai.kilocode.client.diff
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.diff.DiffContentFactory
import com.intellij.diff.requests.DiffRequest
import com.intellij.diff.requests.SimpleDiffRequest
import com.intellij.diff.util.DiffUserDataKeys
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.project.Project
internal fun diffRequest(
project: Project,
dto: DiffFileDto,
branch: String? = null,
labels: Pair<String, String> = KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.current"),
): DiffRequest {
val sides = DiffPatchReconstruct.sides(dto)
val type = FileTypeManager.getInstance().getFileTypeByFileName(dto.file)
val factory = DiffContentFactory.getInstance()
val left = when {
DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty()
sides.renderable -> factory.create(project, sides.before, type)
else -> factory.createEmpty()
}
val right = when {
DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty()
sides.renderable -> factory.create(project, sides.after, type)
else -> factory.create(project, dto.patch ?: KiloBundle.message("diff.editor.patch.unavailable"), type)
}
return SimpleDiffRequest(diffTitle(dto.file, branch), left, right, labels.first, labels.second).also {
it.putUserData(DiffUserDataKeys.FORCE_READ_ONLY, true)
}
}
internal fun diffTitle(file: String, branch: String?): String {
val name = branch.takeIf { !it.isNullOrBlank() } ?: return file
return KiloBundle.message("diff.editor.file.title", file, name)
}
@@ -0,0 +1,16 @@
package ai.kilocode.client.diff
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.vcs.FileStatus
internal fun fileStatus(dto: DiffFileDto): FileStatus = when (dto.status) {
"added" -> FileStatus.ADDED
"deleted" -> FileStatus.DELETED
"untracked" -> FileStatus.UNKNOWN
"modified" -> FileStatus.MODIFIED
else -> when {
DiffPatchReconstruct.added(dto.patch) -> FileStatus.ADDED
DiffPatchReconstruct.deleted(dto.patch) -> FileStatus.DELETED
else -> FileStatus.MODIFIED
}
}
@@ -0,0 +1,93 @@
package ai.kilocode.client.diff
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.TextAnnotationGutterProvider
import com.intellij.openapi.editor.colors.ColorKey
import com.intellij.openapi.editor.colors.EditorFontType
import com.intellij.ui.EditorTextField
import java.awt.Color
object DiffLineNumbers {
data class Row(val old: Int?, val new: Int?)
private val HUNK = Regex("^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@")
fun rows(patch: String): List<Row> {
val rows = mutableListOf<Pair<String, Row>>()
var old = 0
var new = 0
var hunk = false
patch.lineSequence().forEach { line ->
// Hunk headers (and any pre-hunk file/VCS headers) are the only lines stripped, matching
// pureDiff's hunk-aware body. In-hunk lines are kept verbatim even when they look like a
// header (e.g. a deleted "-- " comment renders as "--- ..."), so the counters stay aligned.
if (line.startsWith("@@")) {
HUNK.find(line)?.let { match ->
old = match.groupValues[1].toInt()
new = match.groupValues[2].toInt()
}
hunk = true
return@forEach
}
if (!hunk) return@forEach
when {
line.startsWith("+") -> rows.add(line to Row(null, new++))
line.startsWith("-") -> rows.add(line to Row(old++, null))
line.startsWith("\\") -> rows.add(line to Row(null, null))
else -> rows.add(line to Row(old++, new++))
}
}
return rows.trimBlankEdges().map { it.second }
}
private fun List<Pair<String, Row>>.trimBlankEdges(): List<Pair<String, Row>> {
// isNotEmpty (not isNotBlank) mirrors pureDiff's trim('\n'): a blank context line renders as
// a single space that survives the body trim, so an empty-string edge is the only one dropped.
val start = indexOfFirst { it.first.isNotEmpty() }
if (start < 0) return emptyList()
val end = indexOfLast { it.first.isNotEmpty() }
return subList(start, end + 1)
}
}
fun installDiffGutter(field: EditorTextField, rows: List<DiffLineNumbers.Row>) {
val ed = field.getEditor(true) ?: return
ed.settings.isLineNumbersShown = false
ed.gutter.closeAllAnnotations()
ed.gutter.registerTextAnnotation(DiffGutter(rows))
}
private const val FIGURE = '\u2007'
private class DiffGutter(private val rows: List<DiffLineNumbers.Row>) : TextAnnotationGutterProvider {
private val oldWidth = width { it.old }
private val newWidth = width { it.new }
override fun getLineText(line: Int, editor: Editor): String? {
val row = rows.getOrNull(line) ?: return null
// The gutter paints with a proportional font, so pad with the figure space (digit-width)
// to right-align both columns. Each column keeps a fixed width even when a side is blank,
// and trailing figure spaces add a right inset before the code text.
return "${col(row.old, oldWidth)}$FIGURE${col(row.new, newWidth)}$FIGURE$FIGURE"
}
override fun getToolTip(line: Int, editor: Editor): String? = null
override fun getStyle(line: Int, editor: Editor): EditorFontType = EditorFontType.PLAIN
override fun getColor(line: Int, editor: Editor): ColorKey? = null
override fun getBgColor(line: Int, editor: Editor): Color? = null
override fun gutterClosed() = Unit
override fun getPopupActions(line: Int, editor: Editor): List<AnAction>? = null
override fun useMargin(): Boolean = false
private fun width(pick: (DiffLineNumbers.Row) -> Int?): Int =
rows.mapNotNull(pick).maxOrNull()?.toString()?.length ?: 1
private fun col(value: Int?, width: Int): String = value?.toString().orEmpty().padStart(width, FIGURE)
}
@@ -0,0 +1,71 @@
package ai.kilocode.client.diff
import ai.kilocode.rpc.dto.DiffFileDto
internal data class DiffSides(
val before: String,
val after: String,
val renderable: Boolean,
)
internal object DiffPatchReconstruct {
private val HUNK = Regex("^@@ -\\d+(?:,(\\d+))? \\+\\d+(?:,(\\d+))? @@")
fun sides(dto: DiffFileDto): DiffSides {
val patch = dto.patch
if (patch.isNullOrBlank() || binary(patch)) return DiffSides("", "", false)
val before = StringBuilder()
val after = StringBuilder()
var hunks = 0
var oldLen = 0
var newLen = 0
var oldSeen = 0
var newSeen = 0
// Drop the trailing empty element that split('\n') yields for a newline-terminated patch (the
// usual case for git output). Counting it as a body line would inflate oldSeen/newSeen past the
// header lengths and wrongly reject every full-context diff. Mirrors DiffLineNumbers' edge trim;
// real blank context lines are " " (space-prefixed), never "", so no content is lost.
for (line in patch.split('\n').dropLastWhile { it.isEmpty() }) {
if (line.startsWith("@@")) {
hunks += 1
HUNK.find(line)?.let { match ->
oldLen += match.groupValues[1].ifEmpty { "1" }.toInt()
newLen += match.groupValues[2].ifEmpty { "1" }.toInt()
}
continue
}
if (hunks == 0) continue
if (line.startsWith("\\")) continue
when (line.firstOrNull()) {
' ' -> {
before.appendLine(line.substring(1))
after.appendLine(line.substring(1))
oldSeen += 1
newSeen += 1
}
'-' -> { before.appendLine(line.substring(1)); oldSeen += 1 }
'+' -> { after.appendLine(line.substring(1)); newSeen += 1 }
else -> {
before.appendLine("")
after.appendLine("")
oldSeen += 1
newSeen += 1
}
}
}
// Both producers (CLI snapshot and branchDiff) emit a single full-context hunk. A patch with
// several hunks, or one whose header lengths don't match the reconstructed body, has elided
// context: reconstructing would place every line at the wrong number, so fall back to the
// raw-patch view (renderable = false) instead of showing a misaligned side-by-side diff.
if (hunks != 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false)
val left = if (added(patch)) "" else before.toString().removeSuffix("\n")
val right = if (deleted(patch)) "" else after.toString().removeSuffix("\n")
return DiffSides(left, right, true)
}
fun added(patch: String?): Boolean = patch?.lineSequence()?.any { it == "--- /dev/null" } == true
fun deleted(patch: String?): Boolean = patch?.lineSequence()?.any { it == "+++ /dev/null" } == true
private fun binary(patch: String): Boolean = patch.lineSequence().any { it.startsWith("Binary files ") }
}
@@ -0,0 +1,613 @@
package ai.kilocode.client.diff
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.diff.chains.DiffRequestProducer
import com.intellij.diff.chains.SimpleDiffRequestChain
import com.intellij.diff.impl.CacheDiffRequestChainProcessor
import com.intellij.diff.impl.DiffRequestProcessorListener
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonShortcuts
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.event.DocumentEvent
import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.vcs.FileStatus
import com.intellij.openapi.vfs.AsyncFileListener
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import com.intellij.ui.IdeBorderFactory
import com.intellij.ui.EditorNotificationPanel
import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.PopupHandler
import com.intellij.ui.SideBorder
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.TreeSpeedSearch
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.treeStructure.Tree
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import javax.swing.JViewport
import javax.swing.JTree
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.TreeCellRenderer
import javax.swing.tree.TreeModel
import javax.swing.tree.TreeNode
import javax.swing.tree.TreePath
@RequiresEdt
internal fun buildDiffEditor(
project: Project,
params: Map<String, String>,
files: List<DiffFileDto>,
parent: Disposable,
branch: String? = null,
scope: CoroutineScope,
refresh: ((DiffEditorData) -> Unit) -> Job,
replace: (DiffEditorData) -> Unit,
): JComponent = DiffEditorView(project, params, files, parent, branch, scope, refresh, replace).component
internal val DIFF_FILE_KEY: Key<String> = Key.create("kilo.diff.file")
internal class DiffEditorView(
private val project: Project,
private val params: Map<String, String>,
initial: List<DiffFileDto>,
private val parent: Disposable,
branch: String?,
private val scope: CoroutineScope,
private val load: ((DiffEditorData) -> Unit) -> Job,
private val replace: (DiffEditorData) -> Unit,
) : Disposable {
private val disposed = AtomicBoolean(false)
private val outdated = AtomicBoolean(false)
private val refreshing = AtomicBoolean(false)
private val tree = buildFileTree(initial)
private val badge = DiffStatBadge(0, 0, inset = UiStyle.Gap.pad())
private val splitter = OnePixelSplitter(false, 0.25f)
private val select = Debouncer<Int>(scope, parent) { show(it) }
private val banner = EditorNotificationPanel(EditorNotificationPanel.Status.Warning).apply {
text(KiloBundle.message("diff.editor.outdated"))
createActionLabel(KiloBundle.message("diff.editor.refresh")) { refresh() }
isVisible = false
}
private val root = JPanel(BorderLayout()).apply {
add(banner, BorderLayout.NORTH)
add(splitter, BorderLayout.CENTER)
}
private var files = initial
private var branch = branch
private var syncing = false
private var requested: String? = initial.firstOrNull()?.file
private var refreshJob: Job? = null
private var processor = processor(initial, selected(initial.firstOrNull()?.file))
private val openFileAction = object : DumbAwareAction(
KiloBundle.message("diff.editor.openFile"),
KiloBundle.message("diff.editor.openFile"),
AllIcons.Actions.EditSource,
) {
override fun getActionUpdateThread() = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val file = selectedFile()
e.presentation.isEnabled = file != null && fileStatus(file) != FileStatus.DELETED && path(file) != null
}
override fun actionPerformed(e: AnActionEvent) {
val file = selectedFile() ?: return
val path = path(file) ?: return
scope.launch { service<KiloWorkspaceService>().openFile(path) }
}
}
val component: JComponent = root
init {
Disposer.register(parent, this)
Disposer.register(parent, processor)
tree.addTreeSelectionListener {
if (syncing) return@addTreeSelectionListener
val file = selectedFile() ?: return@addTreeSelectionListener
val index = files.indexOfFirst { it.file == file.file }
if (index >= 0) select.request(index)
}
openFileAction.registerCustomShortcutSet(CommonShortcuts.getEditSource(), tree)
installMenu()
// Tie the listener to the processor it observes, not to the long-lived parent: applyFiles
// disposes the old processor on each refresh, and registering under parent would leak a
// removal hook (holding the dead processor) for every refresh across the editor's lifetime.
processor.addListener(DiffRequestProcessorListener { syncTree() }, processor)
splitter.firstComponent = buildTreePanel(tree, initial, badge, processor.component, ::refresh)
splitter.secondComponent = processor.component
processor.updateRequest()
applyBadge(initial)
select(initial.firstOrNull()?.file)
listen()
}
override fun dispose() {
disposed.set(true)
// Bind the refresh coroutine to this view's lifecycle (load already does so via `parent`): an
// in-flight refresh started just before the editor closes would otherwise keep running on the
// project scope, holding the `done` closure and through it this view, its tree, and processor.
refreshJob?.cancel()
refreshJob = null
}
@RequiresEdt
fun applyFiles(next: List<DiffFileDto>, nextBranch: String? = branch) {
if (same(files, next) && branch == nextBranch) return
val path = selectedFile()?.file ?: activePath() ?: files.firstOrNull()?.file
val index = selected(path, next)
val old = processor
files = next
branch = nextBranch
requested = next.getOrNull(index)?.file
tree.model = buildFileModel(next)
expandAll(tree)
processor = processor(next, index)
Disposer.register(parent, processor)
processor.addListener(DiffRequestProcessorListener { syncTree() }, processor)
splitter.firstComponent = buildTreePanel(tree, next, badge, processor.component, ::refresh)
splitter.secondComponent = processor.component
processor.updateRequest()
Disposer.dispose(old)
applyBadge(next)
select(next.getOrNull(index)?.file)
root.revalidate()
root.repaint()
}
@RequiresEdt
internal fun refresh() {
if (disposed.get() || project.isDisposed) return
if (!refreshing.compareAndSet(false, true)) return
saveDocuments()
outdated.set(false)
banner.isVisible = false
root.revalidate()
root.repaint()
refreshJob?.cancel()
refreshJob = load { data ->
refreshing.set(false)
if (!disposed.get() && !project.isDisposed) {
if (data is DiffEditorData.Files) applyFiles(data.files, data.branch)
if (data !is DiffEditorData.Files) replace(data)
}
}
}
internal fun markOutdated() {
if (!outdated.compareAndSet(false, true)) return
ApplicationManager.getApplication().invokeLater({ showOutdated() }, ModalityState.any()) {
disposed.get() || project.isDisposed
}
}
private fun show(index: Int) {
if (disposed.get() || project.isDisposed || index !in files.indices) return
val path = files[index].file
if (activePath() == path) {
requested = null
return
}
requested = path
processor.setCurrentRequest(index)
}
private fun installMenu() {
val group = DefaultActionGroup(
openFileAction,
Separator.getInstance(),
TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, ::refresh),
)
PopupHandler.installPopupMenu(tree, group, ActionPlaces.POPUP)
}
@RequiresEdt
private fun showOutdated() {
if (disposed.get() || project.isDisposed) return
banner.isVisible = true
root.revalidate()
root.repaint()
}
private fun listen() {
val dir = params["directory"] ?: return
val root = clean(dir) ?: return
EditorFactory.getInstance().eventMulticaster.addDocumentListener(
object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
val file = FileDocumentManager.getInstance().getFile(event.document) ?: return
if (inside(root, file.path)) markOutdated()
}
},
parent,
)
VirtualFileManager.getInstance().addAsyncFileListenerBackgroundable(
object : AsyncFileListener {
override fun prepareChange(events: List<VFileEvent>): AsyncFileListener.ChangeApplier? {
if (outdated.get()) return null
if (events.none { inside(root, it.path) }) return null
return object : AsyncFileListener.ChangeApplier {
override fun afterVfsChange() {
markOutdated()
}
}
}
},
parent,
)
}
private fun saveDocuments() {
val dir = params["directory"] ?: return
val root = clean(dir) ?: return
val manager = FileDocumentManager.getInstance()
manager.saveDocuments { doc ->
val file = manager.getFile(doc) ?: return@saveDocuments false
inside(root, file.path)
}
}
private fun processor(next: List<DiffFileDto>, index: Int): CacheDiffRequestChainProcessor {
val producers = next.map { file -> producer(file) }
val chain = SimpleDiffRequestChain.fromProducers(producers, index.coerceIn(0, (next.size - 1).coerceAtLeast(0)))
return CacheDiffRequestChainProcessor(project, chain)
}
private fun producer(file: DiffFileDto): DiffRequestProducer = object : DiffRequestProducer {
override fun getName(): String = file.file
override fun process(context: UserDataHolder, indicator: ProgressIndicator) = diffRequest(project, file, branch, labels()).also {
it.putUserData(DIFF_FILE_KEY, file.file)
}
}
private fun labels(): Pair<String, String> {
if (params["source"] == "branch") {
return KiloBundle.message("diff.editor.side.base") to KiloBundle.message("diff.editor.side.current")
}
return KiloBundle.message("diff.editor.side.original") to KiloBundle.message("diff.editor.side.modified")
}
private fun syncTree() {
if (disposed.get()) return
val path = activePath() ?: return
val target = reverseSyncTarget(path, requested, selectedFile()?.file)
if (path == requested) requested = null
if (target == null) return
select(target)
}
private fun path(file: DiffFileDto): String? {
if (fileStatus(file) == FileStatus.DELETED) return null
val dir = params["directory"] ?: return null
val root = clean(dir) ?: return null
return try {
val raw = Path.of(file.file)
val path = (if (raw.isAbsolute) raw else root.resolve(raw)).normalize()
// Constrain "open file" to the diff's directory: reject a server-supplied entry that
// escapes via `..` or an absolute path outside the base rather than opening it blindly.
if (!path.startsWith(root)) return null
path.toString()
} catch (_: InvalidPathException) {
null
}
}
private fun select(path: String?) {
if (path == null) return
syncing = true
selectTreeNode(tree, path)
syncing = false
}
private fun activePath(): String? = processor.activeRequest?.getUserData(DIFF_FILE_KEY)
private fun selectedFile(): DiffFileDto? {
val node = tree.lastSelectedPathComponent as? DefaultMutableTreeNode ?: return null
return (node.userObject as? Node)?.file
}
private fun applyBadge(next: List<DiffFileDto>) {
badge.update(next.sumOf { it.additions }, next.sumOf { it.deletions })
}
private fun selected(path: String?, next: List<DiffFileDto> = files): Int {
val index = next.indexOfFirst { it.file == path }
if (index >= 0) return index
return 0
}
private fun same(a: List<DiffFileDto>, b: List<DiffFileDto>): Boolean = a == b
private fun clean(dir: String): Path? = try {
Path.of(dir).normalize()
} catch (_: InvalidPathException) {
null
}
private fun inside(root: Path, raw: String): Boolean = try {
val path = Path.of(raw).normalize()
if (!path.startsWith(root)) return false
val rel = root.relativize(path).toString().replace('\\', '/')
if (rel == ".git/HEAD" || rel.startsWith(".git/refs/")) return true
rel != ".git" && !rel.startsWith(".git/")
} catch (_: InvalidPathException) {
false
}
}
internal fun reverseSyncTarget(active: String?, requested: String?, selected: String?): String? {
if (active == null) return null
if (requested != null) return null
if (active == selected) return null
return active
}
private class Debouncer<T>(
private val scope: CoroutineScope,
parent: Disposable,
private val delay: Long = 300,
private val action: suspend (T) -> Unit,
) {
private var job: Job? = null
init {
Disposer.register(parent) { job?.cancel() }
}
fun request(value: T) {
job?.cancel()
job = scope.launch {
delay(delay)
withContext(Dispatchers.Main) { action(value) }
}
}
}
@RequiresEdt
internal fun emptyChangesComponent(): JComponent = JPanel(BorderLayout()).apply {
add(com.intellij.ui.components.JBLabel(KiloBundle.message("diff.editor.empty")), BorderLayout.CENTER)
}
private fun buildFileTree(files: List<DiffFileDto>): Tree {
val tree = DiffTree(buildFileModel(files)).apply {
isRootVisible = false
showsRootHandles = true
isOpaque = true
cellRenderer = Renderer()
}
TreeSpeedSearch(tree) { path ->
val node = path.lastPathComponent as? DefaultMutableTreeNode
(node?.userObject as? Node)?.name.orEmpty()
}
expandAll(tree)
return tree
}
private fun buildFileModel(files: List<DiffFileDto>): DefaultTreeModel {
val root = DefaultMutableTreeNode(Node("", "", true, null))
for (file in files) addFile(root, file)
updateStats(root)
return DefaultTreeModel(root)
}
private fun buildTreePanel(tree: Tree, files: List<DiffFileDto>, badge: DiffStatBadge, target: JComponent, refresh: () -> Unit): JComponent {
val toolbar = ActionManager.getInstance().createActionToolbar(
ActionPlaces.TOOLBAR,
treeToolbarGroup(tree, refresh),
true,
)
toolbar.targetComponent = target
toolbar.component.background = JBUI.CurrentTheme.ToolWindow.background()
toolbar.updateActionsImmediately()
val row = object : JPanel(BorderLayout()) {
override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background()
}.apply {
border = IdeBorderFactory.createBorder(SideBorder.BOTTOM)
add(toolbar.component, BorderLayout.WEST)
badge.update(files.sumOf { it.additions }, files.sumOf { it.deletions })
add(
Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(0, 0, 0, UiStyle.Gap.pad())
next(JBLabel(fileCount(files.size)).apply { foreground = UiStyle.Colors.weak() })
next(badge)
},
BorderLayout.EAST,
)
}
return object : JPanel(BorderLayout()) {
override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background()
}.apply {
add(row, BorderLayout.NORTH)
add(
JBScrollPane(tree).apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
},
BorderLayout.CENTER,
)
}
}
internal fun treeToolbarGroup(tree: Tree, refresh: () -> Unit) = DefaultActionGroup(
TreeAction(KiloBundle.message("diff.editor.refresh"), AllIcons.Actions.Refresh, refresh),
Separator.getInstance(),
TreeAction(KiloBundle.message("diff.editor.tree.expandAll"), AllIcons.Actions.Expandall) { expandAll(tree) },
TreeAction(KiloBundle.message("diff.editor.tree.collapseAll"), AllIcons.Actions.Collapseall) { collapseAll(tree) },
)
private fun fileCount(count: Int): String = KiloBundle.message(
if (count == 1) "session.changes.count.one" else "session.changes.count.other",
count,
)
private fun expandAll(tree: Tree) {
var i = 0
while (i < tree.rowCount) {
tree.expandRow(i)
i += 1
}
}
private fun collapseAll(tree: Tree) {
for (i in tree.rowCount - 1 downTo 0) tree.collapseRow(i)
}
private fun addFile(root: DefaultMutableTreeNode, file: DiffFileDto) {
var node = root
val parts = file.file.split('/').filter { it.isNotBlank() }
for ((index, part) in parts.withIndex()) {
val path = parts.take(index + 1).joinToString("/")
val leaf = index == parts.lastIndex
val child = child(node, path) ?: DefaultMutableTreeNode(Node(part, path, !leaf, if (leaf) file else null)).also(node::add)
node = child
}
}
private fun updateStats(node: DefaultMutableTreeNode): Stats {
val item = node.userObject as? Node ?: return Stats(0, 0)
if (item.file != null) return Stats(item.additions, item.deletions)
val stats = (0 until node.childCount)
.map { updateStats(node.getChildAt(it) as? DefaultMutableTreeNode ?: return@map Stats(0, 0)) }
.fold(Stats(0, 0)) { acc, child -> Stats(acc.additions + child.additions, acc.deletions + child.deletions) }
item.additions = stats.additions
item.deletions = stats.deletions
return stats
}
private fun child(node: DefaultMutableTreeNode, path: String): DefaultMutableTreeNode? {
for (i in 0 until node.childCount) {
val child = node.getChildAt(i) as? DefaultMutableTreeNode ?: continue
if ((child.userObject as? Node)?.path == path) return child
}
return null
}
private fun selectTreeNode(tree: Tree, path: String) {
val node = find(tree.model.root as? DefaultMutableTreeNode ?: return, path) ?: return
val selection = TreePath(node.path)
tree.selectionPath = selection
tree.scrollPathToVisible(selection)
}
private fun find(node: DefaultMutableTreeNode, path: String): DefaultMutableTreeNode? {
if ((node.userObject as? Node)?.path == path) return node
for (i in 0 until node.childCount) {
val found = find(node.getChildAt(i) as? DefaultMutableTreeNode ?: continue, path)
if (found != null) return found
}
return null
}
private data class Stats(val additions: Int, val deletions: Int)
private class Node(val name: String, val path: String, val dir: Boolean, val file: DiffFileDto?) {
var additions: Int = file?.additions ?: 0
var deletions: Int = file?.deletions ?: 0
}
private class DiffTree(model: TreeModel) : Tree(model) {
override fun getBackground(): Color = JBUI.CurrentTheme.ToolWindow.background()
override fun getScrollableTracksViewportHeight(): Boolean {
val view = parent as? JViewport ?: return super.getScrollableTracksViewportHeight()
return preferredSize.height < view.height || super.getScrollableTracksViewportHeight()
}
}
private class Renderer : JPanel(BorderLayout()), TreeCellRenderer {
private val text = SimpleColoredComponent()
private val badge = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT)
init {
UiStyle.Components.transparent(this, text)
border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, UiStyle.Gap.xl())
add(text, BorderLayout.CENTER)
add(badge, BorderLayout.EAST)
}
override fun getTreeCellRendererComponent(
tree: JTree,
value: Any?,
selected: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean,
): Component {
val node = value as? DefaultMutableTreeNode
val item = node?.userObject as? Node
text.clear()
text.icon = if (item?.dir == true) AllIcons.Nodes.Folder else AllIcons.FileTypes.Text
val name = item?.name?.ifBlank { item.path }.orEmpty()
val color = item?.file?.let(::fileStatus)?.color
if (color == null) text.append(name) else text.append(name, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, color))
val changed = item != null && (item.additions != 0 || item.deletions != 0)
badge.isVisible = changed
if (changed) badge.update(item.additions, item.deletions)
return this
}
}
private class TreeAction(
text: String,
icon: Icon,
private val action: () -> Unit,
) : DumbAwareAction(text, text, icon) {
override fun getActionUpdateThread() = ActionUpdateThread.EDT
override fun actionPerformed(e: AnActionEvent) = action()
}
private val TreeNode.path: Array<TreeNode>
get() {
val list = mutableListOf<TreeNode>()
var node: TreeNode? = this
while (node != null) {
list += node
node = node.parent
}
return list.asReversed().toTypedArray()
}
@@ -0,0 +1,207 @@
package ai.kilocode.client.diff
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.vfs.KiloEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.Centerizer
import com.intellij.util.ui.JBUI
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JComponent
import javax.swing.JPanel
internal object KiloDiffEditorKind : KiloEditorKind {
const val ID = "kilo-diff"
override val id: String = ID
override fun title(params: Map<String, String>): String {
return params["title"].takeIfPresent()
?: params["branch"].takeIfPresent()?.let { KiloBundle.message("diff.editor.branch.title.named", it) }
?: KiloBundle.message(if (params["source"] == "branch") "diff.editor.branch.title" else "diff.editor.session.title")
}
override fun presentablePath(params: Map<String, String>): String = title(params)
override fun isValid(params: Map<String, String>): Boolean {
val dir = params["directory"].takeIfPresent() ?: return false
if (dir.isBlank()) return false
if (params["source"] == "branch") return true
if (params["source"] == "inline") return params["token"].takeIfPresent() != null
return params["sessionId"].takeIfPresent() != null
}
@RequiresEdt
override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent {
val panel = JPanel(BorderLayout())
panel.add(connecting(), BorderLayout.CENTER)
val service = project.service<KiloDiffEditorService>()
var current: Disposable? = null
fun render(data: DiffEditorData) {
current?.let { Disposer.dispose(it) }
val child = Disposer.newDisposable(parent, "Kilo diff editor content")
current = child
panel.removeAll()
panel.add(
when (data) {
DiffEditorData.Connecting -> connecting()
DiffEditorData.Empty -> emptyChangesComponent()
is DiffEditorData.Error -> failed(data.message)
is DiffEditorData.Files -> buildDiffEditor(
project,
file.path.params,
data.files,
child,
data.branch,
service.scope,
{ done -> service.refresh(file.path.params, done) },
::render,
)
},
BorderLayout.CENTER,
)
panel.revalidate()
panel.repaint()
}
service.load(file.path.params, parent, ::render)
return panel
}
}
@Service(Service.Level.PROJECT)
internal class KiloDiffEditorService(
private val project: Project,
private val cs: CoroutineScope,
) {
internal val scope: CoroutineScope
get() = cs
fun load(params: Map<String, String>, parent: Disposable, done: (DiffEditorData) -> Unit) {
val disposed = AtomicBoolean(false)
val job = cs.launch {
val app = service<KiloAppService>()
app.connect()
withContext(Dispatchers.Main) {
if (alive(disposed)) done(DiffEditorData.Connecting)
}
val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR }
if (state.status == KiloAppStatusDto.ERROR) {
withContext(Dispatchers.Main) {
if (alive(disposed)) done(DiffEditorData.Error(KiloBundle.message("session.connection.error.app")))
}
return@launch
}
val data = runCatching { fetch(params) }
.getOrElse {
if (it is CancellationException) throw it
LOG.warn("diff editor load failed source=${params["source"]} dir=${params["directory"]}", it)
DiffEditorData.Error(it.message ?: it::class.java.simpleName)
}
withContext(Dispatchers.Main) {
if (alive(disposed)) done(data)
}
}
Disposer.register(parent) {
disposed.set(true)
job.cancel()
}
}
fun refresh(params: Map<String, String>, done: (DiffEditorData) -> Unit) = cs.launch {
val data = runCatching { fetch(params) }
.getOrElse {
if (it is CancellationException) throw it
LOG.warn("diff editor refresh failed source=${params["source"]} dir=${params["directory"]}", it)
DiffEditorData.Error(it.message ?: it::class.java.simpleName)
}
withContext(Dispatchers.Main) {
if (!project.isDisposed) done(data)
}
}
private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get()
internal suspend fun fetch(params: Map<String, String>): DiffEditorData {
val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty
val workspace = service<KiloWorkspaceService>()
val store = project.service<KiloInlineDiffStore>()
val files = when (params["source"]) {
// branch is authoritative here (no store seeding): recompute on every load/refresh so a
// re-open or Refresh always reflects the current worktree instead of a stale click seed.
"branch" -> workspace.branchDiff(dir)
"inline" -> store.get(params["token"].orEmpty()).orEmpty()
else -> project.service<KiloSessionService>().diff(params["sessionId"].orEmpty(), dir)
}
if (files.isEmpty()) return DiffEditorData.Empty
val branch = params["branch"].takeIfPresent()
?: if (params["source"] == "branch") workspace.branchName(dir) else null
return DiffEditorData.Files(files, branch)
}
private companion object {
private val LOG = KiloLog.create(KiloDiffEditorService::class.java)
}
}
internal sealed interface DiffEditorData {
data object Connecting : DiffEditorData
data object Empty : DiffEditorData
data class Error(val message: String) : DiffEditorData
data class Files(val files: List<DiffFileDto>, val branch: String? = null) : DiffEditorData
}
internal fun diffParams(source: String, directory: String, sessionId: String?, title: String, branch: String? = null, token: String? = null): Map<String, String> =
linkedMapOf(
"source" to source,
"directory" to directory,
"title" to title,
).apply {
if (!sessionId.isNullOrBlank()) put("sessionId", sessionId)
if (!branch.isNullOrBlank()) put("branch", branch)
if (!token.isNullOrBlank()) put("token", token)
}
fun ensureDiffEditorKind() {
service<KiloEditorKindRegistry>().register(KiloDiffEditorKind)
}
private fun connecting(): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(AnimatedIcon.Default()))
next(JBLabel(KiloBundle.message("session.connection.connecting")))
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
private fun failed(message: String): JComponent = Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(message))
next(ActionLink(KiloBundle.message("session.connection.retry")) {
service<KiloAppService>().retryAsync()
})
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() }
@@ -0,0 +1,33 @@
package ai.kilocode.client.diff
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.components.Service
import java.util.Collections
/**
* Hands off the diff payload for a "Open in Diff Viewer" click to the editor that opens for it.
*
* Bounded by a small access-ordered LRU: this is a project-level service, so without eviction every
* click would retain the full patch text of its turn for the IDE session's lifetime. [MAX] entries is
* ample for the handful of diff editors a user keeps open, and the eldest entry is dropped after that.
*/
@Service(Service.Level.PROJECT)
class KiloInlineDiffStore {
private val items = Collections.synchronizedMap(
object : LinkedHashMap<String, List<DiffFileDto>>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, List<DiffFileDto>>): Boolean = size > MAX
},
)
fun put(token: String, files: List<DiffFileDto>) {
items[token] = files
}
fun get(token: String): List<DiffFileDto>? = items[token]
fun pop(token: String): List<DiffFileDto>? = items.remove(token)
private companion object {
const val MAX = 32
}
}
@@ -5,6 +5,7 @@ import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.rpc.isManagedWorktreeStorage
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileTypes.FileTypeManager
@@ -27,6 +28,7 @@ import javax.swing.JComponent
import javax.swing.JList
typealias SessionFileOpener = (href: String, anchor: RelativePoint?) -> Unit
typealias SessionDiffOpener = (files: List<DiffFileDto>, title: String, key: String) -> Unit
fun MdView.LinkEvent.anchor(): RelativePoint? {
val component = component ?: return null
@@ -4,6 +4,10 @@ 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.diff.KiloDiffEditorKind
import ai.kilocode.client.diff.KiloInlineDiffStore
import ai.kilocode.client.diff.diffParams
import ai.kilocode.client.diff.ensureDiffEditorKind
import ai.kilocode.client.migration.KiloMigrationService
import ai.kilocode.client.migration.MigrationUiController
import ai.kilocode.client.migration.MigrationUiState
@@ -55,6 +59,7 @@ import ai.kilocode.client.util.UiTimers
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.SessionRevertDto
@@ -82,8 +87,10 @@ import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.util.function.Predicate
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
@@ -202,6 +209,11 @@ class SessionUi(
}
private var editorTheme = style.editorScheme
private var colorTheme = UIManager.getLookAndFeel()
private var wasBusy = false
// Kept separate so a background stat refresh (turn end / revert) can supersede another refresh
// but never cancel an in-flight user-initiated open.
private var refreshJob: Job? = null
private var openJob: Job? = null
private var disposed = false
init {
@@ -214,6 +226,7 @@ class SessionUi(
bindStyle()
bindMigration()
onStateChanged(controller.model.state)
refreshBranchChanges()
loaded?.let(::finishOpen)
}
@@ -370,9 +383,10 @@ class SessionUi(
deleteQueued = { id -> controller.deleteQueuedMessage(id) },
banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus),
).also {
it.setDiffOpener(::openInlineDiff, controller.id)
it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) }
}
header = SessionHeaderPanel(controller, this)
header = SessionHeaderPanel(controller, this) { openBranchChanges() }
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
scroll.onScroll = {
@@ -716,6 +730,7 @@ class SessionUi(
@RequiresEdt
private fun onRevertChanged(revert: SessionRevertDto?) {
refreshBranchChanges()
syncPromptRevert()
val rollback = pendingRollback
if (rollback != null) {
@@ -804,6 +819,74 @@ class SessionUi(
BrowserUtil.browse(url)
}
private fun openInlineDiff(files: List<DiffFileDto>, title: String, key: String) {
cs.launch {
val branch = workspaces.branchName(workspace.directory)
val label = branch?.let { KiloBundle.message("diff.editor.inline.title.named", title, it) } ?: title
withContext(Dispatchers.Main) {
ensureDiffEditorKind()
project.service<KiloInlineDiffStore>().put(key, files)
project.service<KiloVfsManager>().open(
KiloDiffEditorKind.ID,
diffParams("inline", workspace.directory, controller.id, label, token = key),
)
Telemetry.send("Diff Editor Opened", mapOf("source" to "inline"))
}
}
}
/** Badge-only refresh: fetches stats (no patch text) and updates the header count. */
private fun refreshBranchChanges() {
refreshJob?.cancel()
refreshJob = cs.launch {
val files = runCatching { workspaces.branchDiff(workspace.directory, patches = false) }
.getOrElse {
if (it is CancellationException) throw it
LOG.warn("branch changes badge refresh failed dir=${workspace.directory}", it)
return@launch
}
withContext(Dispatchers.Main) {
if (disposed || project.isDisposed) return@withContext
header.setBranchChanges(files)
}
}
}
/** User clicked the badge: opens the branch diff editor. Never cancelled by a background refresh. */
private fun openBranchChanges() {
openJob?.cancel()
openJob = cs.launch {
val dir = workspace.directory
val branch = workspaces.branchName(dir)
val files = runCatching { workspaces.branchDiff(dir, patches = false) }
.getOrElse {
if (it is CancellationException) throw it
LOG.warn("branch changes open failed dir=$dir", it)
emptyList()
}
withContext(Dispatchers.Main) {
if (disposed || project.isDisposed) return@withContext
header.setBranchChanges(files)
openBranchDiff(branch)
}
}
}
@RequiresEdt
private fun openBranchDiff(branch: String?) {
// No store seeding: the diff editor's fetch recomputes branchDiff authoritatively, so a
// re-open or Refresh always reflects the current worktree (and nothing is retained for its life).
ensureDiffEditorKind()
val dir = workspace.directory
val title = branch?.let { KiloBundle.message("diff.editor.branch.title.named", it) }
?: KiloBundle.message("diff.editor.branch.title")
project.service<KiloVfsManager>().open(
KiloDiffEditorKind.ID,
diffParams("branch", dir, null, title, branch),
)
Telemetry.send("Diff Editor Opened", mapOf("source" to "branch"))
}
private fun openAttachment(messageId: String, item: FileAttachment) {
val url = item.url.takeIf { it.isNotBlank() } ?: run {
LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}")
@@ -854,12 +937,15 @@ class SessionUi(
private fun onStateChanged(state: SessionState) {
if (disposed) return
val busy = state.isBusy()
if (wasBusy && state is SessionState.Idle) refreshBranchChanges()
wasBusy = busy
if (state is SessionState.Reverting) overlay.clear()
if (state is SessionState.Error) {
pendingRollback = null
pendingRedo = null
}
prompt.setBusy(state.isBusy())
prompt.setBusy(busy)
load.setState(state)
scroll.setQuestionPending(questionPending(state))
scroll.show(body(state))
@@ -927,6 +1013,8 @@ class SessionUi(
override fun dispose() {
disposed = true
refreshJob?.cancel()
openJob?.cancel()
hide.stop()
popup.hideAll()
modalFocus = null
@@ -162,6 +162,7 @@ class SessionController(
// then reconciled when the operation releases so an underlying server turn is not lost.
private var revertDeferred: SessionState? = null
private var creating: CompletableDeferred<String?>? = null
private val pending = LinkedHashMap<String, Permission>()
private val childJobs: MutableMap<String, Job> = mutableMapOf()
private val childIds: MutableSet<String> = mutableSetOf()
private val childParts: MutableMap<PartKey, String> = mutableMapOf()
@@ -363,6 +364,7 @@ class SessionController(
return
}
val id = sid ?: return
updateModel { (childIds + id).forEach(::purgePending) }
capture("Session Stop Clicked", sessionProps(id))
cs.launch {
try {
@@ -386,6 +388,11 @@ class SessionController(
return
}
val current = model.state
// Clear the local queue before re-surfacing the visible card. approve() may synchronously
// re-enqueue a skill-shell card via show() (skill-shell asks always need a human), so that
// enqueue must be the last writer — otherwise a trailing clear() would drop it and leave a
// ghost card that is not in pending, which a later Stop/idle purge could not clear.
pending.clear()
val skip = if (current is SessionState.AwaitingPermission) {
approve(current.permission)
setOf(current.permission.id)
@@ -720,33 +727,29 @@ class SessionController(
private fun approve(id: String, restore: () -> Permission) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" }
// Skill-shell batches must be answered by a human: the server refuses non-interactive
// approvals, so show the card (its manual reply sets interactive=true) rather than send a
// machine reply. Decide and enqueue synchronously on the EDT so back-to-back asks keep
// arrival (FIFO) order, matching asked()'s non-auto path; only the RPC needs a coroutine.
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
show(restore())
return
}
updateModel { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) }
cs.launch {
try {
// Skill-shell batches must be answered by a human: the server refuses
// non-interactive approvals, so auto-approve must show the card (whose
// manual reply sets interactive=true) rather than send a machine reply.
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
edt {
if (disposed) return@edt
model.setState(SessionState.AwaitingPermission(restore()))
}
return@launch
}
edt {
if (disposed) return@edt
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
sessions.replyPermission(id, directory, PermissionReplyDto("once"))
capture("Permission Auto Approved", sessionProps() + mapOf("tool" to restore().name, "source" to "single"))
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id ok=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
if (disposed) return@edt
model.setState(SessionState.AwaitingPermission(restore().copy(
// Queue the error card too, so pending stays the single source of truth and a
// later Stop / TurnClose / idle purge can clear it instead of stranding it.
show(restore().copy(
state = PermissionRequestState.ERROR,
message = e.message ?: KiloBundle.message("session.permission.error"),
)))
))
}
}
}
@@ -762,18 +765,30 @@ class SessionController(
try {
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip }
val count = replyAll(permissions)
// Skill-shell requests are skipped by replyAll; surface one as a card so it
// isn't stranded (never machine-approved, never shown).
val card = skillShellCard(permissions)?.let { toPermission(it) }
if (count == 0 && card == null) return@launch
// Skill-shell requests are skipped by replyAll; queue all of them so they aren't
// stranded (never machine-approved, never shown) or overwritten by later cards.
val cards = permissions.filter { it.metadata["skillShell"] == "true" }.map(::toPermission)
if (count == 0 && cards.isEmpty()) return@launch
runEdt {
if (disposed) return@runEdt
if (card != null) {
updateModel { model.setState(SessionState.AwaitingPermission(card)) }
if (cards.isNotEmpty()) {
updateModel {
cards.forEach(::enqueue)
if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) {
promote()
}
}
return@runEdt
}
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) {
// A card in `skip` was handled synchronously by the caller (approve() either
// replied to it — already Busy — or re-showed a skill-shell card we must keep).
// Never transition it to Busy here or the preserved skill-shell card vanishes
// with no reply path left.
if (current is SessionState.AwaitingPermission &&
current.permission.sessionId in ids &&
current.permission.id !in skip
) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
}
@@ -803,6 +818,12 @@ class SessionController(
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
assertEdt()
pending[id]?.let { perm ->
pending[id] = perm.copy(
state = state,
message = message ?: perm.message,
)
}
val current = model.state
if (current !is SessionState.AwaitingPermission) return
if (current.permission.id != id) return
@@ -1149,6 +1170,9 @@ class SessionController(
if (child in childParts.values) return
childIds.remove(child)
childJobs.remove(child)?.cancel()
// A sub-agent that finished/was cancelled with an unanswered permission would otherwise leave
// a queue entry that a later promote() surfaces as a live card for a session that no longer exists.
purgePending(child)
}
@RequiresEdt
@@ -1166,6 +1190,7 @@ class SessionController(
childJobs.clear()
childIds.clear()
childParts.clear()
pending.clear()
}
private suspend fun recoverChildPermissions(child: String) {
@@ -1173,21 +1198,23 @@ class SessionController(
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
if (permissions.isEmpty()) return
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
// A skill-shell request must surface as a card even under auto-approve (replyAll
// skips it); prefer it over the last pending so a human can answer.
val show = if (autoApprove) {
// Under auto-approve, replyAll approves the ordinary permissions and skips skill-shell
// ones (they need a human); queue only those. Otherwise queue every pending permission.
val queue = if (autoApprove) {
replyAll(permissions)
skillShellCard(permissions) ?: return
permissions.filter { it.metadata["skillShell"] == "true" }
} else {
skillShellCard(permissions) ?: permissions.last()
permissions
}
val last = toPermission(show)
if (queue.isEmpty()) return
val items = queue.map(::toPermission)
runEdt {
if (disposed) return@runEdt
if (child !in childIds) return@runEdt
// Do not overwrite an existing root or other child AwaitingPermission state
if (model.state is SessionState.AwaitingPermission) return@runEdt
updateModel { model.setState(SessionState.AwaitingPermission(last)) }
items.forEach(::enqueue)
if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) {
updateModel { promote() }
}
}
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
@@ -1236,6 +1263,9 @@ class SessionController(
return
}
}
// After auto-approve only skill-shell permissions still need a human card; queue those.
// Otherwise queue the whole pending set so each request is resolved in turn.
val queue = if (autoApprove) permissions.filter { it.metadata["skillShell"] == "true" } else permissions
val branch = when {
permissions.isNotEmpty() -> "permission"
questions.isNotEmpty() -> "question"
@@ -1249,9 +1279,10 @@ class SessionController(
if (disposed) return@runEdt
if (sid != id) return@runEdt
updateModel {
if (permissions.isNotEmpty()) {
// Prefer a skill-shell request (needs a human) over the last pending.
model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last())))
pending.entries.removeIf { it.value.sessionId == id }
if (queue.isNotEmpty()) {
queue.map(::toPermission).forEach(::enqueue)
promote()
} else if (questions.isNotEmpty()) {
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
} else if (status != null) {
@@ -1358,9 +1389,13 @@ class SessionController(
revertDeferred = SessionState.Idle
return
}
// The turn is done, so any still-queued permission for it is a ghost the CLI abandoned
// server-side without a reply event — drop it before deciding whether to keep a card.
purgePending(event.sessionID)
// Keep pending questions visible for follow-up flows that arrive just before close.
val current = model.state
if (current is SessionState.AwaitingQuestion) return
if (current is SessionState.AwaitingPermission) return
val clobberOk = event.reason == "completed"
|| current is SessionState.Busy
|| current is SessionState.Retry
@@ -1504,15 +1539,22 @@ class SessionController(
approve(event.request)
return
}
val perm = toPermission(event.request)
model.setState(SessionState.AwaitingPermission(perm))
show(toPermission(event.request))
}
private fun replied(event: ChatEventDto.PermissionReplied) {
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
val front = current is SessionState.AwaitingPermission && current.permission.id == event.requestID
pending.remove(event.requestID)
// Front card resolved: advance to the next queued permission, else resume Busy.
if (front) {
model.setState(afterResolve())
return
}
// A queued (non-front) permission or an unrelated prompt is active: leave it in place.
if (current is SessionState.AwaitingPermission || current is SessionState.AwaitingQuestion) return
// Otherwise (busy/idle/etc.) only surface a still-queued permission; never force Busy.
promote()
}
private fun asked(event: ChatEventDto.QuestionAsked) {
@@ -1522,14 +1564,59 @@ class SessionController(
private fun replied(event: ChatEventDto.QuestionReplied) {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
model.setState(afterResolve())
}
}
private fun rejected(event: ChatEventDto.QuestionRejected) {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Idle)
model.setState(afterResolve(idle = true))
}
}
private fun afterResolve(idle: Boolean = false): SessionState {
return pending.values.firstOrNull()?.let { SessionState.AwaitingPermission(it) }
?: if (idle) SessionState.Idle else SessionState.Busy(KiloBundle.message("session.status.considering"))
}
private fun enqueue(perm: Permission) {
pending[perm.id] = perm
}
private fun promote() {
val perm = pending.values.firstOrNull() ?: return
model.setState(SessionState.AwaitingPermission(perm))
}
/**
* Queue [perm] and surface it if no card/question is already up. Wrapped in updateModel so the
* transcript's bottom-follow is preserved (permission cards live inside the scroll pane), and
* kept synchronous so callers on the EDT enqueue in arrival (FIFO) order.
*/
@RequiresEdt
private fun show(perm: Permission) = updateModel {
enqueue(perm)
if (model.state !is SessionState.AwaitingPermission && model.state !is SessionState.AwaitingQuestion) {
promote()
}
}
/**
* Drop queued permissions for [session] and clear/re-promote the visible card when it belonged to
* one of them. The CLI deletes an outstanding permission server-side on turn interruption without
* emitting permission.replied (`Permission.ask` cleans up in `Effect.ensuring`), so on TurnClose /
* idle / child untrack a still-queued entry is a ghost that would otherwise resurface on the next
* promote() and fail to reply with NotFoundError.
*/
@RequiresEdt
private fun purgePending(session: String?) {
if (session == null) return
val removed = pending.entries.removeIf { it.value.sessionId == session }
if (!removed) return
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.sessionId == session) {
model.setState(afterResolve(idle = true))
}
}
@@ -1548,6 +1635,10 @@ class SessionController(
"idle" -> {
val current = model.state
if (current is SessionState.LoginRequired || current is SessionState.Reverting) return
purgePending(sid)
// purgePending may promote a still-queued permission from another (unpurged) child
// session; mirror idle() and leave that card in place rather than clobbering it with Idle.
if (model.state is SessionState.AwaitingPermission) return
SessionState.Idle
}
"busy" -> {
@@ -1649,6 +1740,9 @@ class SessionController(
revertDeferred = SessionState.Idle
return
}
// An idle session cannot have a live permission outstanding — purge any ghost left by an
// abort/error that originated on the server or another client (local abort() already clears).
purgePending(sid)
// Treat session.idle as an explicit signal to return to Idle.
// Only apply if we're not in a more specific non-terminal state.
val current = model.state
@@ -183,7 +183,7 @@ internal class SessionScroll(
} finally {
auto = false
}
tail = atBottom()
tail = near()
syncValue()
updateJump()
if (tail) {
@@ -0,0 +1,204 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.selection.hoverPlaceholder
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.session.views.tool.EditFileChange
import ai.kilocode.client.session.views.tool.POPUP_OPTS
import ai.kilocode.client.session.views.tool.PatchBody
import ai.kilocode.client.session.views.tool.setFont
import ai.kilocode.client.session.views.tool.setForeground
import ai.kilocode.client.session.views.tool.setIcon
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.DiffBars
import ai.kilocode.client.ui.ToolbarButtonAction
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.toolbarButton
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import javax.swing.JComponent
class ModifiedFilesView private constructor(
private val openFile: SessionFileOpener,
private val selection: SessionSelection? = null,
private val parts: Header = Header(),
private val body: PatchBody = PatchBody(selection, openFile),
) : SecondarySessionPartView(parts.panel, { body.mountFiles(emptyList()) }), SessionCopyTarget {
override val contentId = CONTENT_ID
private var style = SessionEditorStyle.current()
private var files = emptyList<EditFileChange>()
private var diffs = emptyList<DiffFileDto>()
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
private var turnId: String = CONTENT_ID
constructor(
openFile: SessionFileOpener,
selection: SessionSelection? = null,
) : this(openFile, selection, Header(), PatchBody(selection, openFile))
init {
body.parent = this
parts.diff.addActionListener { openDiffViewer() }
isVisible = false
bindHeader(parts.glyph, parts.title, parts.count, parts.panel.left, parts.bars, parts.anchor)
applyStyle(style)
}
override val copyEligible: Boolean get() = diffs.isNotEmpty()
override val copyAnchor: JComponent get() = parts.anchor
override val copyToolbar: JComponent get() = parts.diff
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?, turnId: String) {
this.openDiff = openDiff
this.sessionId = sessionId
this.turnId = turnId
}
/** Returns true when anything visible changed, so the parent only relayouts on a real change. */
@RequiresEdt
fun setDiffs(diffs: List<DiffFileDto>): Boolean {
val next = diffs.map(::file)
this.diffs = diffs
if (files == next) {
val visible = next.isNotEmpty()
parts.diff.isEnabled = visible
if (isVisible == visible) return false
isVisible = visible
revalidate()
repaint()
return true
}
files = next
val visible = files.isNotEmpty()
val additions = files.sumOf { it.additions }
val deletions = files.sumOf { it.deletions }
if (isVisible != visible) isVisible = visible
if (!visible) collapse()
parts.update(files.size, additions, deletions)
parts.diff.isEnabled = visible
if (isExpanded()) body.updateFiles(files)
revalidate()
repaint()
return true
}
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
if (!changed) return false
body.updateFiles(files)
body.applyStyle(style)
return true
}
@RequiresEdt
override fun update(content: Content) = Unit
override fun copyText(): String? = null
@RequiresEdt
override fun headerPopup(): HeaderPopupRequest? {
if (isExpanded() || files.isEmpty()) return null
return HeaderPopupRequest(row, build = { buildPopup(files) }) {
Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "changes"))
}
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
parts.applyStyle(style)
body.applyStyle(style)
refresh()
}
override fun dispose() {
body.disposeBody()
super.dispose()
}
@RequiresEdt
internal fun bodyCreated() = body.created()
@RequiresEdt
internal fun bodyVisible() = body.attached(this)
@RequiresEdt
internal fun countText() = parts.count.text
private fun openDiffViewer() {
if (diffs.isEmpty()) return
openDiff(diffs, KiloBundle.message("diff.editor.changedFiles.title"), "turn:${sessionId ?: "pending"}:$turnId")
}
@RequiresEdt
private fun buildPopup(files: List<EditFileChange>): HeaderPopupBody {
val owner = Disposer.newDisposable("Modified files popup body")
val popup = PatchBody(selection, openFile, POPUP_OPTS).also { it.parent = owner }
val panel = popup.mountFiles(files)
popup.applyStyle(style)
return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)
}
private class Header {
val glyph = JBLabel()
val title = JBLabel(KiloBundle.message("session.changes.modified"))
val count = JBLabel()
val diff = toolbarButton(
ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {},
).apply { isEnabled = false }
val anchor = hoverPlaceholder(diff)
val bars = DiffBars(0, 0)
// Left-aligned header: icon, title, file count, sticks change badge, open-in-diff.
val panel = PartHeader().apply {
leading(glyph)
left(title)
titleGap()
left(count, PartHeader.centered(bars), PartHeader.centered(anchor))
}
@RequiresEdt
fun update(total: Int, additions: Int, deletions: Int) {
val text = KiloBundle.message(if (total == 1) "session.changes.count.one" else "session.changes.count.other", total)
if (count.text != text) count.text = text
bars.update(additions, deletions)
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
setIcon(glyph, SessionViewIcons.edit)
setForeground(glyph, SessionUiStyle.View.Tool.completed())
setFont(title, style.boldEditorFont)
setFont(count, style.transcriptFont)
setForeground(title, UiStyle.Colors.fg())
setForeground(count, UiStyle.Colors.weak())
}
}
private companion object {
const val CONTENT_ID = "session-modified-files"
}
}
private fun file(dto: DiffFileDto) = EditFileChange(
path = dto.file,
type = "",
additions = dto.additions,
deletions = dto.deletions,
patch = dto.patch.orEmpty(),
)
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
@@ -79,6 +80,8 @@ class SessionMessageListPanel(
private var hiddenTool: ToolCallRef? = null
private var hovered: PartView? = null
private var revertingMessage: String? = null
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
var onHover: ((PartView, Boolean) -> Unit)? = null
@@ -157,13 +160,21 @@ class SessionMessageListPanel(
// Message events: structural changes are handled via turn events above.
is SessionModelEvent.MessageAdded,
is SessionModelEvent.MessageUpdated,
is SessionModelEvent.MessageRemoved,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
is SessionModelEvent.HeaderUpdated,
is SessionModelEvent.Compacted -> Unit
is SessionModelEvent.MessageUpdated -> {
// message.updated fires on every streamed metadata delta (time/tokens/cost). Only
// relayout the transcript when the turn's modified-files card actually changed,
// not on each delta or when this message isn't a turn anchor.
if (turnViews[event.info.info.id]?.setDiffs(event.info.info.summary?.diffs.orEmpty()) == true) {
refresh()
}
}
is SessionModelEvent.DiffUpdated -> {
banner?.update()
refresh()
@@ -175,6 +186,12 @@ class SessionMessageListPanel(
rebuild()
}
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) }
}
// ------ public lookup API ------
/** Find the [MessageView] for a message by id, or null if not present. */
@@ -223,13 +240,16 @@ class SessionMessageListPanel(
// ------ private event handlers ------
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued)
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also {
it.setDiffOpener(openDiff, sessionId)
}
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
val mv = tv.addMessage(msg)
register(msgId, tv, mv)
}
tv.setDiffs(diffsOf(turn))
tv.syncCopyToolbars()
syncQueued(tv)
syncReverted()
@@ -258,6 +278,7 @@ class SessionMessageListPanel(
val mv = tv.addMessage(msg)
register(id, tv, mv)
}
tv.setDiffs(diffsOf(turn))
tv.syncCopyToolbars()
syncQueued(tv)
syncReverted()
@@ -288,13 +309,16 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued)
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also {
it.setDiffOpener(openDiff, sessionId)
}
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
val mv = tv.addMessage(msg)
register(msgId, tv, mv)
}
tv.setDiffs(diffsOf(turn))
tv.syncCopyToolbars()
syncQueued(tv)
add(tv)
@@ -429,6 +453,9 @@ class SessionMessageListPanel(
add(progress)
}
private fun diffsOf(turn: ai.kilocode.client.session.model.Turn) =
model.message(turn.id)?.info?.summary?.diffs.orEmpty()
private fun register(msgId: String, tv: TurnView, mv: MessageView) {
msgToTurn[msgId] = tv
msgToView[msgId] = mv
@@ -0,0 +1,138 @@
package ai.kilocode.client.session.ui.header
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.ActionEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.AbstractAction
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.KeyStroke
internal class BranchChangesBadge(
private val open: () -> Unit,
) : JPanel(null) {
private val count = JBLabel()
private val stat = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT)
private val row = Stack.horizontal(gap = UiStyle.Gap.sm()).next(count).next(stat)
private var files = emptyList<DiffFileDto>()
private var additions = 0
private var deletions = 0
private var over = false
init {
isOpaque = false
isVisible = false
isFocusable = true
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
toolTipText = KiloBundle.message("diff.editor.branch.tooltip")
getAccessibleContext().accessibleName = KiloBundle.message("diff.editor.branch.tooltip")
border = JBUI.Borders.empty(0, UiStyle.Gap.sm())
add(row)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(event: MouseEvent) = hover(true)
override fun mouseExited(event: MouseEvent) = hover(false)
override fun mouseClicked(event: MouseEvent) = activate()
})
// Keep the action reachable without a mouse (the HoverIcon this replaced was an
// AbstractButton). Enter/Space fire the same guarded action as a click.
val action = object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) = activate()
}
getInputMap(JComponent.WHEN_FOCUSED).apply {
put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE)
put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE)
}
actionMap.put(ACTIVATE, action)
}
private fun activate() {
if (isEnabled) open()
}
fun applyStyle(style: SessionEditorStyle) {
count.font = style.smallFont
count.foreground = UiStyle.Colors.weak()
}
override fun getPreferredSize(): Dimension {
val ins = insets
val size = row.preferredSize
return Dimension(size.width + ins.left + ins.right, JBUI.scale(24))
}
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height)
override fun doLayout() {
val ins = insets
val w = maxOf(0, width - ins.left - ins.right)
val h = maxOf(0, height - ins.top - ins.bottom)
val size = row.preferredSize
val rowW = minOf(size.width, w)
val rowH = minOf(size.height, h)
row.setBounds(ins.left, ins.top + (h - rowH) / 2, rowW, rowH)
}
fun update(next: List<DiffFileDto>): Boolean {
if (files == next) return false
files = next
additions = files.sumOf { it.additions }
deletions = files.sumOf { it.deletions }
val text = KiloBundle.message(
if (files.size == 1) "session.changes.count.one" else "session.changes.count.other",
files.size,
)
count.text = text
stat.update(additions, deletions)
isVisible = files.isNotEmpty()
revalidate()
repaint()
return true
}
override fun paintComponent(g: Graphics) {
if (over && isEnabled) paintHover(g)
super.paintComponent(g)
}
internal fun countText() = count.text
internal fun stats() = additions to deletions
private fun hover(value: Boolean) {
if (over == value) return
over = value
repaint()
}
private fun paintHover(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = UiStyle.Colors.actionHoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
private companion object {
const val ACTIVATE = "kilo.branch.changes.activate"
}
}
@@ -11,6 +11,7 @@ import ai.kilocode.client.session.views.todo.TodoListPanel
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.icons.AllIcons
@@ -19,6 +20,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.SwingTextTrimmer
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
@@ -40,6 +42,7 @@ import javax.swing.SwingUtilities
class SessionHeaderPanel(
private val controller: SessionController,
parent: Disposable,
onOpenBranchDiff: (() -> Unit)? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget {
companion object {
@@ -52,7 +55,15 @@ class SessionHeaderPanel(
internal const val EXPANDED_KEY = "kilo.session.header.expanded"
}
private val title = JBLabel()
private val title = JBLabel().apply {
putClientProperty(SwingTextTrimmer.KEY, SwingTextTrimmer.ELLIPSIS_AT_RIGHT)
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
toggle()
}
})
}
private val cost = JBLabel()
private val context = JBLabel()
private val todos = JBLabel()
@@ -65,7 +76,9 @@ class SessionHeaderPanel(
accessibleContext.accessibleName = KiloBundle.message("session.header.compact")
addActionListener { controller.compact() }
}
private val changes = BranchChangesBadge { onOpenBranchDiff?.invoke() }
private val expand = JBLabel().apply {
border = JBUI.Borders.empty(0, UiStyle.Gap.sm())
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
toolTipText = KiloBundle.message("session.header.expand")
accessibleContext.accessibleName = KiloBundle.message("session.header.expand")
@@ -102,8 +115,38 @@ class SessionHeaderPanel(
iconTextGap = UiStyle.Gap.xs()
}
private val top = BorderLayoutPanel()
private val center = BorderLayoutPanel().apply {
border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, 0)
// Lays the title out first with the branch-changes badge hugging its trailing edge,
// both vertically centered. The title ellipsizes so the badge stays visible on long titles.
private val centerGroup = object : JPanel(null) {
override fun getPreferredSize(): Dimension {
val ins = insets
val t = title.preferredSize
var w = t.width
var h = t.height
if (changes.isVisible) {
val b = changes.preferredSize
w += UiStyle.Gap.sm() + b.width
h = maxOf(h, b.height)
}
return Dimension(w + ins.left + ins.right, h + ins.top + ins.bottom)
}
override fun doLayout() {
val ins = insets
val availW = maxOf(0, width - ins.left - ins.right)
val availH = maxOf(0, height - ins.top - ins.bottom)
val t = title.preferredSize
val gap = if (changes.isVisible) UiStyle.Gap.sm() else 0
val b = if (changes.isVisible) changes.preferredSize else Dimension(0, 0)
val badgeW = minOf(b.width, availW)
val titleW = minOf(t.width, maxOf(0, availW - badgeW - gap))
val titleH = minOf(t.height, availH)
title.setBounds(ins.left, ins.top + (availH - titleH) / 2, titleW, titleH)
if (changes.isVisible) {
val badgeH = minOf(b.height, availH)
changes.setBounds(ins.left + titleW + gap, ins.top + (availH - badgeH) / 2, badgeW, badgeH)
}
}
}
private val right = Stack.horizontal()
.next(cost)
@@ -159,10 +202,11 @@ class SessionHeaderPanel(
isOpaque = true
updateUI()
center.add(title, BorderLayout.CENTER)
center.add(right, BorderLayout.EAST)
centerGroup.add(title)
centerGroup.add(changes)
top.add(expand, BorderLayout.WEST)
top.add(center, BorderLayout.CENTER)
top.add(centerGroup, BorderLayout.CENTER)
top.add(right, BorderLayout.EAST)
add(top, BorderLayout.NORTH)
timeline.addMouseListener(object : MouseAdapter() {
override fun mousePressed(event: MouseEvent) {
@@ -261,6 +305,11 @@ class SessionHeaderPanel(
refresh()
}
fun setBranchChanges(files: List<DiffFileDto>) {
if (!changes.update(files)) return
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
background = style.editorBackground
@@ -268,9 +317,10 @@ class SessionHeaderPanel(
top.background = style.editorBackground
top.isOpaque = true
top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm())
center.background = style.editorBackground
center.isOpaque = true
centerGroup.background = style.editorBackground
centerGroup.isOpaque = true
right.background = style.editorBackground
changes.background = style.editorBackground
tokens.background = style.editorBackground
todoRow.background = style.editorBackground
todoBox.background = style.editorBackground
@@ -278,6 +328,7 @@ class SessionHeaderPanel(
viewport.background = style.editorBackground
title.font = style.boldFont
title.foreground = style.editorForeground
changes.applyStyle(style)
cost.font = style.regularFont
cost.foreground = style.editorForeground
cost.icon = null
@@ -303,6 +354,8 @@ class SessionHeaderPanel(
internal fun titleText(): String = title.text
internal fun titleLabel() = title
internal fun costText(): String = costValue
internal fun costTip() = cost.toolTipText
@@ -340,6 +393,18 @@ class SessionHeaderPanel(
internal fun compactButton() = compact
internal fun changesBadge() = changes
internal fun changesVisible() = changes.isVisible
internal fun changesText() = changes.countText()
internal fun changesStat() = changes.stats()
internal fun centerGroupPanel() = centerGroup
internal fun rightPanel() = right
internal fun expandButton() = expand
internal fun isExpanded() = body.parent === this
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.popup
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.Disposable
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
@@ -15,6 +16,7 @@ import javax.swing.JComponent
import javax.swing.JEditorPane
import javax.swing.JPanel
import javax.swing.JScrollPane
import javax.swing.ScrollPaneConstants
class HeaderPopupRequest(
val anchor: JComponent,
@@ -35,16 +37,29 @@ private class HeaderPopupPanel(
private val child: JComponent,
private val maxWidth: Int,
) : JPanel(BorderLayout()) {
init {
// One scroll pane wraps every popup body (single-file edit, multi-file patch, session changes),
// so bodies taller than the max height scroll instead of clipping. Bodies that carry their own
// inner scroll pane render at full height inside the viewport, so only this outer pane scrolls.
private val scroll = JBScrollPane(
child,
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER,
).apply {
// Transparent so the balloon fill shows uniformly behind nested popup content.
isOpaque = false
add(child, BorderLayout.CENTER)
viewport.isOpaque = false
border = JBUI.Borders.empty()
}
init {
isOpaque = false
add(scroll, BorderLayout.CENTER)
}
override fun getPreferredSize(): Dimension {
val width = contentWidth(child).takeIf { it > 0 }?.coerceAtMost(maxWidth) ?: maxWidth
fit(child, width)
val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT))
val height = child.preferredSize.height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT))
return Dimension(width, height)
}
@@ -1,7 +1,9 @@
package ai.kilocode.client.session.ui.selection
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Dimension
import javax.swing.JComponent
import javax.swing.JPanel
internal interface SessionCopyTarget {
val copyEligible: Boolean get() = true
@@ -13,3 +15,15 @@ internal interface SessionCopyTarget {
@RequiresEdt
fun copyText(): String?
}
internal fun hoverPlaceholder(toolbar: JComponent): JComponent = object : JPanel() {
init {
isOpaque = false
}
override fun getPreferredSize(): Dimension = Dimension(toolbar.preferredSize)
override fun getMinimumSize(): Dimension = Dimension(toolbar.minimumSize)
override fun getMaximumSize(): Dimension = Dimension(toolbar.maximumSize)
}
@@ -37,6 +37,27 @@ object SessionUiStyle {
const val BODY_EXTRA_HEIGHT = 16
}
/**
* Single source of truth for the spacing of every session-card header (see `PartHeader`).
* Keep header gaps here so all cards stay aligned; do not hardcode header spacing elsewhere.
*/
object Header {
/** Leading inset from the card edge to the first header element. */
fun left() = JBUI.scale(Layout.HORIZONTAL_PADDING)
/** Trailing inset from the collapse/expand arrow to the card edge. */
fun right() = JBUI.scale(Layout.HORIZONTAL_PADDING)
/** Gap between the leading glyph icon and the title. */
fun icon() = UiStyle.Gap.sm()
/** Universal gap between every element after the title. */
fun gap() = JBUI.scale(Layout.GAP)
/** Larger gap separating the title from the elements that follow it (one standard step above [gap]). */
fun title() = UiStyle.Gap.lg()
}
object Popup {
const val MAX_WIDTH = 350
const val WIDE_MAX_WIDTH = MAX_WIDTH * 2
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
@@ -18,6 +19,7 @@ import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.tool.EditToolView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.ToolbarButtonAction
@@ -92,6 +94,8 @@ class MessageView(
private var prompt: PromptView? = null
private var promptBox: JPanel? = null
private var wrap: PromptWrap? = null
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
init {
isOpaque = false
@@ -106,6 +110,14 @@ class MessageView(
}
}
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
// Rebind parts created before the opener was wired (e.g. history load), matching the
// late-binding TurnView already does for its ModifiedFilesView card.
for (view in parts.values) if (view is EditToolView) view.setDiffOpener(openDiff, sessionId)
}
/**
* Suppress the running/pending question tool part that matches [ref] while
* the linked question request is active. Pass null to stop suppressing.
@@ -340,9 +352,9 @@ class MessageView(
}
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg)) { openAttachment(msg.info.id, it) }
ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg), { openAttachment(msg.info.id, it) }, openDiff, sessionId)
} else {
ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
ViewFactory.create(content, openFile, openUrl, selection, repo, { openAttachment(msg.info.id, it) }, openDiff, sessionId)
}
private fun syncPromptMentions() {
@@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.UiStyle
@@ -71,7 +72,9 @@ class ReasoningView(
init {
row.border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
SessionUiStyle.View.Header.left(),
JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING),
SessionUiStyle.View.Header.right(),
)
bindHeader(parts.title, parts.icon)
applyStyle(style)
@@ -281,6 +284,8 @@ class ReasoningView(
md.background = style.editorBackground
md.component.border = JBUI.Borders.empty()
md.set(text)
// The shared popup wrapper (HeaderPopupBody) provides the scroll pane, so pass the content
// panel directly instead of nesting a second scroll pane here.
val panel = TrackPanel().apply {
isOpaque = true
background = style.editorBackground
@@ -290,15 +295,7 @@ class ReasoningView(
)
add(md.component, BorderLayout.CENTER)
}
val scroll = JBScrollPane(panel).apply {
border = JBUI.Borders.empty()
isOpaque = true
background = style.editorBackground
viewport.background = style.editorBackground
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
return HeaderPopupBody(scroll, md, style.editorBackground)
return HeaderPopupBody(panel, md, style.editorBackground)
}
private fun bodyMaxHeight(): Int {
@@ -336,7 +333,7 @@ class ReasoningView(
}
class ReasoningParts(
val header: JPanel,
val header: PartHeader,
val title: JBLabel,
val icon: JBLabel,
private val selection: SessionSelection?,
@@ -391,10 +388,9 @@ class ReasoningBody(
private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts {
val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() }
val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() }
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
isOpaque = false
add(icon, BorderLayout.WEST)
add(title, BorderLayout.CENTER)
val header = PartHeader().apply {
leading(icon)
left(title)
}
return ReasoningParts(header, title, icon, selection)
}
@@ -15,10 +15,12 @@ object SessionViewIcons {
val chevronExpanded: Icon = chevronDown
val code = icon("code")
val codeLines = icon("code-lines")
val edit = codeLines
val console = icon("console")
val eye = icon("eye")
val glasses = icon("glasses")
val mcp = icon("mcp")
val openDiff = icon("open-diff")
val ruleApprove = icon("check-small")
val ruleApproveActive = icon("check-small-active")
val ruleDeny = icon("close-small")
@@ -1,8 +1,10 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.ui.ModifiedFilesView
import ai.kilocode.client.session.ui.SessionLayoutPanel
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -10,6 +12,7 @@ import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
@@ -40,7 +43,10 @@ class TurnView(
) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView {
private val messages = LinkedHashMap<String, MessageView>()
private var modified: ModifiedFilesView? = null
private var settled = true
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
override val sessionViewKind = SessionView.Kind.Default
@@ -51,6 +57,13 @@ class TurnView(
isOpaque = false
}
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
modified?.setDiffOpener(openDiff, sessionId, id)
messages.values.forEach { it.setDiffOpener(openDiff, sessionId) }
}
@RequiresEdt
fun setSettled(value: Boolean) {
if (settled == value) return
@@ -64,14 +77,35 @@ class TurnView(
/** Add a new [MessageView] for [msg] at the end of this turn. */
fun addMessage(msg: Message): MessageView {
val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert)
val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert).also {
it.setDiffOpener(openDiff, sessionId)
}
messages[msg.info.id] = view
add(view)
val idx = modified?.let { components.indexOf(it) } ?: componentCount
add(view, idx)
syncCopyToolbars()
revalidate()
return view
}
/** Returns true when the modified-files card was created or its content changed. */
@RequiresEdt
fun setDiffs(diffs: List<DiffFileDto>): Boolean {
val existing = modified
val card = existing ?: if (diffs.isEmpty()) null else ModifiedFilesView(openFile, selection).also {
it.setDiffOpener(openDiff, sessionId, id)
it.resize = resize
it.hover = hover
it.applyStyle(style)
modified = it
add(it)
}
val created = existing == null && card != null
val changed = card?.setDiffs(diffs) ?: false
if (created || changed) revalidate()
return created || changed
}
@RequiresEdt
fun setQueued(active: Boolean, onDelete: (String) -> Unit) {
val anchor = messages.values.firstOrNull { it.role == SessionUiStyle.View.Message.USER_ROLE } ?: return
@@ -111,6 +145,7 @@ class TurnView(
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (view in messages.values) view.applyStyle(style)
modified?.applyStyle(style)
syncCopyToolbars()
revalidate()
repaint()
@@ -121,6 +156,11 @@ class TurnView(
remove(it)
Disposer.dispose(it)
}
modified?.let {
remove(it)
Disposer.dispose(it)
}
modified = null
messages.clear()
}
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.views.base.GenericView
import ai.kilocode.client.session.views.base.PartView
@@ -36,6 +37,12 @@ object ViewFactory {
openFile: SessionFileOpener,
): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null)
fun create(
content: Content,
openFile: SessionFileOpener,
openUrl: (String) -> Unit,
): PartView = create(content, openFile, openUrl = openUrl, selection = null, repo = null)
fun create(
content: Content,
openFile: SessionFileOpener,
@@ -43,6 +50,8 @@ object ViewFactory {
selection: SessionSelection? = null,
repo: String? = null,
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
openDiff: SessionDiffOpener = { _, _, _ -> },
sessionId: String? = null,
): PartView = when (content) {
is Text -> TextView(content, openFile = openFile, openUrl = openUrl, selection = selection)
is Reasoning -> ReasoningView(content, openFile = openFile, openUrl = openUrl, selection = selection)
@@ -55,7 +64,7 @@ object ViewFactory {
GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo)
SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo)
ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection)
EditToolView.canRender(content) -> EditToolView(content, openFile, selection = selection)
EditToolView.canRender(content) -> EditToolView(content, openFile, selection, openDiff, sessionId)
TaskToolView.canRender(content) -> TaskToolView(content, selection = selection)
else -> ToolView(content, selection = selection)
}
@@ -69,6 +78,12 @@ object ViewFactory {
openFile: SessionFileOpener,
): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null)
fun createUser(
content: Content,
openFile: SessionFileOpener,
openUrl: (String) -> Unit,
): PartView = createUser(content, openFile, openUrl = openUrl, selection = null, repo = null)
fun createUser(
content: Content,
openFile: SessionFileOpener,
@@ -77,9 +92,11 @@ object ViewFactory {
repo: String? = null,
mentions: List<PromptMention> = emptyList(),
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
openDiff: SessionDiffOpener = { _, _, _ -> },
sessionId: String? = null,
): PartView = when (content) {
is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions)
else -> create(content, openFile, openUrl, selection, repo, openAttachment)
else -> create(content, openFile, openUrl, selection, repo, openAttachment, openDiff, sessionId)
}
/**
@@ -3,7 +3,6 @@ package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
@@ -29,7 +28,7 @@ abstract class AbstractSessionPartView(
) : this(header, { body }, expanded, expandable)
protected val arrow = JBLabel()
protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0))
protected val row = JPanel(BorderLayout(SessionUiStyle.View.Header.gap(), 0))
private val bound = linkedSetOf<Component>()
private var body: JComponent? = null
@@ -124,6 +123,10 @@ abstract class AbstractSessionPartView(
items.forEach { bind(it) }
}
protected fun unbindHeader(vararg items: Component) {
items.forEach { unbind(it) }
}
protected fun refresh() {
revalidate()
repaint()
@@ -151,6 +154,13 @@ abstract class AbstractSessionPartView(
component.addMouseListener(mouse)
}
private fun unbind(component: Component) {
if (!bound.remove(component)) return
component.removeMouseListener(click)
component.removeMouseListener(mouse)
component.cursor = Cursor.getDefaultCursor()
}
private fun body(): JComponent {
val item = body
if (item != null) return item
@@ -0,0 +1,69 @@
package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionUiStyle.View.Header
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import java.awt.BorderLayout
import java.awt.Component
import javax.swing.JComponent
import javax.swing.JPanel
/**
* Shared session-card header. A [BorderLayout] row with a left group, an optional
* flexible middle that absorbs remaining width and clips (e.g. a file path), and a
* right group that hugs the trailing edge.
*
* The collapse/expand arrow is owned by [AbstractSessionPartView] and sits to the
* right of this header, so together they realise the west (left) / center (right
* group) / east (arrow) layout.
*
* All spacing comes from [Header]: [leading] applies the icon-to-title gap, while every
* other element is separated by the universal [Header.gap]. Text labels center vertically
* by default, so add them directly. Fixed-size controls (icons, badges, diff bars) must be
* added via [centered] so they keep their preferred size and stay centered.
*/
class PartHeader : JPanel(BorderLayout(Header.gap(), 0)) {
val left = Stack.horizontal(Header.gap())
val right = Stack.horizontal(Header.gap())
init {
isOpaque = false
add(left, BorderLayout.WEST)
add(right, BorderLayout.EAST)
}
/** Adds the leading glyph and reserves the tighter icon-to-title gap before the title. */
fun leading(icon: Component): PartHeader {
left.next(icon).gap(Header.icon())
return this
}
/** Reserves the larger title-to-elements gap before the next left element. */
fun titleGap(): PartHeader {
left.gap(Header.title())
return this
}
fun left(vararg items: Component): PartHeader {
items.forEach { left.next(it) }
return this
}
fun right(vararg items: Component): PartHeader {
items.forEach { right.next(it) }
return this
}
/** Flexible middle that absorbs remaining width and clips its content. */
fun fill(component: JComponent): PartHeader {
add(component, BorderLayout.CENTER)
return this
}
companion object {
/** Wraps a fixed-size control so it keeps its preferred size and stays centered. */
fun centered(component: Component): JComponent = component.align(HAlign.CENTER, VAlign.CENTER)
}
}
@@ -17,7 +17,9 @@ abstract class PrimarySessionPartView(
row.background = SessionUiStyle.View.Surface.headerBgColor()
row.border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
SessionUiStyle.View.Header.left(),
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
SessionUiStyle.View.Header.right(),
)
syncBorder()
}
@@ -22,7 +22,9 @@ abstract class SecondarySessionPartView(
row.background = SessionUiStyle.View.Surface.headerBgColor()
row.border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
SessionUiStyle.View.Header.left(),
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
SessionUiStyle.View.Header.right(),
)
syncBorder()
}
@@ -7,15 +7,14 @@ 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.SessionViewIcons
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.base.PrimarySessionPartView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Font
import javax.swing.Box
import javax.swing.JComponent
import javax.swing.JPanel
class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
PrimarySessionPartView(parts.header, parts.list, expanded = true) {
@@ -26,7 +25,7 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
private var style = SessionEditorStyle.current()
init {
bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls)
bindHeader(parts.glyph, parts.title, parts.sub, parts.left, parts.right)
parts.list.border = JBUI.Borders.compound(
JBUI.Borders.customLine(
SessionUiStyle.View.Outline.color(),
@@ -92,12 +91,12 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
}
class TodoParts(
val header: JPanel,
val header: PartHeader,
val glyph: JBLabel,
val title: JBLabel,
val sub: JBLabel,
val center: JPanel,
val controls: JComponent,
val left: Stack,
val right: Stack,
val list: TodoListPanel,
)
@@ -105,19 +104,13 @@ private fun todoParts(): TodoParts {
val glyph = JBLabel(SessionViewIcons.checklist)
val title = JBLabel(KiloBundle.message("session.part.todo.title"))
val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
isOpaque = false
add(title, BorderLayout.WEST)
add(sub, BorderLayout.CENTER)
val header = PartHeader().apply {
leading(glyph)
left(title)
titleGap()
left(sub)
}
val controls = Box.createHorizontalBox()
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
isOpaque = false
add(glyph, BorderLayout.WEST)
add(center, BorderLayout.CENTER)
add(controls, BorderLayout.EAST)
}
return TodoParts(header, glyph, title, sub, center, controls, TodoListPanel())
return TodoParts(header, glyph, title, sub, header.left, header.right, TodoListPanel())
}
private fun subtitle(tool: Tool): String {
@@ -34,7 +34,7 @@ abstract class BaseSearchToolView(
protected abstract fun viewName(): String
init {
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
parts.targets.forEach { bindHeader(it) }
applyStyle(style)
sync()
@@ -104,7 +104,7 @@ abstract class BaseSearchToolView(
@RequiresEdt
internal fun headerComponent() = parts.header
@RequiresEdt
internal fun centerComponent() = parts.center
internal fun centerComponent() = parts.fill
@RequiresEdt
internal fun targetComponents() = parts.targets
@@ -1,21 +1,30 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.diff.DiffLineNumbers
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.selection.hoverPlaceholder
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.ToolbarButtonAction
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdCodeBlockBorder
import ai.kilocode.client.ui.md.MdCodeBlockOptions
import ai.kilocode.client.ui.toolbarButton
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.util.Disposer
@@ -23,8 +32,8 @@ import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import javax.swing.JComponent
import javax.swing.ScrollPaneConstants
/**
@@ -38,34 +47,73 @@ class EditToolView(
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool, openFile),
private var body: EditBody = editBody(tool, selection, openFile),
) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider {
) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider, SessionCopyTarget {
override val contentId: String = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
private var multi = editFiles(tool).size > 1
private var opener: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
private var canDiff = false
private val badge = DiffStatBadge(0, 0)
private val diff = toolbarButton(
ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer),
)
private val diffAnchor = hoverPlaceholder(diff)
private val filesTag = JBLabel().apply {
foreground = UiStyle.Colors.weak()
font = JBFont.small()
border = JBUI.Borders.emptyRight(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)
isVisible = false
}
init {
body.parent = this
parts.controls.add(filesTag)
parts.controls.add(badge)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot, filesTag, badge)
// Left-aligned header: icon, title, file name (single) or file count (multi), change badge, open-in-diff.
parts.left.next(parts.link)
parts.left.next(filesTag)
parts.left.next(PartHeader.centered(badge))
parts.left.next(PartHeader.centered(diffAnchor))
// parts.link is intentionally omitted: FileLinkLabel installs its own click handler that opens
// the file, and binding it here would also toggle the card on the same click (see ReadToolView,
// which likewise omits it). Header toggling still works via parts.left/row.
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot, filesTag, badge, diffAnchor)
applyStyle(style)
sync()
}
override val copyEligible: Boolean get() = canDiff
override val copyAnchor: JComponent get() = diffAnchor
override val copyToolbar: JComponent get() = diff
constructor(
tool: Tool,
openFile: SessionFileOpener,
selection: SessionSelection?,
openDiff: SessionDiffOpener,
sessionId: String?,
) : this(tool, openFile, selection) {
opener = openDiff
this.sessionId = sessionId
}
/**
* Late-bind the diff opener. The transcript builds this view before the session-level opener is
* known, so [ai.kilocode.client.session.views.MessageView] rebinds it once the opener is wired.
*/
@RequiresEdt
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
opener = openDiff
this.sessionId = sessionId
}
override fun uiDataSnapshot(sink: DataSink) {
selection?.provideCopy(sink) { body.markdown() ?: diffMarkdown(item) }
}
override fun copyText(): String? = null
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
@@ -184,11 +232,27 @@ class EditToolView(
changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
syncDiffAction(count)
changed = syncFilesTag(count) || changed
changed = syncBadge() || changed
return changed
}
private fun syncDiffAction(count: Int) {
// Mirrors toDiffFiles(item).isNotEmpty() without re-parsing the metadata JSON or allocating a
// DiffFileDto per file on every streaming delta: files present, else a single-file patch.
val show = count > 0 || editDiff(item).isNotBlank()
if (canDiff == show && diff.isEnabled == show) return
canDiff = show
diff.isEnabled = show
}
private fun openDiffViewer() {
val files = toDiffFiles(item)
if (files.isEmpty()) return
opener(files, diffTitle(item), "tool:${sessionId ?: "pending"}:${item.id}")
}
private fun syncFilesTag(count: Int): Boolean {
val show = count > 1
var changed = setVisible(filesTag, show)
@@ -224,6 +288,20 @@ class EditToolView(
}
}
private fun toDiffFiles(tool: Tool): List<DiffFileDto> {
val files = editFiles(tool).map { DiffFileDto(it.path, it.additions, it.deletions, it.patch, it.type.ifBlank { null }) }
if (files.isNotEmpty()) return files
val patch = editDiff(tool)
if (patch.isBlank()) return emptyList()
val stat = diffStat(tool)
return listOf(DiffFileDto(editPath(tool), stat.first, stat.second, patch))
}
private fun diffTitle(tool: Tool): String =
// Keep the file name for a single-file edit so each per-tool diff tab is identifiable
// (SessionUi decorates it into "<name> (branch)"); reserve the generic label for multi-file patches.
if (editFiles(tool).size > 1) KiloBundle.message("session.part.tool.patch") else tail(editPath(tool))
/** Picks the multi-file patch body for apply_patch spanning several files, else the single diff. */
private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody =
if (editFiles(tool).size > 1) PatchBody(selection, openFile) else diffBody(selection)
@@ -240,15 +318,17 @@ private fun diffBody(selection: SessionSelection?) = ToolMarkdownBody(
),
selection,
render = ::diffMarkdown,
gutter = { editDiff(it).takeIf { patch -> patch.isNotBlank() }?.let(DiffLineNumbers::rows) },
)
private fun popupDiffBody(selection: SessionSelection?) = ToolMarkdownBody(
POPUP_OPTS,
selection,
render = ::diffMarkdown,
gutter = { editDiff(it).takeIf { patch -> patch.isNotBlank() }?.let(DiffLineNumbers::rows) },
)
private val POPUP_OPTS = MdCodeBlockOptions(
internal val POPUP_OPTS = MdCodeBlockOptions(
border = MdCodeBlockBorder.None,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
editorOnly = true,
@@ -1,5 +1,7 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.diff.DiffLineNumbers
import ai.kilocode.client.diff.installDiffGutter
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.ui.selection.SessionSelection
@@ -17,6 +19,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.NamedColorUtil
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Component
@@ -62,13 +65,17 @@ class PatchBody(
private val links = mutableListOf<FileLinkLabel>()
private var style = SessionEditorStyle.current()
private var signature = ""
private val rows = mutableListOf<List<DiffLineNumbers.Row>>()
@RequiresEdt
override fun mount(tool: Tool): JComponent {
override fun mount(tool: Tool): JComponent = mountFiles(editFiles(tool))
@RequiresEdt
internal fun mountFiles(files: List<EditFileChange>): JComponent {
root?.let { return it }
val panel = Stack.vertical()
root = panel
rebuild(tool)
rebuild(files)
return panel
}
@@ -83,9 +90,14 @@ class PatchBody(
@RequiresEdt
override fun update(tool: Tool): Boolean {
return updateFiles(editFiles(tool))
}
@RequiresEdt
internal fun updateFiles(files: List<EditFileChange>): Boolean {
if (root == null) return false
if (signatureOf(tool) == signature) return false
rebuild(tool)
if (signatureOf(files) == signature) return false
rebuild(files)
return true
}
@@ -119,19 +131,20 @@ class PatchBody(
owner = null
views.clear()
links.clear()
rows.clear()
panel?.removeAll()
signature = ""
}
@RequiresEdt
private fun rebuild(tool: Tool) {
private fun rebuild(files: List<EditFileChange>) {
val panel = root ?: return
val parent = parent ?: error("Patch body has no parent")
disposeBody()
val disposable = Disposer.newDisposable("Patch body")
Disposer.register(parent, disposable)
owner = disposable
editFiles(tool).filter { it.patch.isNotBlank() }.forEachIndexed { index, file ->
files.filter { it.patch.isNotBlank() }.forEachIndexed { index, file ->
if (index > 0) panel.gap(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))
panel.next(header(file))
panel.gap(UiStyle.Gap.sm())
@@ -139,15 +152,18 @@ class PatchBody(
Disposer.register(disposable, md)
applyMd(md)
md.set(patchMarkdown(file.patch))
val nums = DiffLineNumbers.rows(file.patch)
rows.add(nums)
installGutter(md, nums)
views.add(md)
panel.next(md.component)
}
signature = signatureOf(tool)
signature = signatureOf(files)
panel.revalidate()
panel.repaint()
}
private fun signatureOf(tool: Tool): String = editFiles(tool)
private fun signatureOf(files: List<EditFileChange>): String = files
.joinToString("\u0000") { "${it.path}\u0001${it.additions}\u0001${it.deletions}\u0001${it.patch}" }
@RequiresEdt
@@ -164,7 +180,10 @@ class PatchBody(
.next(DiffStatBadge(file.additions, file.deletions))
return JBUI.Panels.simplePanel(row).apply {
isOpaque = false
border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)
border = JBUI.Borders.compound(
JBUI.Borders.customLineBottom(NamedColorUtil.getBoundsColor()),
JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING),
)
}
}
@@ -177,9 +196,18 @@ class PatchBody(
md.preBg = style.editorBackground
md.codeFont = style.editorFamily
md.component.border = JBUI.Borders.empty()
rows.getOrNull(views.indexOf(md))?.let { installGutter(md, it) }
return before != md.font
}
@RequiresEdt
private fun installGutter(md: MdView, rows: List<DiffLineNumbers.Row>) {
((md.component as? JPanel)?.components
?.filterIsInstance<JBScrollPane>()
?.mapNotNull { it.viewport.view as? EditorTextField }
?: emptyList()).forEach { installDiffGutter(it, rows) }
}
private companion object {
val DIFF_OPTS = MdCodeBlockOptions(
border = MdCodeBlockBorder.Bottom,
@@ -34,7 +34,7 @@ class ReadToolView(
init {
parts.text?.let { selection?.register(it, this) }
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
parts.text?.text = preview(item)
applyStyle(style)
sync()
@@ -43,7 +43,7 @@ class ShellToolView(
init {
body.parent = this
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
applyStyle(style)
sync()
}
@@ -42,7 +42,7 @@ class TaskToolView(
private var collapsed = false
init {
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
applyStyle(style)
sync()
if (item.childTools.isNotEmpty()) expand()
@@ -1,5 +1,7 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.diff.DiffLineNumbers
import ai.kilocode.client.diff.installDiffGutter
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -31,22 +33,26 @@ class ToolMarkdownBody(
private val opts: MdCodeBlockOptions,
private val selection: SessionSelection?,
private val render: (Tool) -> String,
private val gutter: ((Tool) -> List<DiffLineNumbers.Row>?)? = null,
private val font: (SessionEditorStyle) -> Font = SessionEditorStyle::editorFont,
private val chrome: (MdView) -> Unit = {},
) : EditBody {
override var parent: Disposable? = null
private var view: MdView? = null
private var item: Tool? = null
/** Builds the body on first call, wiring it into [parent]'s disposable tree, then returns it. */
@RequiresEdt
override fun mount(tool: Tool): JComponent {
view?.let { return it.component }
item = tool
val owner = parent ?: error("Tool markdown body has no parent")
val md = MdViewFactory.create(SessionEditorStyle.current(), selection, MdCodeBlockFactory.default(opts))
Disposer.register(owner, md)
view = md
applyStyle(SessionEditorStyle.current())
update(tool)
syncGutter(tool)
return md.component
}
@@ -61,11 +67,13 @@ class ToolMarkdownBody(
@RequiresEdt
override fun update(tool: Tool): Boolean {
item = tool
val md = view ?: return false
val value = render(tool)
if (md.markdown() == value) return false
md.set(value)
chrome(md)
syncGutter(tool)
return true
}
@@ -81,9 +89,19 @@ class ToolMarkdownBody(
md.codeFont = style.editorFamily
md.component.border = JBUI.Borders.empty()
chrome(md)
// EditorTextField drops its editor in removeNotify, so collapse/re-expand yields a fresh
// editor with no annotation provider. Re-install the gutter here (as PatchBody.applyMd does)
// so the old/new line-number gutter survives a re-expansion, not just the first mount.
item?.let(::syncGutter)
return before != md.font
}
@RequiresEdt
private fun syncGutter(tool: Tool) {
val rows = gutter?.invoke(tool) ?: return
codeEditors().forEach { installDiffGutter(it, rows) }
}
@RequiresEdt
override fun markdown(): String? = view?.markdown()
@@ -12,12 +12,10 @@ import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.editor.BashCommandHighlighter
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.cli.KiloCliParser
import ai.kilocode.log.KiloLog
import com.intellij.openapi.actionSystem.DataSink
@@ -44,7 +42,6 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
@@ -62,15 +59,16 @@ private val LOG = KiloLog.create(ToolParts::class.java)
enum class ToolBodyMode { EDITOR, TEXT }
class ToolParts(
val header: JPanel,
val header: PartHeader,
val glyph: JBLabel,
val title: JBLabel,
val sub: JBLabel,
val link: FileLinkLabel,
val slot: JPanel,
val state: JBLabel,
val center: JPanel,
val controls: JComponent,
val left: Stack,
val right: Stack,
val fill: JComponent,
val extra: JBLabel? = null,
val targets: List<JBLabel> = emptyList(),
private val mode: ToolBodyMode = ToolBodyMode.EDITOR,
@@ -406,29 +404,20 @@ internal fun toolParts(
val title = clip(JBLabel())
val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
val link = clip(FileLinkLabel(openFile))
val slot = Stack.fitHorizontal().apply {
val slot = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply {
minimumSize = Dimension(0, minimumSize.height)
next(sub)
next(link)
}
val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
isOpaque = false
minimumSize = Dimension(0, minimumSize.height)
}
val controls = Stack.horizontal()
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
isOpaque = false
center.add(title, BorderLayout.WEST)
center.add(slot, BorderLayout.CENTER)
add(glyph, BorderLayout.WEST)
add(center, BorderLayout.CENTER)
add(controls, BorderLayout.EAST)
}
val parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, mode = mode)
return parts.also {
controls.add(it.state)
val header = PartHeader().apply {
leading(glyph)
left(title)
titleGap()
fill(slot)
right(state)
}
return ToolParts(header, glyph, title, sub, link, slot, state, header.left, header.right, fill = slot, mode = mode)
}
@RequiresEdt
@@ -442,30 +431,24 @@ internal fun searchParts(count: Int): ToolParts {
}
}
val link = clip(FileLinkLabel())
val slot = Stack.fitHorizontal().apply {
val slot = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply {
minimumSize = Dimension(0, minimumSize.height)
next(sub)
next(link)
}
val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } }
val target = stack.align(HAlign.TRACK, VAlign.CENTER)
val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
isOpaque = false
val target = Stack.fitHorizontal(SessionUiStyle.View.Header.gap()).apply {
minimumSize = Dimension(0, minimumSize.height)
add(title, BorderLayout.WEST)
add(target, BorderLayout.CENTER)
targets.forEach { next(it) }
}
val controls = Stack.horizontal()
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
isOpaque = false
add(glyph, BorderLayout.WEST)
add(center, BorderLayout.CENTER)
add(controls, BorderLayout.EAST)
}
return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets, mode = ToolBodyMode.EDITOR).also {
controls.add(it.state)
val header = PartHeader().apply {
leading(glyph)
left(title)
titleGap()
fill(target)
right(state)
}
return ToolParts(header, glyph, title, sub, link, slot, state, header.left, header.right, fill = target, targets = targets, mode = ToolBodyMode.EDITOR)
}
internal fun icon(tool: Tool) = when (tool.name) {
@@ -476,7 +459,7 @@ internal fun icon(tool: Tool) = when (tool.name) {
"codesearch" -> SessionViewIcons.code
"task" -> SessionViewIcons.task
"bash" -> SessionViewIcons.console
"edit", "write", "apply_patch" -> SessionViewIcons.codeLines
"edit", "write", "apply_patch" -> SessionViewIcons.edit
"todowrite", "todoread" -> SessionViewIcons.checklist
"question" -> SessionViewIcons.bubble
"skill" -> SessionViewIcons.brain
@@ -855,13 +838,27 @@ internal fun diffStat(tool: Tool): Pair<Int, Int> {
return added to removed
}
/** Display-only diff body without VCS/file metadata headers (Index, diff --git, ---, +++, etc.). */
internal fun pureDiff(diff: String): String = diff.lineSequence()
.filterNot(::diffMeta)
.joinToString("\n")
.trim('\n')
/**
* Display-only diff body. Strips the pre-hunk file/VCS headers (Index, diff --git, ---, +++, etc.)
* and the `@@` hunk markers, but keeps every in-hunk line verbatim a deleted `-- ` comment that
* renders as `--- ...` is diff content, not a header, so it must survive here and in
* [ai.kilocode.client.diff.DiffLineNumbers.rows] for the gutter line numbers to stay aligned.
*/
internal fun pureDiff(diff: String): String {
val out = StringBuilder()
var hunk = false
diff.lineSequence().forEach { line ->
if (line.startsWith("@@")) {
hunk = true
return@forEach
}
if (!hunk && diffMeta(line)) return@forEach
out.append(line).append('\n')
}
return out.toString().trim('\n')
}
private fun diffMeta(line: String): Boolean = line.startsWith("Index:") ||
internal fun diffMeta(line: String): Boolean = line.startsWith("Index:") ||
line.startsWith("====") ||
line.startsWith("diff --git ") ||
line.startsWith("@@") ||
@@ -31,7 +31,7 @@ class ToolView(
private var disposed = false
init {
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.left, parts.right, parts.slot)
applyStyle(style)
sync()
}
@@ -0,0 +1,77 @@
package ai.kilocode.client.ui
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import javax.swing.JPanel
internal class DiffBars(
additions: Int,
deletions: Int,
) : JPanel() {
private var additions = additions
private var deletions = deletions
init {
isOpaque = false
}
fun update(additions: Int, deletions: Int) {
if (this.additions == additions && this.deletions == deletions) return
this.additions = additions
this.deletions = deletions
repaint()
}
override fun getPreferredSize(): Dimension = JBUI.size(WIDTH, HEIGHT)
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = preferredSize
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val barHeight = JBUI.scale(HEIGHT)
val y = maxOf(0, (height - barHeight) / 2)
blocks().forEachIndexed { index, color ->
g2.color = color
g2.fillRoundRect(
JBUI.scale(index * STEP),
y,
JBUI.scale(BAR_WIDTH),
barHeight,
JBUI.scale(ARC),
JBUI.scale(ARC),
)
}
} finally {
g2.dispose()
}
super.paintComponent(g)
}
private fun blocks(): List<Color> {
val total = additions + deletions
if (total <= 0) return List(COUNT) { UiStyle.Colors.weak() }
val added = ((additions.toDouble() / total) * COUNT).toInt().coerceIn(0, COUNT)
val removed = ((deletions.toDouble() / total) * COUNT).toInt().coerceIn(0, COUNT - added)
val neutral = COUNT - added - removed
return List(added) { UiStyle.Colors.addedForeground() } +
List(removed) { UiStyle.Colors.removedForeground() } +
List(neutral) { UiStyle.Colors.weak() }
}
private companion object {
const val COUNT = 5
const val BAR_WIDTH = 2
const val STEP = 4
const val HEIGHT = 14
const val WIDTH = 18
const val ARC = 2
}
}
@@ -6,6 +6,7 @@ import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.GridBagLayout
@@ -15,7 +16,31 @@ import javax.swing.JPanel
internal class DiffStatBadge(
additions: Int,
deletions: Int,
private val variant: Variant = Variant.REGULAR,
private val inset: Int = 0,
) : JPanel(GridBagLayout()) {
constructor(additions: Int, deletions: Int) : this(additions, deletions, Variant.REGULAR, 0)
internal enum class Variant {
REGULAR,
COMPACT;
fun height() = when (this) {
REGULAR -> JBUI.scale(16)
COMPACT -> JBUI.scale(14)
}
fun gap() = when (this) {
REGULAR -> UiStyle.Gap.sm()
COMPACT -> UiStyle.Gap.xs()
}
fun pad() = when (this) {
REGULAR -> UiStyle.Gap.sm()
COMPACT -> UiStyle.Gap.sm()
}
}
private val removed = JBLabel().apply {
foreground = UiStyle.Colors.removedForeground()
font = JBFont.small()
@@ -27,26 +52,36 @@ internal class DiffStatBadge(
init {
isOpaque = false
border = JBUI.Borders.empty(0, UiStyle.Gap.sm(), 0, UiStyle.Gap.sm())
border = JBUI.Borders.empty(0, variant.pad(), 0, variant.pad() + inset)
add(
Stack.horizontal(UiStyle.Gap.sm())
Stack.horizontal(variant.gap())
.next(removed)
.next(added),
)
update(additions, deletions)
}
override fun getPreferredSize(): Dimension {
val dim = super.getPreferredSize()
return Dimension(dim.width, variant.height())
}
fun update(additions: Int, deletions: Int) {
removed.text = "-$deletions"
added.text = "+$additions"
removed.isVisible = deletions > 0
added.isVisible = additions > 0
if (removed.isVisible) removed.text = "-$deletions"
if (added.isVisible) added.text = "+$additions"
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
val w = maxOf(0, width - inset)
val h = minOf(height, variant.height())
val y = (height - h) / 2
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = backgroundColor()
g2.fillRoundRect(0, 0, width, height, height, height)
g2.fillRoundRect(0, y, w, h, h, h)
} finally {
g2.dispose()
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.vfs
import ai.kilocode.client.diff.ensureDiffEditorKind
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditor
@@ -13,6 +14,7 @@ import com.intellij.openapi.vfs.VirtualFile
class KiloFileEditorProvider : FileEditorProvider, DumbAware {
override fun accept(project: Project, file: VirtualFile): Boolean {
ensureAttachmentEditorKind()
ensureDiffEditorKind()
val path = path(file) ?: return false
return service<KiloEditorKindRegistry>().get(path.kind) != null
}
@@ -21,6 +23,7 @@ class KiloFileEditorProvider : FileEditorProvider, DumbAware {
override fun createEditor(project: Project, file: VirtualFile): FileEditor {
ensureAttachmentEditorKind()
ensureDiffEditorKind()
val path = path(file) ?: error("Invalid Kilo virtual file: ${file.path}")
val kilo = file as? KiloVirtualFile ?: KiloVirtualFile(path)
val kind = service<KiloEditorKindRegistry>().get(kilo.path.kind) ?: error("Unknown Kilo editor kind: ${kilo.path.kind}")
@@ -0,0 +1,6 @@
<!-- Copyright 2026 Kilo Code contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 4.5H3.5C2.94772 4.5 2.5 4.94772 2.5 5.5V12.5C2.5 13.0523 2.94772 13.5 3.5 13.5H10.5C11.0523 13.5 11.5 13.0523 11.5 12.5V9.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 2.5H13.5V6.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 7.5L13.5 2.5" stroke="#6C707E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 589 B

@@ -0,0 +1,6 @@
<!-- Copyright 2026 Kilo Code contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 4.5H3.5C2.94772 4.5 2.5 4.94772 2.5 5.5V12.5C2.5 13.0523 2.94772 13.5 3.5 13.5H10.5C11.0523 13.5 11.5 13.0523 11.5 12.5V9.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 2.5H13.5V6.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 7.5L13.5 2.5" stroke="#CED0D6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 589 B

@@ -124,7 +124,30 @@ session.status.offline=Connection offline
session.part.reasoning=Reasoning
session.part.compaction=context compacted
session.changes.modified=Modified
session.changes.count.one={0} file
session.changes.count.other={0} files
diff.editor.session.title=Session Changes
diff.editor.inline.title.named={0} ({1})
diff.editor.changedFiles.title=Changed files
diff.editor.branch.title=Changes vs base branch
diff.editor.branch.title.named=Changes vs base branch ({0})
diff.editor.file.title={0} ({1})
diff.editor.side.base=Base
diff.editor.side.current=Current
diff.editor.side.original=Original
diff.editor.side.modified=Modified
diff.editor.branch.tooltip=Compare with base branch
diff.editor.session.tooltip=Open changes in editor
diff.editor.empty=No changes
diff.editor.patch.unavailable=Diff unavailable
diff.editor.outdated=Changes on disk are not shown
diff.editor.openFile=Open File
diff.editor.refresh=Refresh
diff.editor.tree.expandAll=Expand All
diff.editor.tree.collapseAll=Collapse All
session.part.tool.copy=Copy
session.part.tool.openDiff=Open in Diff Viewer
session.part.tool.error=Error
session.part.tool.agent={0} Agent
session.part.tool.pending=Pending
@@ -0,0 +1,127 @@
package ai.kilocode.client.diff
import ai.kilocode.client.session.views.tool.pureDiff
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class DiffLineNumbersTest : BasePlatformTestCase() {
fun `test rows align with pure diff display lines`() {
fixtures().forEach { patch ->
assertEquals(pureDiff(patch).trim('\n').lines().size, DiffLineNumbers.rows(patch).size)
}
}
fun `test modified hunk emits old and new counters`() {
val patch = """
@@ -1,3 +1,3 @@
keep
-old
+new
done
""".trimIndent()
assertEquals(
listOf(
DiffLineNumbers.Row(1, 1),
DiffLineNumbers.Row(2, null),
DiffLineNumbers.Row(null, 2),
DiffLineNumbers.Row(3, 3),
),
DiffLineNumbers.rows(patch),
)
}
fun `test multi hunk resets counters`() {
val patch = """
@@ -1,1 +1,1 @@
-old
+new
@@ -10,1 +20,1 @@
keep
""".trimIndent()
assertEquals(
listOf(
DiffLineNumbers.Row(1, null),
DiffLineNumbers.Row(null, 1),
DiffLineNumbers.Row(10, 20),
),
DiffLineNumbers.rows(patch),
)
}
fun `test in-hunk header-shaped lines stay content`() {
// A deleted "-- foo" comment renders as "--- foo" and an added "++ bar" as "+++ bar";
// both are hunk content, so they keep incrementing the counters instead of being dropped.
val patch = """
--- a/q.sql
+++ b/q.sql
@@ -1,2 +1,2 @@
--- old comment
+++ new comment
keep
""".trimIndent()
assertEquals(
listOf(
DiffLineNumbers.Row(1, null),
DiffLineNumbers.Row(null, 1),
DiffLineNumbers.Row(2, 2),
),
DiffLineNumbers.rows(patch),
)
}
fun `test no newline marker emits empty row`() {
val patch = """
@@ -1 +1 @@
-old
\ No newline at end of file
+new
""".trimIndent()
assertEquals(
listOf(
DiffLineNumbers.Row(1, null),
DiffLineNumbers.Row(null, null),
DiffLineNumbers.Row(null, 1),
),
DiffLineNumbers.rows(patch),
)
}
private fun fixtures() = listOf(
"""
diff --git a/src/App.kt b/src/App.kt
index 111..222 100644
--- a/src/App.kt
+++ b/src/App.kt
@@ -1,2 +1,2 @@
keep
-old
+new
""".trimIndent(),
"""
--- /dev/null
+++ b/src/New.kt
@@ -0,0 +1,2 @@
+one
+two
""".trimIndent(),
"""
--- a/src/Old.kt
+++ /dev/null
@@ -1,2 +0,0 @@
-one
-two
""".trimIndent(),
"@@ -1 +1 @@\r\n-old\r\n+new\r\n",
"""
--- a/q.sql
+++ b/q.sql
@@ -1,2 +1,2 @@
--- old comment
+++ new comment
keep
""".trimIndent(),
)
}
@@ -0,0 +1,187 @@
package ai.kilocode.client.diff
import ai.kilocode.rpc.dto.DiffFileDto
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DiffPatchReconstructTest {
@Test
fun `reconstructs modified full context patch`() {
val dto = DiffFileDto(
file = "src/A.kt",
additions = 1,
deletions = 1,
patch = """
diff --git a/src/A.kt b/src/A.kt
index 111..222 100644
--- a/src/A.kt
+++ b/src/A.kt
@@ -1,3 +1,3 @@
one
-two
+TWO
three
""".trimIndent(),
)
val sides = DiffPatchReconstruct.sides(dto)
assertTrue(sides.renderable)
assertEquals("one\ntwo\nthree", sides.before)
assertEquals("one\nTWO\nthree", sides.after)
}
@Test
fun `newline terminated full context patch stays renderable`() {
// Real git patches end with a newline, so split('\n') yields a trailing "" that must not be
// counted as a hunk body line. Without the edge trim this reconstructs as non-renderable and
// falls back to the all-green raw-patch view.
val dto = DiffFileDto(
file = "src/A.kt",
additions = 1,
deletions = 1,
patch = """
diff --git a/src/A.kt b/src/A.kt
index 111..222 100644
--- a/src/A.kt
+++ b/src/A.kt
@@ -1,3 +1,3 @@
one
-two
+TWO
three
""".trimIndent() + "\n",
)
val sides = DiffPatchReconstruct.sides(dto)
assertTrue(sides.renderable)
assertEquals("one\ntwo\nthree", sides.before)
assertEquals("one\nTWO\nthree", sides.after)
}
@Test
fun `added file has empty before side`() {
val dto = DiffFileDto(
file = "src/A.kt",
additions = 2,
deletions = 0,
patch = """
diff --git a/src/A.kt b/src/A.kt
--- /dev/null
+++ b/src/A.kt
@@ -0,0 +1,2 @@
+one
+two
""".trimIndent(),
)
val sides = DiffPatchReconstruct.sides(dto)
assertEquals("", sides.before)
assertEquals("one\ntwo", sides.after)
}
@Test
fun `synthesized untracked patch renders as added file`() {
val dto = DiffFileDto(
file = "src/New.kt",
additions = 2,
deletions = 0,
patch = """
diff --git a/src/New.kt b/src/New.kt
new file mode 100644
--- /dev/null
+++ b/src/New.kt
@@ -0,0 +1,2 @@
+one
+two
\ No newline at end of file
""".trimIndent(),
)
val sides = DiffPatchReconstruct.sides(dto)
assertTrue(DiffPatchReconstruct.added(dto.patch))
assertEquals("", sides.before)
assertEquals("one\ntwo", sides.after)
assertTrue(sides.renderable)
}
@Test
fun `deleted file has empty after side`() {
val dto = DiffFileDto(
file = "src/A.kt",
additions = 0,
deletions = 2,
patch = """
diff --git a/src/A.kt b/src/A.kt
--- a/src/A.kt
+++ /dev/null
@@ -1,2 +0,0 @@
-one
-two
""".trimIndent(),
)
val sides = DiffPatchReconstruct.sides(dto)
assertEquals("one\ntwo", sides.before)
assertEquals("", sides.after)
}
@Test
fun `multi hunk partial context patch is not renderable`() {
val dto = DiffFileDto(
file = "src/A.kt",
additions = 2,
deletions = 2,
patch = """
diff --git a/src/A.kt b/src/A.kt
--- a/src/A.kt
+++ b/src/A.kt
@@ -1,3 +1,3 @@
one
-two
+TWO
@@ -20,3 +20,3 @@
twenty
-x
+X
""".trimIndent(),
)
val sides = DiffPatchReconstruct.sides(dto)
assertFalse(sides.renderable)
assertEquals("", sides.before)
assertEquals("", sides.after)
}
@Test
fun `single hunk with mismatched header length is not renderable`() {
// header claims 3 old / 3 new lines but the body only carries 2 of each (context elided).
val dto = DiffFileDto(
file = "src/A.kt",
additions = 1,
deletions = 1,
patch = """
--- a/src/A.kt
+++ b/src/A.kt
@@ -1,3 +1,3 @@
-two
+TWO
""".trimIndent(),
)
assertFalse(DiffPatchReconstruct.sides(dto).renderable)
}
@Test
fun `binary and blank patches are not renderable`() {
assertFalse(DiffPatchReconstruct.sides(DiffFileDto("a.bin", 0, 0, "Binary files a/a.bin and b/a.bin differ")).renderable)
assertFalse(DiffPatchReconstruct.sides(DiffFileDto("a.kt", 0, 0, "")).renderable)
}
}
@@ -0,0 +1,435 @@
package ai.kilocode.client.diff
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.actionSystem.ActionToolbar
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vcs.FileStatus
import com.intellij.ui.SimpleColoredComponent
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.EditorNotificationPanel
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.treeStructure.Tree
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Container
import javax.swing.SwingUtilities
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.TreePath
class KiloDiffEditorContentTest : BasePlatformTestCase() {
fun `test tree toolbar shows aggregate badge`() {
val parent = Disposer.newDisposable()
try {
val view = view(files(), parent)
val badges = components(view).filterIsInstance<DiffStatBadge>()
assertTrue(badges.any { it.addedLabelForTest().text == "+5" && it.removedLabelForTest().text == "-4" })
} finally {
Disposer.dispose(parent)
}
}
fun `test tree toolbar shows changed file count`() {
val parent = Disposer.newDisposable()
try {
val view = view(files(), parent)
assertTrue(components(view).filterIsInstance<JBLabel>().any { it.text == "2 files" })
} finally {
Disposer.dispose(parent)
}
}
fun `test tree toolbar shows singular changed file count`() {
val parent = Disposer.newDisposable()
try {
val view = view(listOf(file("src/App.kt", 2, 1)), parent)
assertTrue(components(view).filterIsInstance<JBLabel>().any { it.text == "1 file" })
} finally {
Disposer.dispose(parent)
}
}
fun `test tree renderer shows compact row change badge`() {
val parent = Disposer.newDisposable()
try {
val view = view(files(), parent)
val tree = components(view).filterIsInstance<Tree>().single()
val badge = rowBadge(renderer(tree, leaf(tree)))
assertTrue(badge.isVisible)
assertTrue(badge.preferredSize.height < DiffStatBadge(1, 1).preferredSize.height)
} finally {
Disposer.dispose(parent)
}
}
fun `test tree renderer uses file status color`() {
val parent = Disposer.newDisposable()
try {
val color = FileStatus.ADDED.color ?: return
val view = view(listOf(file("src/App.kt", 2, 0, status = "added")), parent)
val tree = components(view).filterIsInstance<Tree>().single()
val row = renderer(tree, leaf(tree))
val text = components(row).filterIsInstance<SimpleColoredComponent>().single()
val iter = text.iterator()
assertTrue(iter.hasNext())
iter.next()
assertEquals(color, iter.textAttributes.fgColor)
} finally {
Disposer.dispose(parent)
}
}
fun `test explicit and patch-derived file statuses`() {
assertEquals(FileStatus.ADDED, fileStatus(file("src/New.kt", 1, 0, status = "added")))
assertEquals(FileStatus.MODIFIED, fileStatus(file("src/App.kt", 1, 1, status = "modified")))
assertEquals(FileStatus.DELETED, fileStatus(file("src/Old.kt", 0, 1, status = "deleted")))
assertEquals(FileStatus.UNKNOWN, fileStatus(file("src/Unknown.kt", 1, 0, status = "untracked")))
assertEquals(FileStatus.ADDED, fileStatus(file("src/New.kt", 1, 0, patch = "--- /dev/null\n+++ b/src/New.kt")))
assertEquals(FileStatus.DELETED, fileStatus(file("src/Old.kt", 0, 1, patch = "--- a/src/Old.kt\n+++ /dev/null")))
assertEquals(FileStatus.MODIFIED, fileStatus(file("src/App.kt", 1, 1, patch = "@@ -1 +1 @@\n-old\n+new")))
}
fun `test row renderer places badge east of filename`() {
val parent = Disposer.newDisposable()
try {
val view = view(files(), parent)
val tree = components(view).filterIsInstance<Tree>().single()
val row = renderer(tree, leaf(tree)) as Container
val layout = row.layout as BorderLayout
val east = layout.getLayoutComponent(BorderLayout.EAST)
val center = layout.getLayoutComponent(BorderLayout.CENTER)
assertTrue(east is DiffStatBadge)
assertNotNull(center)
} finally {
Disposer.dispose(parent)
}
}
fun `test row badge hidden when node has no changes`() {
val parent = Disposer.newDisposable()
try {
val view = view(listOf(file("src/Empty.kt", 0, 0)), parent)
val tree = components(view).filterIsInstance<Tree>().single()
val badge = rowBadge(renderer(tree, leaf(tree)))
assertFalse(badge.isVisible)
} finally {
Disposer.dispose(parent)
}
}
fun `test tree expands all rows on show`() {
val parent = Disposer.newDisposable()
try {
val view = view(files(), parent)
val tree = components(view).filterIsInstance<Tree>().single()
assertEquals(4, tree.rowCount)
} finally {
Disposer.dispose(parent)
}
}
fun `test tree paints tool window background`() {
val parent = Disposer.newDisposable()
try {
val view = view(files(), parent)
val tree = components(view).filterIsInstance<Tree>().single()
val scroll = SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, tree) as JBScrollPane
val row = (scroll.parent.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container
val toolbar = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST)
assertTrue(tree.isOpaque)
assertEquals(JBUI.CurrentTheme.ToolWindow.background(), tree.background)
assertEquals(JBUI.CurrentTheme.ToolWindow.background(), row.background)
assertEquals(JBUI.CurrentTheme.ToolWindow.background(), toolbar.background)
assertEquals(0, scroll.border.getBorderInsets(scroll).top)
assertEquals(0, scroll.border.getBorderInsets(scroll).left)
assertEquals(0, scroll.border.getBorderInsets(scroll).bottom)
assertEquals(0, scroll.border.getBorderInsets(scroll).right)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).top)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).left)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).bottom)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).right)
} finally {
Disposer.dispose(parent)
}
}
fun `test tree toolbar installs actions in order`() {
val parent = Disposer.newDisposable()
try {
// Assert against the toolbar the view actually installs (not a freshly built group), so
// this guards the real regression: the tree toolbar losing or rewiring its actions.
val view = view(files(), parent)
val tree = components(view).filterIsInstance<Tree>().single()
val scroll = SwingUtilities.getAncestorOfClass(JBScrollPane::class.java, tree) as JBScrollPane
val row = (scroll.parent.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container
val toolbar = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) as ActionToolbar
val actions = toolbar.actionGroup.getChildren(null).toList()
assertEquals(KiloBundle.message("diff.editor.refresh"), actions[0].templatePresentation.text)
assertTrue(actions[1] is Separator)
assertEquals(KiloBundle.message("diff.editor.tree.expandAll"), actions[2].templatePresentation.text)
assertEquals(KiloBundle.message("diff.editor.tree.collapseAll"), actions[3].templatePresentation.text)
assertEquals(4, actions.size)
} finally {
Disposer.dispose(parent)
}
}
fun `test row renderer reuses badge instance`() {
val parent = Disposer.newDisposable()
try {
val view = view(files(), parent)
val tree = components(view).filterIsInstance<Tree>().single()
val leaf = leaf(tree)
val first = renderer(tree, leaf)
val second = renderer(tree, leaf)
assertSame(first, second)
assertSame(rowBadge(first), rowBadge(second))
} finally {
Disposer.dispose(parent)
}
}
fun `test branch is included in diff title`() {
val request = diffRequest(project, file("src/App.kt", 1, 1), "feature/test")
assertEquals("src/App.kt (feature/test)", request.title)
}
fun `test diff params includes inline token`() {
val params = diffParams("inline", "/repo", "ses_1", "Session Changes", token = "tool:ses_1:p1")
assertEquals("inline", params["source"])
assertEquals("/repo", params["directory"])
assertEquals("ses_1", params["sessionId"])
assertEquals("Session Changes", params["title"])
assertEquals("tool:ses_1:p1", params["token"])
}
fun `test inline params require directory and token`() {
assertTrue(KiloDiffEditorKind.isValid(diffParams("inline", "/repo", null, "Session Changes", token = "turn:ses_1:u1")))
assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "directory" to "/repo", "title" to "Session Changes")))
assertFalse(KiloDiffEditorKind.isValid(mapOf("source" to "inline", "token" to "turn:ses_1:u1", "title" to "Session Changes")))
}
fun `test inline editor title uses params title`() {
assertEquals("Session Changes", KiloDiffEditorKind.title(diffParams("inline", "/repo", null, "Session Changes", token = "token")))
}
fun `test reload updates aggregate badge`() {
val parent = Disposer.newDisposable()
try {
val editor = editor(files(), parent)
editor.applyFiles(listOf(file("src/App.kt", 7, 6)), "feature/test")
val badges = components(editor.component).filterIsInstance<DiffStatBadge>()
assertTrue(badges.any { it.addedLabelForTest().text == "+7" && it.removedLabelForTest().text == "-6" })
} finally {
Disposer.dispose(parent)
}
}
fun `test reload preserves selected file`() {
val parent = Disposer.newDisposable()
try {
val editor = editor(files(), parent)
val tree = components(editor.component).filterIsInstance<Tree>().single()
tree.selectionPath = TreePath(leaf(tree).path)
editor.applyFiles(
listOf(file("src/App.kt", 4, 2), file("test/AppTest.kt", 1, 1)),
"feature/test",
)
assertSame(leaf(tree), tree.lastSelectedPathComponent)
} finally {
Disposer.dispose(parent)
}
}
fun `test outdated banner is hidden initially`() {
val parent = Disposer.newDisposable()
try {
val editor = editor(files(), parent)
assertFalse(banner(editor).isVisible)
} finally {
Disposer.dispose(parent)
}
}
fun `test outdated banner appears when files change`() {
val parent = Disposer.newDisposable()
try {
val editor = editor(files(), parent)
editor.markOutdated()
UIUtil.dispatchAllInvocationEvents()
assertTrue(banner(editor).isVisible)
} finally {
Disposer.dispose(parent)
}
}
fun `test outdated banner appears for unsaved ide document changes`() {
val parent = Disposer.newDisposable()
try {
val psi = myFixture.addFileToProject("src/App.kt", "old")
val doc = FileDocumentManager.getInstance().getDocument(psi.virtualFile)!!
val dir = psi.virtualFile.parent.parent.path
val editor = editor(files(), parent, dir = dir)
ApplicationManager.getApplication().runWriteAction { doc.setText("new") }
UIUtil.dispatchAllInvocationEvents()
assertTrue(banner(editor).isVisible)
} finally {
Disposer.dispose(parent)
}
}
fun `test manual refresh clears banner and updates files`() {
val parent = Disposer.newDisposable()
try {
val next = listOf(file("src/App.kt", 9, 8))
val editor = editor(files(), parent) { done ->
done(DiffEditorData.Files(next, "feature/test"))
Job().also { it.complete() }
}
editor.markOutdated()
UIUtil.dispatchAllInvocationEvents()
editor.refresh()
val badges = components(editor.component).filterIsInstance<DiffStatBadge>()
assertFalse(banner(editor).isVisible)
assertTrue(badges.any { it.addedLabelForTest().text == "+9" && it.removedLabelForTest().text == "-8" })
} finally {
Disposer.dispose(parent)
}
}
fun `test editor construction does not refresh`() {
val parent = Disposer.newDisposable()
var calls = 0
try {
editor(files(), parent) {
calls += 1
Job().also { it.complete() }
}
assertEquals(0, calls)
} finally {
Disposer.dispose(parent)
}
}
fun `test reverse sync skips active requested path`() {
assertNull(reverseSyncTarget("src/App.kt", "src/App.kt", "test/AppTest.kt"))
}
fun `test reverse sync waits while requested path is pending`() {
assertNull(reverseSyncTarget("src/App.kt", "test/AppTest.kt", "src/App.kt"))
}
fun `test reverse sync returns active path for diff-driven navigation`() {
assertEquals("test/AppTest.kt", reverseSyncTarget("test/AppTest.kt", null, "src/App.kt"))
}
private fun renderer(tree: Tree, node: DefaultMutableTreeNode): Component =
tree.cellRenderer.getTreeCellRendererComponent(
tree,
node,
false,
tree.isExpanded(TreePath(node.path)),
node.isLeaf,
0,
false,
)
private fun leaf(tree: Tree): DefaultMutableTreeNode {
val root = tree.model.root as DefaultMutableTreeNode
val src = root.getChildAt(0) as DefaultMutableTreeNode
return src.getChildAt(0) as DefaultMutableTreeNode
}
private fun rowBadge(row: Component): DiffStatBadge = components(row).filterIsInstance<DiffStatBadge>().single()
private fun banner(editor: DiffEditorView): EditorNotificationPanel = components(editor.component)
.filterIsInstance<EditorNotificationPanel>()
.single()
private fun view(files: List<DiffFileDto>, parent: Disposable): Component = editor(files, parent).component
private fun editor(
files: List<DiffFileDto>,
parent: Disposable,
dir: String = project.basePath.orEmpty(),
load: ((DiffEditorData) -> Unit) -> Job = { Job().also { it.complete() } },
): DiffEditorView {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
Disposer.register(parent) { scope.cancel() }
return DiffEditorView(
project,
mapOf("directory" to dir, "source" to "branch"),
files,
parent,
"feature/test",
scope,
load,
) {}
}
private fun components(root: Component): List<Component> {
val out = mutableListOf<Component>()
fun visit(node: Component) {
out.add(node)
if (node is Container) node.components.forEach(::visit)
}
visit(root)
return out
}
private fun files() = listOf(
file("src/App.kt", 2, 1),
file("test/AppTest.kt", 3, 3),
)
private fun file(
path: String,
additions: Int,
deletions: Int,
patch: String? = "@@ -1 +1 @@\n-old\n+new",
status: String? = null,
) = DiffFileDto(
file = path,
additions = additions,
deletions = deletions,
patch = patch,
status = status,
)
}
@@ -0,0 +1,72 @@
package ai.kilocode.client.diff
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.components.service
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.testFramework.replaceService
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class KiloInlineDiffStoreTest : BasePlatformTestCase() {
private lateinit var coroutines: TestCoroutines
private lateinit var workspace: FakeWorkspaceRpcApi
private lateinit var service: KiloDiffEditorService
override fun setUp() {
super.setUp()
coroutines = TestCoroutines()
workspace = FakeWorkspaceRpcApi()
service = KiloDiffEditorService(project, coroutines.scope)
project.replaceService(KiloInlineDiffStore::class.java, KiloInlineDiffStore(), testRootDisposable)
ApplicationManager.getApplication()
.replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(coroutines.scope, workspace), testRootDisposable)
}
override fun tearDown() {
try {
coroutines.close { UIUtil.dispatchAllInvocationEvents() }
} finally {
super.tearDown()
}
}
fun `test pop returns then clears while get remains persistent`() {
val store = project.service<KiloInlineDiffStore>()
val files = listOf(file("src/A.kt", 2, 1))
store.put("inline", files)
assertEquals(files, store.get("inline"))
assertEquals(files, store.get("inline"))
store.put("branch:/test", files)
assertEquals(files, store.pop("branch:/test"))
assertNull(store.pop("branch:/test"))
}
fun `test branch fetch recomputes authoritatively and ignores any store seed`() = runBlocking {
val store = project.service<KiloInlineDiffStore>()
val stale = listOf(file("src/Stale.kt", 3, 1))
val fresh = file("src/Fresh.kt", 1, 0)
workspace.branchDiffs.add(fresh)
workspace.branchName = "main"
// A leftover seed under the branch token must never be consumed as a side channel: it would
// otherwise poison a re-open or Refresh with content from an earlier click.
store.put("branch:/test", stale)
val params = diffParams("branch", "/test", null, "Branch", "main")
val first = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files
val second = withContext(coroutines.dispatcher) { service.fetch(params) } as DiffEditorData.Files
assertEquals(listOf(fresh), first.files)
assertEquals(listOf(fresh), second.files)
assertEquals(listOf("/test", "/test"), workspace.branchDiffCalls)
assertEquals(stale, store.get("branch:/test"))
}
private fun file(path: String, additions: Int, deletions: Int) = DiffFileDto(path, additions, deletions)
}
@@ -1,10 +1,17 @@
package ai.kilocode.client.session
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.ModifiedFilesView
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.tool.ShellToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageSummaryDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
@@ -14,10 +21,6 @@ import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionRevertDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.ToolRefDto
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.views.tool.ShellToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBRadioButton
@@ -345,6 +348,57 @@ class SessionScrollTest : SessionUiTestBase() {
assertEquals(value, bar.value)
}
fun `test expanding modified files at bottom preserves clicked header position`() {
val mid = "modified_expand_bottom"
val pid = "modified_expand_bottom_part"
rpc.history.addAll(history(23) + modifiedHistory(mid, pid) + historyRange(1, start = 23))
ui = newUi(id = "ses_test")
settle()
drainScroll()
val bar = scrollBar()
setBottom(bar)
drainScroll()
val view = modifiedView()
assertFalse(view.bodyVisible())
val y = visibleY(view)
val value = bar.value
view.toggle()
drainScroll()
assertTrue(view.bodyVisible())
assertEquals(y, visibleY(view))
assertEquals(value, bar.value)
}
fun `test preserve re-enables tail when viewport is near bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
val messages = find<SessionMessageListPanel>(ui)
setBottom(bar)
drainScroll()
val anchor = messages.components.filterIsInstance<JComponent>().first()
val value = bottom(bar) - JBUI.scale(16)
setValue(bar, value)
drainScroll()
assertEquals(value, bar.value)
assertFalse(ui.scroll.following())
assertTrue(jumpButton().isVisible)
ui.scroll.preserve(anchor) {}
drainScroll()
assertFalse(jumpButton().isVisible)
assertTrue(ui.scroll.following())
emit(ChatEventDto.MessageUpdated("ses_test", message("preserve_shrink_tail")))
drainScroll()
assertBottom(bar)
}
fun `test expanding tool in middle preserves clicked header position`() {
val mid = "tool_expand_middle"
val pid = "tool_expand_middle_part"
@@ -1149,6 +1203,12 @@ class SessionScrollTest : SessionUiTestBase() {
?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}")
}
private fun modifiedView(): ModifiedFilesView {
val messages = find<SessionMessageListPanel>(ui)
return findAll<ModifiedFilesView>(messages).singleOrNull()
?: error("missing modified files card\n${messages.dumpDetailed()}")
}
private fun bodyVisible(view: JComponent): Boolean = when (view) {
is ShellToolView -> view.bodyVisible()
is ToolView -> view.bodyVisible()
@@ -1278,6 +1338,25 @@ class SessionScrollTest : SessionUiTestBase() {
listOf(toolPart(pid, mid)),
)
private fun modifiedHistory(mid: String, pid: String) = MessageWithPartsDto(
message(mid).copy(summary = MessageSummaryDto(listOf(modifiedFile()))),
listOf(part(pid, mid, "text", text(0))),
)
private fun modifiedFile() = DiffFileDto(
file = "src/Changed.kt",
additions = 80,
deletions = 80,
patch = buildString {
appendLine("diff --git a/src/Changed.kt b/src/Changed.kt")
appendLine("--- a/src/Changed.kt")
appendLine("+++ b/src/Changed.kt")
appendLine("@@ -1,80 +1,80 @@")
repeat(80) { i -> appendLine("-old line $i") }
repeat(80) { i -> appendLine("+new line $i") }
},
)
private fun historyRange(count: Int, start: Int) = List(count) { offset ->
val i = start + offset
val id = "hist_range_$i"
@@ -28,6 +28,7 @@ import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.SessionRevertDto
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.util.ui.JBUI
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
@@ -114,6 +115,33 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertNull(drop.dropTarget)
}
fun `test branch changes badge refreshes on finish and revert`() {
workspaceRpc.branchDiffs.clear()
workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1))
val header = find<SessionHeaderPanel>(ui)
controller().model.setState(SessionState.Busy("running"))
controller().model.setState(SessionState.Idle)
settle()
assertEquals(2 to 1, header.changesStat())
workspaceRpc.branchDiffs.clear()
workspaceRpc.branchDiffs.add(DiffFileDto("src/B.kt", 4, 3))
controller().model.setState(SessionState.Busy("running"))
controller().model.setState(SessionState.Idle)
settle()
assertEquals(4 to 3, header.changesStat())
workspaceRpc.branchDiffs.clear()
workspaceRpc.branchDiffs.add(DiffFileDto("src/C.kt", 1, 0))
controller().model.setRevert(SessionRevertDto("msg1", "part1", diff = "patch"))
settle()
assertEquals(1 to 0, header.changesStat())
}
fun `test prompt file drag leave does not immediately hide drop overlay`() {
val prompt = find<PromptPanel>(ui)
val drop = find<SessionDropOverlay>(ui)
@@ -46,6 +46,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
protected lateinit var sessions: KiloSessionService
protected lateinit var app: KiloAppService
protected lateinit var workspaces: KiloWorkspaceService
protected lateinit var workspaceRpc: FakeWorkspaceRpcApi
protected lateinit var rpc: FakeSessionRpcApi
protected lateinit var appRpc: FakeAppRpcApi
protected lateinit var workspace: Workspace
@@ -60,7 +61,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
appRpc = FakeAppRpcApi().also {
it.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
}
val workspaceRpc = FakeWorkspaceRpcApi().also {
workspaceRpc = FakeWorkspaceRpcApi().also {
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
}
@@ -3,8 +3,10 @@ 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.DiffFileDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.MessageSummaryDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelDto
@@ -13,7 +15,9 @@ import ai.kilocode.rpc.dto.ProviderDto
class HistoryLoadingTest : SessionControllerTestBase() {
fun `test existing session loads history on init`() {
val m = msg("msg1", "ses_test", "user")
val m = msg("msg1", "ses_test", "user").copy(
summary = MessageSummaryDto(listOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch"))),
)
val part = part("prt1", "ses_test", "msg1", "text", text = "hello")
rpc.history.add(MessageWithPartsDto(m, listOf(part)))
@@ -30,6 +34,7 @@ class HistoryLoadingTest : SessionControllerTestBase() {
""",
c,
)
assertEquals("src/A.kt", c.model.message("msg1")?.info?.summary?.diffs?.single()?.file)
}
fun `test non-empty history shows messages view`() {
@@ -0,0 +1,291 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
class PermissionQueueTest : SessionControllerTestBase() {
override fun setUp() {
super.setUp()
edt { KiloPluginSettings.unsetAutoApprove() }
}
fun `test two permissions advance in FIFO order`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
assertPermission(m, "perm1")
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
assertPermission(m, "perm2")
emit(ChatEventDto.PermissionReplied("ses_test", "perm2"))
assertTrue(m.model.state is SessionState.Busy)
}
fun `test duplicate permission ask does not reset active card`() {
val (m, _, events) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1", "edit")))
events.clear()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1", "read")))
assertPermission(m, "perm1", "edit")
assertModelEvents("", events)
}
fun `test non-front resolution leaves active permission shown`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
emit(ChatEventDto.PermissionReplied("ses_test", "perm2"))
assertPermission(m, "perm1")
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
assertTrue(m.model.state is SessionState.Busy)
}
fun `test recovered permissions advance in FIFO order`() {
rpc.pendingPermissionList.add(permission("perm1"))
rpc.pendingPermissionList.add(permission("perm2"))
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertPermission(m, "perm1")
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
assertPermission(m, "perm2")
emit(ChatEventDto.PermissionReplied("ses_test", "perm2"))
assertTrue(m.model.state is SessionState.Busy)
}
fun `test late permission reply while idle does not force busy`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionReplied("ses_test", "perm_gone"))
assertTrue(m.model.state is SessionState.Idle)
}
fun `test turn close purges outstanding permission ghost`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
assertPermission(m, "perm1")
// The CLI abandons an outstanding permission server-side when a turn is interrupted, without
// emitting permission.replied, so TurnClose must drop the ghost instead of leaving it shown.
emit(ChatEventDto.TurnClose("ses_test", "aborted"))
assertTrue(m.model.state is SessionState.Idle)
// The next request surfaces itself rather than the purged ghost (which would fail to reply).
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
assertPermission(m, "perm2")
}
fun `test session idle purges outstanding permission ghost`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
assertPermission(m, "perm1")
emit(ChatEventDto.SessionIdle("ses_test"))
assertTrue(m.model.state is SessionState.Idle)
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
assertPermission(m, "perm2")
}
fun `test stop purges outstanding permission ghost`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
assertPermission(m, "perm1")
edt { m.abort() }
flush()
assertTrue(m.model.state is SessionState.Idle)
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm2")))
assertPermission(m, "perm2")
}
fun `test auto approve skill shell permissions stay queued in FIFO order`() {
edt { KiloPluginSettings.setAutoApprove(true) }
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1")))
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm2")))
assertPermission(m, "perm1")
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
assertPermission(m, "perm2")
}
fun `test auto approve failure card is queued and purged by stop`() {
edt { KiloPluginSettings.setAutoApprove(true) }
rpc.replyPermissionThrows = RuntimeException("boom")
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
flush()
// The failed auto-approval surfaces as an error card; it must be in the queue so purge sees it.
val state = m.model.state as? SessionState.AwaitingPermission ?: error("Expected error card")
assertEquals("perm1", state.permission.id)
assertEquals(PermissionRequestState.ERROR, state.permission.state)
edt { m.abort() }
flush()
assertTrue(m.model.state is SessionState.Idle)
}
fun `test auto approve drain queues multiple skill shell permissions`() {
rpc.pendingPermissionList.add(skillPermission("perm1"))
rpc.pendingPermissionList.add(skillPermission("perm2"))
val (m, _, _) = prompted()
edt { m.setAutoApprove(true) }
flush()
assertPermission(m, "perm1")
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
assertPermission(m, "perm2")
}
fun `test toggling auto approve on keeps a visible skill shell card queued for purge`() {
// A skill-shell card is up while auto-approve is off; enabling auto-approve must not strand it.
// Skill-shell asks always need a human, so setAutoApprove re-shows the card via show(); that
// enqueue has to survive pending.clear() (be the last writer) or the card becomes a ghost that
// is no longer in pending, which a later Stop could not purge and answering would NotFoundError.
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1")))
assertPermission(m, "perm1")
edt { m.setAutoApprove(true) }
flush()
// Still shown, and still tracked in pending — so Stop can clear it.
assertPermission(m, "perm1")
edt { m.abort() }
flush()
assertTrue(m.model.state is SessionState.Idle)
}
fun `test toggling auto approve on keeps skill shell card while draining other permissions`() {
// Visible skill-shell card plus another auto-approvable permission on the server. Enabling
// auto-approve drains/replies the other one, but the drain must not flip the preserved
// skill-shell card to Busy — that would hide it with no reply path left.
rpc.pendingPermissionList.add(permission("perm2"))
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", skillPermission("perm1")))
assertPermission(m, "perm1")
edt { m.setAutoApprove(true) }
flush()
assertTrue(rpc.permissionReplies.any { it.first == "perm2" })
assertPermission(m, "perm1")
edt { m.abort() }
flush()
assertTrue(m.model.state is SessionState.Idle)
}
fun `test replying active question shows queued permission`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
assertTrue(m.model.state is SessionState.AwaitingQuestion)
edt { m.replyQuestion("q1", QuestionReplyDto(listOf(listOf("A")))) }
emit(ChatEventDto.QuestionReplied("ses_test", "q1"))
assertPermission(m, "perm1")
}
fun `test status idle keeps a promoted child permission instead of clobbering to idle`() {
// A root card in front of a queued child permission: when the root session reports idle,
// purgePending clears the root card and promotes the child's still-live permission. The
// status handler must leave that promotion in place rather than overwriting it with Idle.
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PermissionAsked("ses_child", childPermission("child_perm1")))
assertPermission(m, "perm1")
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
assertPermission(m, "child_perm1")
}
private fun taskPart(child: String) = ChatEventDto.PartUpdated(
sessionID = "ses_test",
part = PartDto(
id = "part_task",
sessionID = "ses_test",
messageID = "msg1",
type = "tool",
tool = "task",
metadata = mapOf("sessionId" to child),
input = mapOf("subagent_type" to "explore", "description" to "Find files"),
),
)
private fun childPermission(id: String) = PermissionRequestDto(
id = id,
sessionID = "ses_child",
permission = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
)
private fun assertPermission(c: SessionController, id: String, name: String = "edit") {
val state = c.model.state as? SessionState.AwaitingPermission ?: error("Expected AwaitingPermission")
assertEquals(id, state.permission.id)
assertEquals(name, state.permission.name)
}
private fun permission(id: String, name: String = "edit") = PermissionRequestDto(
id = id,
sessionID = "ses_test",
permission = name,
patterns = listOf("*.kt"),
always = emptyList(),
)
private fun skillPermission(id: String) = permission(id).copy(metadata = mapOf("skillShell" to "true"))
private fun question(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(QuestionInfoDto("Pick one", "Choice")),
)
}
@@ -0,0 +1,153 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.views.SessionViewIcons
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
import java.awt.Component
import java.awt.Container
import javax.swing.AbstractButton
class ModifiedFilesViewTest : BasePlatformTestCase() {
private lateinit var view: ModifiedFilesView
override fun setUp() {
super.setUp()
view = ModifiedFilesView({ _, _ -> })
}
override fun tearDown() {
try {
Disposer.dispose(view)
} finally {
super.tearDown()
}
}
fun `test view is hidden without changes and shows count after diff`() {
assertFalse(view.isVisible)
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
assertTrue(view.isVisible)
assertEquals("1 file", view.countText())
}
fun `test header uses edit icon`() {
val labels = components(view).filterIsInstance<JBLabel>()
assertTrue(labels.any { it.icon === SessionViewIcons.edit })
}
fun `test expand renders one link and badge per file`() {
val opened = mutableListOf<String>()
Disposer.dispose(view)
view = ModifiedFilesView({ href, _ -> opened.add(href) })
view.setDiffs(listOf(
file("src/A.kt", 2, 0, ADD),
file("pkg/B.kt", 1, 1, UPDATE),
))
assertFalse(view.bodyCreated())
view.toggle()
assertTrue(view.isExpanded())
assertTrue(view.bodyVisible())
assertTrue(view.bodyCreated())
assertEquals(2, components(view).filterIsInstance<DiffStatBadge>().size)
val links = components(view).filterIsInstance<JBLabel>().filter { it.text?.contains("<u>") == true }
assertTrue(links.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" })
assertTrue(links.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" })
}
fun `test popup is available only when collapsed`() {
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
assertNotNull(view.headerPopup())
view.toggle()
assertNull(view.headerPopup())
}
fun `test open in diff uses changed files title`() {
val titles = mutableListOf<String>()
view.setDiffOpener({ _, title, _ -> titles.add(title) }, "ses", "turn")
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
openDiffButton().doClick()
assertEquals("Changed files", titles.single())
}
fun `test dispose releases created editors`() {
val base = EditorFactory.getInstance().allEditors.size
view.setDiffs(listOf(file("src/A.kt", 2, 1, PATCH)))
repeat(20) {
view.expand()
view.collapse()
view.setDiffs(listOf(file("src/A.kt", it + 1, 1, PATCH)))
}
Disposer.dispose(view)
UIUtil.dispatchAllInvocationEvents()
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
private fun components(root: Component): List<Component> {
val out = mutableListOf<Component>()
fun visit(node: Component) {
out.add(node)
if (node is Container) node.components.forEach(::visit)
}
visit(root)
return out
}
private fun openDiffButton(): AbstractButton = view.copyToolbar as AbstractButton
private fun file(path: String, additions: Int, deletions: Int, patch: String) = DiffFileDto(
file = path,
additions = additions,
deletions = deletions,
patch = patch,
)
private companion object {
val PATCH = """
diff --git a/src/A.kt b/src/A.kt
--- a/src/A.kt
+++ b/src/A.kt
@@ -1,1 +1,2 @@
-old
+new
+more
""".trimIndent()
val ADD = """
diff --git a/src/A.kt b/src/A.kt
--- /dev/null
+++ b/src/A.kt
@@ -0,0 +1,2 @@
+one
+two
""".trimIndent()
val UPDATE = """
diff --git a/pkg/B.kt b/pkg/B.kt
--- a/pkg/B.kt
+++ b/pkg/B.kt
@@ -1,1 +1,1 @@
-before
+after
""".trimIndent()
}
}
@@ -32,6 +32,7 @@ import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageSummaryDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
@@ -62,6 +63,16 @@ import javax.swing.RepaintManager
import javax.swing.SwingUtilities
import javax.swing.border.Border
private val PATCH = """
diff --git a/src/A.kt b/src/A.kt
--- a/src/A.kt
+++ b/src/A.kt
@@ -1,1 +1,2 @@
-old
+new
+more
""".trimIndent()
/**
* Tests for [SessionMessageListPanel] structural and index integrity.
*
@@ -99,6 +110,28 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertEquals("", panel.dump())
}
fun `test modified files card follows turn anchor summary`() {
model.upsertMessage(msg("u1", "user").copy(summary = summary("src/A.kt")))
val turn = panel.findTurn("u1")!!
val card = components(turn).filterIsInstance<ModifiedFilesView>().single()
assertSame(card, turn.components.last())
assertTrue(card.isVisible)
assertEquals("1 file", card.countText())
}
fun `test message updated summary updates modified files card`() {
model.upsertMessage(msg("u1", "user"))
assertTrue(components(panel.findTurn("u1")!!).filterIsInstance<ModifiedFilesView>().isEmpty())
model.upsertMessage(msg("u1", "user").copy(summary = summary("src/A.kt")))
val card = components(panel.findTurn("u1")!!).filterIsInstance<ModifiedFilesView>().single()
assertTrue(card.isVisible)
assertEquals("1 file", card.countText())
}
fun `test transcript content has symmetric side padding`() {
model.upsertMessage(msg("a1", "assistant"))
@@ -1260,6 +1293,10 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0),
)
private fun summary(path: String) = MessageSummaryDto(
diffs = listOf(DiffFileDto(path, 2, 1, PATCH)),
)
private fun part(id: String, mid: String, type: String, text: String? = null) = PartDto(
id = id, sessionID = "ses", messageID = mid, type = type, text = text,
)
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.ui.header
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.StepFinish
import ai.kilocode.client.session.model.Tool
@@ -19,12 +20,15 @@ import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.icons.AllIcons
import com.intellij.ide.util.PropertiesComponent
import ai.kilocode.rpc.dto.DiffFileDto
import java.awt.Cursor
import java.awt.Color
import java.awt.Point
import java.awt.event.MouseEvent
import java.awt.event.MouseWheelEvent
import java.awt.image.BufferedImage
import javax.swing.JComponent
import javax.swing.RepaintManager
import javax.swing.UIManager
class SessionHeaderPanelTest : SessionControllerTestBase() {
@@ -122,6 +126,96 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
assertEquals(1, rpc.compacts.size)
}
fun `test branch changes badge shows count stats and hides when empty`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
assertFalse(panel.changesVisible())
panel.setBranchChanges(listOf(
DiffFileDto("src/A.kt", 2, 1),
DiffFileDto("src/B.kt", 0, 3),
DiffFileDto("src/C.kt", 5, 0),
))
assertTrue(panel.changesVisible())
assertEquals("3 files", panel.changesText())
assertEquals(7 to 4, panel.changesStat())
panel.setBranchChanges(emptyList())
assertFalse(panel.changesVisible())
}
fun `test branch changes badge invokes callback when clicked`() {
val c = promptedHeader()
var opened = 0
val panel = SessionHeaderPanel(c, parent) { opened++ }
val badge = panel.changesBadge()
panel.setBranchChanges(listOf(DiffFileDto("src/A.kt", 2, 1)))
assertTrue(badge.isVisible)
assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), badge.toolTipText)
assertEquals(KiloBundle.message("diff.editor.branch.tooltip"), badge.accessibleContext.accessibleName)
click(badge)
assertEquals(1, opened)
}
fun `test branch changes badge is hidden without files even with callback`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent) {}
assertFalse(panel.changesVisible())
}
fun `test branch changes badge no-op update does not repaint`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
val files = listOf(DiffFileDto("src/A.kt", 2, 1))
panel.setBranchChanges(files)
val prev = RepaintManager.currentManager(panel)
val tracker = TrackingRepaintManager(panel.changesBadge())
try {
RepaintManager.setCurrentManager(tracker)
panel.setBranchChanges(files)
assertTrue(tracker.dirty.isEmpty())
assertTrue(tracker.invalid.isEmpty())
} finally {
RepaintManager.setCurrentManager(prev)
}
}
fun `test clicking session title toggles expansion`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
assertFalse(panel.isExpanded())
click(panel.titleLabel())
assertTrue(panel.isExpanded())
click(panel.titleLabel())
assertFalse(panel.isExpanded())
}
fun `test top row places expand center group and right controls`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
val top = panel.expandButton().parent
val layout = top.layout as java.awt.BorderLayout
assertSame(panel.expandButton(), layout.getLayoutComponent(java.awt.BorderLayout.WEST))
assertSame(panel.centerGroupPanel(), layout.getLayoutComponent(java.awt.BorderLayout.CENTER))
assertSame(panel.rightPanel(), layout.getLayoutComponent(java.awt.BorderLayout.EAST))
assertSame(panel.centerGroupPanel(), panel.changesBadge().parent)
assertSame(panel.rightPanel(), panel.compactButton().parent)
}
fun `test todo list starts collapsed and toggles independently`() {
val c = promptedHeader()
val panel = SessionHeaderPanel(c, parent)
@@ -677,4 +771,19 @@ class SessionHeaderPanelTest : SessionControllerTestBase() {
private fun reset() {
PropertiesComponent.getInstance().unsetValue(SessionHeaderPanel.EXPANDED_KEY)
}
private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() {
val dirty = mutableListOf<JComponent>()
val invalid = mutableListOf<JComponent>()
override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) {
if (c === watched) dirty.add(c)
super.addDirtyRegion(c, x, y, w, h)
}
override fun addInvalidComponent(invalidComponent: JComponent) {
if (invalidComponent === watched) invalid.add(invalidComponent)
super.addInvalidComponent(invalidComponent)
}
}
}
@@ -0,0 +1,49 @@
package ai.kilocode.client.session.ui.popup
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.Component
import java.awt.Container
import java.awt.Dimension
import javax.swing.JPanel
class HeaderPopupBodyTest : BasePlatformTestCase() {
fun `test tall popup content scrolls and caps height`() {
val tall = JPanel().apply {
preferredSize = Dimension(JBUI.scale(200), JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT * 3))
}
val owner = Disposer.newDisposable("popup body")
Disposer.register(testRootDisposable, owner)
val body = HeaderPopupBody(tall, owner, UIUtil.getPanelBackground())
val scroll = descendants(body.component).filterIsInstance<JBScrollPane>().single()
assertSame(tall, scroll.viewport.view)
assertEquals(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), body.component.preferredSize.height)
}
fun `test short popup content is not capped`() {
val short = JPanel().apply {
preferredSize = Dimension(JBUI.scale(200), JBUI.scale(40))
}
val owner = Disposer.newDisposable("popup body")
Disposer.register(testRootDisposable, owner)
val body = HeaderPopupBody(short, owner, UIUtil.getPanelBackground())
assertEquals(JBUI.scale(40), body.component.preferredSize.height)
}
private fun descendants(root: Component): List<Component> {
val out = mutableListOf<Component>()
fun visit(node: Component) {
out.add(node)
if (node is Container) node.components.forEach(::visit)
}
visit(root)
return out
}
}
@@ -9,6 +9,7 @@ import ai.kilocode.client.session.views.tool.EditToolView
import ai.kilocode.client.session.views.tool.ReadToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.rpc.dto.DiffFileDto
import com.intellij.openapi.diff.DiffColors
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.util.Disposer
@@ -23,6 +24,7 @@ import kotlinx.serialization.json.put
import java.awt.Component
import java.awt.Container
import java.awt.event.MouseEvent
import javax.swing.AbstractButton
@Suppress("UnstableApiUsage")
class EditToolViewTest : BasePlatformTestCase() {
@@ -130,6 +132,55 @@ class EditToolViewTest : BasePlatformTestCase() {
assertEquals(listOf("src/A.kt"), opened)
}
fun `test open in diff action fires for edit and patch`() {
val edit = mutableListOf<List<DiffFileDto>>()
val titles = mutableListOf<String>()
val editView = track(EditToolView(tool(), { _, _ -> }, null, { files, title, _ ->
edit.add(files)
titles.add(title)
}, "ses"))
val editButton = openDiffButton(editView)
assertTrue(editButton.isEnabled)
editButton.doClick()
assertEquals(1, edit.single().size)
// Single-file edit keeps the file name so its diff tab is identifiable (not a generic "Edit").
assertEquals("App.kt", titles.single())
val patch = mutableListOf<List<DiffFileDto>>()
val patchView = track(EditToolView(tool().also {
it.input = emptyMap()
it.metadata = mapOf("files" to filesMeta(
FileChange("src/A.kt", 2, 0, ADD_HUNK),
FileChange("src/B.kt", 1, 1, UPDATE_HUNK),
))
}, { _, _ -> }, null, { files, title, _ ->
patch.add(files)
titles.add(title)
}, "ses"))
val patchButton = openDiffButton(patchView)
assertTrue(patchButton.isEnabled)
patchButton.doClick()
assertEquals(2, patch.single().size)
assertEquals("Patch", titles.last())
}
fun `test open in diff uses a late-bound opener`() {
// Mirrors the real wiring: the view is built before the session-level opener is known, then
// MessageView rebinds it. Without late binding the button click is a no-op.
val fired = mutableListOf<List<DiffFileDto>>()
val view = track(EditToolView(tool()))
val button = openDiffButton(view)
assertTrue(button.isEnabled)
button.doClick()
assertTrue(fired.isEmpty())
view.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses")
button.doClick()
assertEquals(1, fired.single().size)
}
fun `test single file apply_patch keeps link and hides count tag`() {
val view = track(EditToolView(tool().also {
it.input = emptyMap()
@@ -222,6 +273,8 @@ class EditToolViewTest : BasePlatformTestCase() {
click(link, 0)
assertEquals(listOf("/repo/src/App.kt"), opened)
// The link is not bound for toggling, so opening the file must not also collapse the card.
assertTrue(view.isExpanded())
}
fun `test metadata only patch falls back to raw text`() {
@@ -397,6 +450,9 @@ class EditToolViewTest : BasePlatformTestCase() {
if (child is DiffStatBadge) nested + child else nested
}
private fun openDiffButton(view: EditToolView): AbstractButton =
view.copyToolbar as AbstractButton
private fun tool() = Tool("p1", "edit", toolKind("edit")).also {
it.state = ToolExecState.COMPLETED
it.title = "src/App.kt"
@@ -9,9 +9,10 @@ import ai.kilocode.client.session.views.tool.GlobToolView
import ai.kilocode.client.session.views.tool.ReadToolView
import ai.kilocode.client.session.views.tool.SearchToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Container
import java.awt.Dimension
@@ -111,12 +112,15 @@ class SearchToolViewTest : BasePlatformTestCase() {
assertEquals(style.regularFont, view.targetFont(1))
}
fun `test search header title target gap uses standard medium gap`() {
fun `test search header uses standard layout gap between regions`() {
val view = SearchToolView(tool().also {
it.input = mapOf("pattern" to "TODO", "include" to "*.kt")
})
assertEquals(UiStyle.Gap.md(), (view.centerComponent().layout as BorderLayout).hgap)
assertEquals(
JBUI.scale(SessionUiStyle.View.Layout.GAP),
(view.headerComponent().layout as BorderLayout).hgap,
)
}
fun `test completed search starts collapsed and expands output`() {
@@ -8,11 +8,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.scale.JBUIScale
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.image.BufferedImage
@@ -279,10 +279,10 @@ class ToolViewTest : BasePlatformTestCase() {
assertSmallEditorFont(view.stateFont(), style)
}
fun `test tool header title subtitle gap uses standard medium gap`() {
fun `test tool header uses standard layout gap`() {
val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" }))
assertEquals(UiStyle.Gap.md(), centerGap(view))
assertEquals(JBUI.scale(SessionUiStyle.View.Layout.GAP), headerGap(view))
}
fun `test applyStyle updates tool fonts in place`() {
@@ -434,11 +434,10 @@ class ToolViewTest : BasePlatformTestCase() {
assertTrue(font.size < style.editorSize)
}
private fun centerGap(view: ToolView): Int {
private fun headerGap(view: ToolView): Int {
val row = view.components.filterIsInstance<JPanel>().single()
val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
return (center.layout as BorderLayout).hgap
return (header.layout as BorderLayout).hgap
}
private fun paint(border: Border): Color {
@@ -7,12 +7,18 @@ import ai.kilocode.client.session.model.Text
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.ModifiedFilesView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.tool.EditToolView
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.JBUI
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.awt.image.BufferedImage
import javax.swing.AbstractButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.RepaintManager
@@ -84,6 +90,32 @@ class TurnViewTest : BasePlatformTestCase() {
assertEquals("user#u1, assistant#a1", tv.dump())
}
fun `test modified files card stays last in turn`() {
val tv = TurnView("u1", openFile)
tv.addMessage(msg("u1", "user"))
tv.setDiffs(listOf(diff("src/A.kt")))
val card = tv.components.last() as ModifiedFilesView
assertTrue(card.isVisible)
tv.addMessage(msg("a1", "assistant"))
assertSame(card, tv.components.last())
assertEquals(listOf("u1", "a1"), tv.messageIds())
}
fun `test modified files card hides for empty diffs`() {
val tv = TurnView("u1", openFile)
tv.setDiffs(listOf(diff("src/A.kt")))
val card = tv.components.last() as ModifiedFilesView
tv.setDiffs(emptyList())
assertFalse(card.isVisible)
}
// ------ MessageView ------
fun `test new MessageView is empty`() {
@@ -294,6 +326,20 @@ class TurnViewTest : BasePlatformTestCase() {
}
}
fun `test setDiffOpener rebinds edit tool parts built before wiring`() {
// The transcript builds the MessageView (and its EditToolView) before the session-level
// opener is known, exactly like history load. Rebinding must reach the existing part.
val message = msg("a1", "assistant").also { it.parts["t1"] = editTool() }
val mv = MessageView(message, openFile)
val fired = mutableListOf<List<DiffFileDto>>()
mv.setDiffOpener({ files, _, _ -> fired.add(files) }, "ses")
openDiffButton(mv).doClick()
assertEquals(1, fired.single().size)
}
fun `test MessageView pre-populates parts from Message on creation`() {
val message = msg("a1", "assistant")
val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") }
@@ -344,6 +390,23 @@ class TurnViewTest : BasePlatformTestCase() {
private fun msg(id: String, role: String): Message =
Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0)))
private fun diff(path: String) = DiffFileDto(path, additions = 2, deletions = 1, patch = PATCH)
private fun editTool() = Tool("t1", "edit", toolKind("edit")).also {
it.state = ToolExecState.COMPLETED
it.title = "src/App.kt"
it.input = mapOf("filePath" to "/repo/src/App.kt")
it.metadata = mapOf("filediff" to buildJsonObject {
put("file", "src/App.kt")
put("additions", 2)
put("deletions", 1)
put("patch", PATCH)
}.toString())
}
private fun openDiffButton(view: MessageView): AbstractButton =
(view.part("t1") as EditToolView).copyToolbar as AbstractButton
private fun reasoning(id: String, content: String) = Reasoning(id).also {
it.done = false
it.content.append(content)
@@ -375,4 +438,16 @@ class TurnViewTest : BasePlatformTestCase() {
super.addInvalidComponent(invalidComponent)
}
}
private companion object {
val PATCH = """
diff --git a/src/A.kt b/src/A.kt
--- a/src/A.kt
+++ b/src/A.kt
@@ -1,1 +1,2 @@
-old
+new
+more
""".trimIndent()
}
}
@@ -0,0 +1,108 @@
package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionUiStyle.View.Header
import ai.kilocode.client.ui.DiffBars
import ai.kilocode.client.ui.HoverIcon
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import java.awt.Component
import java.awt.Container
import java.awt.Dimension
import javax.swing.SwingUtilities
import kotlin.math.abs
class PartHeaderTest : BasePlatformTestCase() {
fun `test labels and fixed controls are vertically centered`() {
val title = JBLabel("Edit")
val icon = HoverIcon()
val bars = DiffBars(3, 1)
val header = PartHeader().apply {
left(title, PartHeader.centered(icon))
right(PartHeader.centered(bars))
}
sized(header, 400)
val mid = header.height / 2
assertNear(mid, centerY(header, title))
assertNear(mid, centerY(header, icon))
assertNear(mid, centerY(header, bars))
}
fun `test leading uses icon gap and universal gap between elements`() {
val glyph = JBLabel("g")
val title = JBLabel("Edit")
val extra = JBLabel("x")
val header = PartHeader().apply {
leading(glyph)
left(title, extra)
}
sized(header, 400)
assertEquals(Header.icon(), title.x - (glyph.x + glyph.width))
assertEquals(Header.gap(), extra.x - (title.x + title.width))
}
fun `test title gap separates the title from following elements`() {
val glyph = JBLabel("g")
val title = JBLabel("Edit")
val name = JBLabel("main.tf")
val extra = JBLabel("x")
val header = PartHeader().apply {
leading(glyph)
left(title)
titleGap()
left(name, extra)
}
sized(header, 400)
assertEquals(Header.title(), name.x - (title.x + title.width))
assertEquals(Header.gap(), extra.x - (name.x + name.width))
assertTrue(Header.title() > Header.gap())
}
fun `test right group hugs the trailing edge`() {
val bars = DiffBars(1, 1)
val header = PartHeader().apply {
left(JBLabel("Modified"))
right(PartHeader.centered(bars))
}
sized(header, 400)
val edge = SwingUtilities.convertPoint(bars.parent, bars.x + bars.width, 0, header).x
assertTrue("right group should reach the trailing edge, was $edge of ${header.width}", edge >= header.width - 2)
}
fun `test fill middle absorbs width and clips long content`() {
val path = JBLabel("a".repeat(400))
val header = PartHeader().apply {
left(JBLabel("Edit"))
fill(path)
right(JBLabel("done"))
}
sized(header, 240)
assertTrue("fill child should not exceed header width", path.width <= header.width)
}
private fun sized(header: PartHeader, width: Int) {
header.size = Dimension(width, header.preferredSize.height)
layout(header)
}
private fun layout(root: Container) {
root.doLayout()
root.components.filterIsInstance<Container>().forEach { layout(it) }
}
private fun centerY(header: PartHeader, comp: Component): Int =
SwingUtilities.convertPoint(comp.parent, comp.x + comp.width / 2, comp.y + comp.height / 2, header).y
private fun assertNear(expected: Int, actual: Int) {
assertTrue("expected ~$expected but was $actual", abs(expected - actual) <= 1)
}
}
@@ -80,12 +80,12 @@ class TodoWriteViewTest : BasePlatformTestCase() {
assertEquals(style.regularFont, view.rowFont(1))
}
fun `test todo header title subtitle gap uses standard medium gap`() {
fun `test todo header uses standard layout gap`() {
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(TodoDto("Next", "pending", "medium"))
})
assertEquals(UiStyle.Gap.md(), centerGap(view))
assertEquals(JBUI.scale(SessionUiStyle.View.Layout.GAP), headerGap(view))
}
fun `test todo body uses next standard inner padding`() {
@@ -157,11 +157,10 @@ class TodoWriteViewTest : BasePlatformTestCase() {
assertTrue(view.rowText(0).contains("New"))
}
private fun centerGap(view: TodoWriteView): Int {
private fun headerGap(view: TodoWriteView): Int {
val row = view.components.filterIsInstance<JPanel>().first()
val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel
return (center.layout as BorderLayout).hgap
return (header.layout as BorderLayout).hgap
}
private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state }
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
@@ -47,6 +48,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
/** Message history returned by [messages]. */
val history = mutableListOf<MessageWithPartsDto>()
val histories = mutableMapOf<String, MutableList<MessageWithPartsDto>>()
val diffs = mutableMapOf<String, MutableList<DiffFileDto>>()
var historyGate: CompletableDeferred<Unit>? = null
var historyCalls = 0
private set
@@ -252,6 +254,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
return histories[id]?.toList() ?: history.toList()
}
override suspend fun diff(id: String, directory: String): List<DiffFileDto> {
assertNotEdt("diff")
return diffs[id]?.toList().orEmpty()
}
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? {
assertNotEdt("attachmentPart")
attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey))
@@ -276,8 +283,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
configs.add(directory to config)
}
var replyPermissionThrows: Exception? = null
override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) {
assertNotEdt("replyPermission")
replyPermissionThrows?.let { throw it }
permissionReplies.add(Triple(requestId, directory, reply))
}
@@ -2,6 +2,7 @@ package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
@@ -34,6 +35,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
var searchResult = FileSearchResultDto()
var search: ((String) -> FileSearchResultDto)? = null
var gitChanges: String? = null
val branchDiffs = mutableListOf<DiffFileDto>()
val branchDiffCalls = CopyOnWriteArrayList<String>()
val branchDiffPatchCalls = CopyOnWriteArrayList<Boolean>()
var branchName: String? = null
var openResult = true
var localConfigPath = "/test/.kilo/kilo.jsonc"
var globalConfigPath = "/config/kilo.jsonc"
@@ -94,6 +99,18 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
return gitChanges
}
override suspend fun branchDiff(directory: String, patches: Boolean): List<DiffFileDto> {
assertNotEdt("branchDiff")
branchDiffCalls.add(directory)
branchDiffPatchCalls.add(patches)
return branchDiffs.toList()
}
override suspend fun branchName(directory: String): String? {
assertNotEdt("branchName")
return branchName
}
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
assertNotEdt("openFile")
opened.add(path)
@@ -0,0 +1,44 @@
package ai.kilocode.client.ui
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class DiffStatBadgeTest : BasePlatformTestCase() {
fun `test hides deletion label when deletions are zero`() {
val badge = DiffStatBadge(3, 0)
assertTrue(badge.addedLabelForTest().isVisible)
assertEquals("+3", badge.addedLabelForTest().text)
assertFalse(badge.removedLabelForTest().isVisible)
}
fun `test hides addition label when additions are zero`() {
val badge = DiffStatBadge(0, 2)
assertTrue(badge.removedLabelForTest().isVisible)
assertEquals("-2", badge.removedLabelForTest().text)
assertFalse(badge.addedLabelForTest().isVisible)
}
fun `test both zero leaves badge empty`() {
val badge = DiffStatBadge(0, 0)
assertFalse(badge.removedLabelForTest().isVisible)
assertFalse(badge.addedLabelForTest().isVisible)
}
fun `test update toggles zero side visibility`() {
val badge = DiffStatBadge(1, 1)
badge.update(0, 4)
assertTrue(badge.removedLabelForTest().isVisible)
assertFalse(badge.addedLabelForTest().isVisible)
badge.update(5, 0)
assertFalse(badge.removedLabelForTest().isVisible)
assertTrue(badge.addedLabelForTest().isVisible)
badge.update(0, 0)
assertFalse(badge.removedLabelForTest().isVisible)
assertFalse(badge.addedLabelForTest().isVisible)
}
}
@@ -3,6 +3,7 @@ package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
@@ -99,6 +100,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
/** Load message history for a session. */
suspend fun messages(id: String, directory: String): List<MessageWithPartsDto>
/** Load cumulative file changes for a session. */
suspend fun diff(id: String, directory: String): List<DiffFileDto>
/** Load one attachment part from a session without returning full history to the frontend. */
suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto?
@@ -1,6 +1,7 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
@@ -54,6 +55,17 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
/** Current uncommitted git changes as a unified diff for @git-changes mentions. */
suspend fun gitChanges(directory: String): String?
/**
* Committed branch changes compared with the default branch merge-base.
*
* [patches] = false returns file stats only (additions/deletions/status) and skips materializing
* the full patch text used by the header badge, which only needs counts.
*/
suspend fun branchDiff(directory: String, patches: Boolean = true): List<DiffFileDto>
/** Current git branch name for branch-scoped UI labels. */
suspend fun branchName(directory: String): String?
/** Open an absolute backend file path in the IDE. */
suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean
@@ -18,6 +18,12 @@ data class MessageDto(
val cost: Double? = null,
val tokens: TokensDto? = null,
val error: MessageErrorDto? = null,
val summary: MessageSummaryDto? = null,
)
@Serializable
data class MessageSummaryDto(
val diffs: List<DiffFileDto> = emptyList(),
)
@Serializable
@@ -403,6 +409,7 @@ data class DiffFileDto(
val additions: Int,
val deletions: Int,
val patch: String? = null,
val status: String? = null,
)
// --- Config Update ---