Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b2f0bb0
fix(jetbrains): sharpen copy icon
kirillk Aug 2, 2026
d5e19d5
fix(jetbrains): update reasoning icon
kirillk Aug 3, 2026
c47cfec
fix(jetbrains): align reverted diff action
kirillk Aug 3, 2026
9b4738e
fix(jetbrains): load reverted session diffs
kirillk Aug 3, 2026
6eba817
fix(jetbrains): show absolute reverted file tooltips
kirillk Aug 3, 2026
4cdbe9d
fix(jetbrains): reconcile session layout cache
kirillk Aug 3, 2026
6414e64
fix(jetbrains): render multi-hunk diffs
kirillk Aug 4, 2026
bfab19c
fix(jetbrains): resolve revert tooltip fallback
kirillk Aug 4, 2026
0983e07
feat(jetbrains): full-file diff detail for editor tabs
kirillk Aug 4, 2026
a90a242
Merge remote-tracking branch 'origin/main' into jetbrains-pixel-icons
kirillk Aug 4, 2026
8650fdd
refactor(jetbrains): reconstruct full-file diffs locally
kirillk Aug 4, 2026
ebebad0
feat(jetbrains): authoritative full-file editor diffs with local fall…
kirillk Aug 4, 2026
41e2bf5
fix(jetbrains): correct gutter line numbers in hunk-fallback diff editor
kirillk Aug 4, 2026
f09e628
fix(cli): give DiffFull.detail a single return shape
kirillk Aug 4, 2026
324291c
Merge remote-tracking branch 'origin/main' into jetbrains-pixel-icons
kirillk Aug 4, 2026
9a4410f
fix(jetbrains): route full diffs to session directory and scope singl…
kirillk Aug 4, 2026
967126c
chore(jetbrains): fix repo-CLI dev build after upstream merge
kirillk Aug 4, 2026
4c6bcf5
fix(jetbrains): support pinned and repo CLI revert models
kirillk Aug 4, 2026
450b8a2
chore: sync bun.lock kilo-jetbrains version with package.json
kirillk Aug 5, 2026
a3160d7
fix(jetbrains): harden diff fallback handling
kirillk Aug 5, 2026
61ffa57
Merge branch 'main' into jetbrains-pixel-icons
kirillk Aug 5, 2026
7571619
Merge branch 'main' into jetbrains-pixel-icons
kirillk Aug 5, 2026
1d35159
Merge branch 'main' into jetbrains-pixel-icons
kirillk Aug 5, 2026
2e9312e
Merge remote-tracking branch 'origin/main' into jetbrains-pixel-icons
kirillk Aug 5, 2026
abfdaad
Merge remote-tracking branch 'origin/jetbrains-pixel-icons' into jetb…
kirillk Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-revert-diff-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Improve JetBrains session transcript layout, icons, reverted-change summaries, and multi-hunk diff rendering.
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,16 @@ class KiloBackendSessionManager(
files = count(files),
)

private fun revertDto(s: ai.kilocode.jetbrains.api.model.SessionRevert?) = s?.let {
revertDto(it.messageID, it.partID, it.snapshot, it.diff)
private fun revertDto(s: Any?) = when (s) {
null -> null
is ai.kilocode.jetbrains.api.model.SessionRevert -> revertDto(s.messageID, s.partID, s.snapshot, s.diff)
else -> runCatching {
val cls = s.javaClass
fun str(name: String) = cls.methods.firstOrNull { it.name == name && it.parameterCount == 0 }?.invoke(s) as? String
val message = str("getMessageID")
?: return@runCatching null.also { log.info("revertDto reflective getMessageID missing on ${cls.name}") }
revertDto(message, str("getPartID"), str("getSnapshot"), str("getDiff"))
}.onFailure { log.info("revertDto reflective decode failed for ${s.javaClass.name}: ${it.message}") }.getOrNull()
}

private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1516,14 +1516,71 @@ object KiloCliDataParser {
private fun parseRevert(obj: JsonObject?): SessionRevertDto? {
if (obj == null) return null
val message = obj.str("messageID") ?: return null
val diff = obj.str("diff")
return SessionRevertDto(
messageID = message,
partID = obj.str("partID"),
snapshot = obj.str("snapshot"),
diff = obj.str("diff"),
diff = diff,
diffs = parseUnifiedDiff(diff),
)
}

private fun parseUnifiedDiff(diff: String?): List<DiffFileDto> {
if (diff.isNullOrBlank()) return emptyList()
val lines = diff.lines()
val starts = lines.mapIndexedNotNull { index, line -> if (line.startsWith("diff --git ")) index else null }
if (starts.isEmpty()) return emptyList()
return starts.mapIndexedNotNull { index, start ->
val end = starts.getOrNull(index + 1) ?: lines.size
parseUnifiedBlock(lines.subList(start, end).joinToString("\n"))
}
}

private fun parseUnifiedBlock(block: String): DiffFileDto? {
val lines = block.lines()
val file = unifiedFile(lines) ?: return null
return DiffFileDto(
file = file,
additions = lines.count { it.startsWith("+") && !it.startsWith("+++") },
deletions = lines.count { it.startsWith("-") && !it.startsWith("---") },
patch = block,
status = unifiedStatus(lines),
)
}

private fun unifiedFile(lines: List<String>): String? {
val next = lines.firstOrNull { it.startsWith("+++ ") }?.removePrefix("+++ ")
val prev = lines.firstOrNull { it.startsWith("--- ") }?.removePrefix("--- ")
val path = sequenceOf(next, prev)
.filterNotNull()
.firstOrNull { it != "/dev/null" }
?: lines.firstOrNull()?.let(::gitDiffTarget)
return path?.let(::cleanDiffPath)
}

private fun unifiedStatus(lines: List<String>): String = when {
lines.any { it == "new file mode" || it.startsWith("new file mode ") } -> "added"
lines.any { it == "deleted file mode" || it.startsWith("deleted file mode ") } -> "deleted"
lines.any { it.startsWith("--- /dev/null") } -> "added"
lines.any { it.startsWith("+++ /dev/null") } -> "deleted"
else -> "modified"
}

private fun gitDiffTarget(line: String): String? {
val match = Regex("^diff --git a/(.*) b/(.*)$").find(line) ?: return null
return match.groupValues.getOrNull(2)
}

private fun cleanDiffPath(path: String): String {
val text = path.trim().trim('"')
return when {
text.startsWith("a/") -> text.removePrefix("a/")
text.startsWith("b/") -> text.removePrefix("b/")
else -> text
}
}

// ================================================================
// Internal — status parsing
// ================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package ai.kilocode.backend.diff

/**
* Rebuilds the full "before" content of a modified file by reverse-applying a unified diff hunk
* patch to the current working-tree content. This lets the JetBrains diff editor show a whole-file
* diff (with collapsible unchanged regions) from the limited-context patch the CLI already returns —
* no CLI change required.
*
* Added and deleted files are intentionally rejected: their patches already carry every line, so the
* frontend reconstructs those full sides directly. Binary patches and any drift between the patch's
* after side and the real file (a stale/historical turn) also return null so the caller can fall back
* to the hunk-only view instead of rendering a wrong diff.
*
* Known limitation: `\ No newline at end of file` markers are dropped rather than tracked per side, so
* the reconstructed `before` inherits the after side's trailing-newline state. When exactly one side
* lacks a trailing newline, the whole-file fallback view will not surface that EOF-newline change. This
* is cosmetic and rare (the scoped hunk view still shows the marker); tracking it per side would require
* remembering which side the marker followed.
*/
internal object DiffFullReconstruct {
private val HUNK = Regex("^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@")

fun before(after: String, patch: String?): String? {
if (patch.isNullOrBlank() || binary(patch) || added(patch) || deleted(patch)) return null

val lines = if (after.isEmpty()) emptyList() else after.split("\n")
val out = ArrayList<String>(lines.size)
var cursor = 0 // next after-line index still to emit (0-based)
var open = false
var start = 0 // 0-based after index where the current hunk begins
val afterBody = ArrayList<String>()
val beforeBody = ArrayList<String>()

fun flush(): Boolean {
if (!open) return true
if (start < cursor) return false // overlapping or out-of-order hunks
while (cursor < start) {
if (cursor >= lines.size) return false
out.add(lines[cursor]); cursor++
}
for (i in afterBody.indices) {
val idx = start + i
if (idx >= lines.size || lines[idx] != afterBody[i]) return false // working tree drifted
}
out.addAll(beforeBody)
cursor = start + afterBody.size
afterBody.clear(); beforeBody.clear()
open = false
return true
}

// git patches are newline-terminated; drop the trailing split artifact so it is not read as a
// blank context line. Real blank context lines are " " (space-prefixed), never "".
for (raw in patch.split("\n").dropLastWhile { it.isEmpty() }) {
if (raw.startsWith("@@")) {
if (!flush()) return null
val match = HUNK.find(raw) ?: return null
val newStart = match.groupValues[1].toIntOrNull() ?: return null
val newLen = match.groupValues[2].ifEmpty { "1" }.toInt()
// For a zero-length new range git reports the line preceding the removed block, so the
// removed lines are reinserted at `newStart`; otherwise the region starts at newStart-1.
start = if (newLen == 0) newStart else newStart - 1
open = true
continue
}
if (!open) continue // skip file headers (diff/index/---/+++)
if (raw.startsWith("\\")) continue // "\ No newline at end of file"
Comment thread
kirillk marked this conversation as resolved.
when (raw.firstOrNull()) {
' ' -> { val body = raw.substring(1); afterBody.add(body); beforeBody.add(body) }
'+' -> afterBody.add(raw.substring(1))
'-' -> beforeBody.add(raw.substring(1))
null -> { afterBody.add(""); beforeBody.add("") }
else -> return null
}
}
if (!flush()) return null
while (cursor < lines.size) { out.add(lines[cursor]); cursor++ }
return out.joinToString("\n")
}

fun added(patch: String): Boolean = patch.lineSequence().any { it == "--- /dev/null" }

fun deleted(patch: String): Boolean = patch.lineSequence().any { it == "+++ /dev/null" }

private fun binary(patch: String): Boolean = patch.lineSequence().any { it.startsWith("Binary files ") }
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Request
import ai.kilocode.backend.diff.DiffFullReconstruct
import java.nio.file.Files
import java.nio.file.Path

/**
* Backend implementation of [KiloSessionRpcApi].
Expand Down Expand Up @@ -155,6 +165,76 @@ class KiloSessionRpcApiImpl internal constructor(
}
}

override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? {
val patch = file.patch
if (patch.isNullOrBlank()) return null
log.info("diffSides start file=${file.file} session=${!sessionId.isNullOrBlank()} message=${!messageId.isNullOrBlank()} patch=${patch.length}")
// 1) Authoritative: a CLI with full/file support returns whole before/after from the snapshot,
// correct even for historical turns. Older CLIs ignore the params, so we detect the missing
// content and fall through to local reconstruction.
if (!sessionId.isNullOrBlank()) authoritative(sessionId, directory, file, messageId)?.let {
log.info("diffSides authoritative file=${file.file} before=${it.before?.length ?: 0} after=${it.after?.length ?: 0}")
return it
}
// 2) Fallback: read the working-tree file and reverse-apply the hunk patch to recover the whole
// "before". No CLI round-trip, so this works against any pinned CLI.
return withContext(Dispatchers.IO) {
val path = resolve(directory, file.file)
val after = path?.let { runCatching { Files.readString(it) }.getOrNull() }
val before = after?.let { DiffFullReconstruct.before(it, patch) }
log.info("diffSides fallback file=${file.file} path=${path ?: "<missing>"} after=${after?.length ?: 0} before=${before?.length ?: 0}")
if (after != null && before != null) file.copy(before = before, after = after) else null
}
}

private fun resolve(directory: String, file: String): Path? {
val direct = Path.of(directory).resolve(file).normalize()
if (Files.isRegularFile(direct)) return direct
// dev-only: a stored diff may reference another worktree (relative, or absolute into a sibling
// worktree that isn't checked out here). Re-root onto the running worktree by trying progressively
// shorter path suffixes until one exists, so full-file diffs work across dev worktrees.
val root = System.getProperty("kilo.dev.worktree.root")?.takeIf { it.isNotBlank() }?.let(Path::of) ?: return null
val segs = Path.of(file).toList()
for (i in segs.indices) {
val candidate = segs.drop(i).fold(root) { acc, seg -> acc.resolve(seg) }.normalize()
if (Files.isRegularFile(candidate)) return candidate
}
return null
}

// Ask the CLI for full before/after via GET /session/:id/diff?full=true&file=...; returns null when
// the pinned CLI lacks full/file support (it omits before/after) so the caller falls back locally.
private suspend fun authoritative(sessionId: String, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? {
val api = app.api ?: return null
return withContext(Dispatchers.IO) {
runCatching {
val url = (api.baseUrl.trimEnd('/') + "/").toHttpUrlOrNull()
?.newBuilder()
?.addPathSegment("session")
?.addPathSegment(sessionId)
?.addPathSegment("diff")
?.addQueryParameter("directory", directory)
?.addQueryParameter("full", "true")
?.addQueryParameter("file", file.file)
?.apply { if (!messageId.isNullOrBlank()) addQueryParameter("messageID", messageId) }
?.build()
?: return@runCatching null
api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
if (!response.isSuccessful) {
log.info("diffSides authoritative file=${file.file} http=${response.code} messageID=${messageId ?: "none"}")
return@runCatching null
}
val arr = Json.parseToJsonElement(response.body?.string().orEmpty()).jsonArray
val item = arr.firstOrNull { it.jsonObject["file"]?.jsonPrimitive?.contentOrNull == file.file }?.jsonObject
val before = item?.get("before")?.jsonPrimitive?.contentOrNull
val after = item?.get("after")?.jsonPrimitive?.contentOrNull
log.info("diffSides authoritative file=${file.file} items=${arr.size} matched=${item != null} before=${before?.length ?: 0} after=${after?.length ?: 0}")
if (before != null && after != null) file.copy(before = before, after = after) else null
}
}.onFailure { log.info("diffSides authoritative file=${file.file} error=${it.message}") }.getOrNull()
}
}

override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? =
ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ class KiloCliDataParserTest {
"messageID": "msg_rollback",
"partID": "prt_rollback",
"snapshot": "snap_rollback",
"diff": "diff --git a/file b/file"
"diff": "diff --git a/src/A.kt b/src/A.kt\n--- a/src/A.kt\n+++ b/src/A.kt\n@@ -1 +1,2 @@\n-old\n+new\n+more\ndiff --git a/src/Old.kt b/src/Old.kt\ndeleted file mode 100644\n--- a/src/Old.kt\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone"
}
}
}
Expand All @@ -705,6 +705,13 @@ class KiloCliDataParserTest {
assertEquals(2, result.session.summary?.files)
assertEquals("msg_rollback", result.session.revert?.messageID)
assertEquals("prt_rollback", result.session.revert?.partID)
assertEquals(2, result.session.revert?.diffs?.size)
assertEquals("src/A.kt", result.session.revert?.diffs?.get(0)?.file)
assertEquals(2, result.session.revert?.diffs?.get(0)?.additions)
assertEquals(1, result.session.revert?.diffs?.get(0)?.deletions)
assertEquals("modified", result.session.revert?.diffs?.get(0)?.status)
assertEquals("src/Old.kt", result.session.revert?.diffs?.get(1)?.file)
assertEquals("deleted", result.session.revert?.diffs?.get(1)?.status)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package ai.kilocode.backend.diff

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class DiffFullReconstructTest {

@Test
fun `reconstructs before across multiple hunks keeping unchanged regions`() {
// Ten-line file; two separated single-line edits. The patch only carries 3-context hunks, so
// the unchanged gap between them must come from the working-tree content.
val after = (1..10).joinToString("\n") { if (it == 2) "TWO" else if (it == 9) "NINE" else "l$it" } + "\n"
val patch = buildString {
append("--- a/f\n+++ b/f\n")
append("@@ -1,4 +1,4 @@\n l1\n-l2\n+TWO\n l3\n l4\n")
append("@@ -7,4 +7,4 @@\n l7\n l8\n-l9\n+NINE\n l10\n")
}

val before = DiffFullReconstruct.before(after, patch)

assertEquals((1..10).joinToString("\n") { "l$it" } + "\n", before)
}

@Test
fun `reconstructs before for a deletion-only hunk`() {
val after = "a\nc\n"
val patch = "--- a/f\n+++ b/f\n@@ -1,3 +1,2 @@\n a\n-b\n c\n"

assertEquals("a\nb\nc\n", DiffFullReconstruct.before(after, patch))
}

@Test
fun `preserves files without a trailing newline`() {
val after = "a\nB"
val patch = "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n\\ No newline at end of file\n"

assertEquals("a\nb", DiffFullReconstruct.before(after, patch))
}

@Test
fun `returns null when context does not match the working tree`() {
val patch = "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n"

assertNull(DiffFullReconstruct.before("x\nB\n", patch))
}

@Test
fun `returns null for added deleted binary and blank patches`() {
assertNull(DiffFullReconstruct.before("hello\n", "--- /dev/null\n+++ b/f\n@@ -0,0 +1 @@\n+hello\n"))
assertNull(DiffFullReconstruct.before("", "--- a/f\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone\n"))
assertNull(DiffFullReconstruct.before("x", "Binary files a/f and b/f differ\n"))
assertNull(DiffFullReconstruct.before("x", ""))
}
}
Loading
Loading