From b2f0bb0c2ba81d2753f370d1adb089d9e0493b5f Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 2 Aug 2026 13:15:45 -0400 Subject: [PATCH 001/157] fix(jetbrains): sharpen copy icon --- .changeset/smooth-copy-icon.md | 5 +++++ .../frontend/src/main/resources/icons/copy.svg | 4 ++-- .../frontend/src/main/resources/icons/copy_dark.svg | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/smooth-copy-icon.md diff --git a/.changeset/smooth-copy-icon.md b/.changeset/smooth-copy-icon.md new file mode 100644 index 00000000000..817db023967 --- /dev/null +++ b/.changeset/smooth-copy-icon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Sharpen the chat message copy icon in JetBrains. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg index 7ee8dc0f842..f73b6c541c5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg index fcbadd38270..b578567ed8a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg @@ -1,3 +1,3 @@ - - + + From d5e19d5b36e02cb6deb1575a682d6974f7e50017 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 2 Aug 2026 20:26:05 -0400 Subject: [PATCH 002/157] fix(jetbrains): update reasoning icon --- .changeset/bright-reasoning-icon.md | 5 +++++ .../frontend/src/main/resources/icons/views/brain.svg | 6 ++++-- .../frontend/src/main/resources/icons/views/brain_dark.svg | 6 ++++-- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/bright-reasoning-icon.md diff --git a/.changeset/bright-reasoning-icon.md b/.changeset/bright-reasoning-icon.md new file mode 100644 index 00000000000..7f04efb0fb2 --- /dev/null +++ b/.changeset/bright-reasoning-icon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Update the JetBrains reasoning block icon to a lightbulb shape. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg index 304a93ea9c5..72b3551622d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg index 894515662bf..38e3f872ec2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg @@ -1,3 +1,5 @@ - - + + + + From 220cdd20c8af4d4420ee0c4bf9bc7da8a0e82673 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 3 Aug 2026 17:10:43 +0200 Subject: [PATCH 003/157] fix(vscode): inset recent sessions in narrow sidebars --- .changeset/narrow-sidebar-recent.md | 5 +++++ packages/kilo-vscode/webview-ui/src/styles/chat-layout.css | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/narrow-sidebar-recent.md diff --git a/.changeset/narrow-sidebar-recent.md b/.changeset/narrow-sidebar-recent.md new file mode 100644 index 00000000000..fcd56b0f73f --- /dev/null +++ b/.changeset/narrow-sidebar-recent.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep recent sessions and the Show History action inset and usable in narrow VS Code sidebars. diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index 3e475d3ff39..d071072d90e 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -115,7 +115,10 @@ flex-direction: column; align-items: center; justify-content: center; + box-sizing: border-box; height: 100%; + width: 100%; + min-width: 0; color: var(--vscode-descriptionForeground); text-align: center; padding: 20px; From 4057d7eb387455b58c4d46d83b81ff3bc61a79a5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 3 Aug 2026 17:44:22 +0200 Subject: [PATCH 004/157] fix(vscode): reduce narrow recent view side margins --- packages/kilo-vscode/webview-ui/src/styles/chat-layout.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index d071072d90e..decab4d0f1c 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -122,6 +122,7 @@ color: var(--vscode-descriptionForeground); text-align: center; padding: 20px; + padding-inline: 12px; gap: 20px; } From b692f1ded1969165587a184e359d0848dc3e9bea Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 13:25:40 -0400 Subject: [PATCH 005/157] feat(cli): add kilocode command-file endpoints for slash command management Add GET /kilocode/command/files and POST /kilocode/command/remove, backed by a Kilo-owned discovery module that lists commands with editable file locations (and marks non-file commands as read-only built-ins). Wire the matching shared RPC contract, backend RPC implementation, DTO, and parser in the JetBrains plugin so clients can consume the endpoints. Regenerate the SDK. --- .changeset/commands-settings-jetbrains.md | 5 + .../kilocode/backend/cli/KiloCliDataParser.kt | 18 ++ .../rpc/KiloAgentBehaviorRpcApiImpl.kt | 80 +++++++++ .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 52 ++++++ .../kilocode/backend/testing/MockCliServer.kt | 9 + .../client/testing/FakeAgentBehaviorRpcApi.kt | 39 ++++ .../kilocode/rpc/KiloAgentBehaviorRpcApi.kt | 9 + .../ai/kilocode/rpc/dto/CommandFileDto.kt | 15 ++ .../opencode/src/kilocode/command-files.ts | 127 +++++++++++++ .../server/httpapi/groups/kilocode.ts | 29 +++ .../server/httpapi/handlers/kilocode.ts | 34 ++++ .../test/kilocode/command-files.test.ts | 82 +++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 79 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 69 +++++++ packages/sdk/openapi.json | 170 ++++++++++++++++++ 15 files changed, 817 insertions(+) create mode 100644 .changeset/commands-settings-jetbrains.md create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt create mode 100644 packages/opencode/src/kilocode/command-files.ts create mode 100644 packages/opencode/test/kilocode/command-files.test.ts diff --git a/.changeset/commands-settings-jetbrains.md b/.changeset/commands-settings-jetbrains.md new file mode 100644 index 00000000000..fc7b8272360 --- /dev/null +++ b/.changeset/commands-settings-jetbrains.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add kilocode command-file endpoints so clients can list editable command/workflow files and remove them. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index d18a8c0dee4..eec74b0c1cb 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -668,6 +669,23 @@ object KiloCliDataParser { ) } + fun parseAgentBehaviorCommandFiles(raw: String): List = + raw.array().mapNotNull { item -> + val obj = item.obj() ?: return@mapNotNull null + val name = obj.str("name") ?: return@mapNotNull null + val location = obj.str("location") ?: return@mapNotNull null + CommandFileDto( + name = name, + description = obj.str("description"), + source = obj.str("source"), + builtin = obj.bool("builtin"), + location = location, + editable = obj.bool("editable"), + content = obj.str("content"), + hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + ) + } + fun parseMcpStatus(raw: String): List { val root = runCatching { json.parseToJsonElement(raw) }.getOrNull() ?: return emptyList() return when (root) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt index 95185f2f204..fa006abd004 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt @@ -10,6 +10,7 @@ import ai.kilocode.rpc.KiloAgentBehaviorRpcApi import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.jetbrains.api.model.AgentBuilderSaveRequest +import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto @@ -136,6 +137,44 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun commands(directory: String) = KiloCliDataParser.parseAgentBehaviorCommands(request(directory, "/command", null)) + override suspend fun commandFiles(directory: String): List = + KiloCliDataParser.parseAgentBehaviorCommandFiles(request(directory, "/kilocode/command/files", null)) + + override suspend fun removeCommand(directory: String, location: String): Boolean = + post(directory, "/kilocode/command/remove", JsonObject(mapOf("location" to JsonPrimitive(location)))) + + override suspend fun reloadCommands(directory: String): Boolean { + LOG.info("Commands reload requested dir=$directory") + if (hasActiveSession(directory)) { + LOG.warn("Commands reload blocked by active session dir=$directory") + return false + } + runCatching { post(directory, "/instance/reload") }.onFailure { err -> + LOG.warn("Commands reload failed dir=$directory", err) + }.getOrThrow() + LOG.info("Commands reload succeeded dir=$directory") + return true + } + + override suspend fun saveCommands(directory: String, edits: Map): Boolean { + LOG.info("Commands save requested dir=$directory count=${edits.size}") + app.requireReady() + val known = knownCommands(directory) + val paths = edits.map { (location, content) -> + val path = writableCommandPath(directory, location, known) ?: return false + path to content + } + withContext(Dispatchers.IO) { + for ((path, content) in paths) { + Files.createDirectories(path.parent) + Files.writeString(path, content, StandardCharsets.UTF_8) + } + } + LOG.info("Command files saved dir=$directory count=${paths.size}") + LOG.info("Commands save reload deferred dir=$directory count=${paths.size}") + return true + } + override suspend fun mcpStatus(directory: String) = KiloCliDataParser.parseMcpStatus(request(directory, "/mcp", null)).also { items -> LOG.info("MCP status returned dir=$directory count=${items.size}") } @@ -238,6 +277,11 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return items.mapNotNull { item -> resolveEditablePath(item) }.toSet() } + private suspend fun knownCommands(directory: String): Set { + val items = commandFiles(directory) + return items.mapNotNull { item -> resolveEditableCommandPath(item) }.toSet() + } + private fun writablePath(directory: String, location: String, known: Set): Path? { val path = resolveSkillPath(location) if (path == null) { @@ -251,12 +295,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return path } + private fun writableCommandPath(directory: String, location: String, known: Set): Path? { + val path = resolveCommandPath(location) + if (path == null) { + LOG.warn("Command save rejected: invalid location dir=$directory location=$location") + return null + } + if (path in known || newCommandPath(directory, path)) return path + LOG.warn("Command save rejected: unknown command dir=$directory path=$path") + return null + } + private fun resolveEditablePath(skill: SkillDto): Path? { val path = resolveSkillPath(skill.location) ?: return null if (urlCached(path)) return null return path } + private fun resolveEditableCommandPath(command: CommandFileDto): Path? { + if (!command.editable) return null + return resolveCommandPath(command.location) + } + private fun resolveSkillPath(location: String): Path? { val raw = normalizeWorkspacePath(location) ?: return null val path = try { @@ -268,6 +328,26 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return path } + private fun resolveCommandPath(location: String): Path? { + val raw = normalizeWorkspacePath(location) ?: return null + val path = try { + Path.of(raw).normalize() + } catch (_: InvalidPathException) { + return null + } + if (!path.isAbsolute || path.fileName?.toString()?.endsWith(".md") != true) return null + return path + } + + private fun newCommandPath(directory: String, path: Path): Boolean { + val root = try { + Path.of(directory).normalize().resolve(".kilo").resolve("command").normalize() + } catch (_: InvalidPathException) { + return false + } + return path.startsWith(root) + } + private fun urlCached(path: Path): Boolean { return cacheRoots().any { root -> path.startsWith(root.resolve("kilo").resolve("skills").normalize()) } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 880e6cba4cd..514fa0d7e28 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -119,6 +119,58 @@ class KiloAgentBehaviorRpcApiImplTest { assertEquals(1, mock.requestCount("/instance/reload")) } + @Test + fun `command files and remove command call CLI endpoints`() = runBlocking { + val dir = Files.createTempDirectory("kilo-command-test") + val file = Files.createDirectories(dir.resolve("command")).resolve("review.md") + Files.writeString(file, "---\ndescription: Review code\n---\n\nReview $" + "ARGUMENTS") + mock.commandFiles = """[ + {"name":"review","description":"Review code","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review"}, + {"name":"init","source":"command","builtin":true,"location":"builtin","editable":false,"content":"Init"} + ]""".trimIndent() + val rpc = rpc() + + val commands = rpc.commandFiles("/test project") + assertEquals(listOf("review", "init"), commands.map { it.name }) + assertEquals(true, commands.single { it.name == "review" }.editable) + assertEquals(false, commands.single { it.name == "init" }.editable) + + assertTrue(rpc.removeCommand("/test project", file.toString())) + assertEquals("{\"location\":\"$file\"}", mock.lastCommandRemoveBody) + assertEquals(1, mock.requestCount("/kilocode/command/remove")) + + mock.commandRemoveStatus = 400 + val err = assertFailsWith { + rpc.removeCommand("/test", "/tmp/missing.md") + } + assertContains(err.message.orEmpty(), "HTTP 400") + + assertTrue(rpc.reloadCommands("/test project")) + assertEquals(1, mock.requestCount("/instance/reload")) + } + + @Test + fun `save commands validates known and new project command paths`() = runBlocking { + val project = Files.createTempDirectory("kilo-command-project") + val known = Files.createDirectories(project.resolve(".kilo/command")).resolve("known.md") + val added = project.resolve(".kilo/command/new.md") + val other = Files.createTempFile("kilo-command-other", ".md") + Files.writeString(known, "old") + Files.writeString(other, "old") + mock.commandFiles = """[ + {"name":"known","source":"command","builtin":false,"location":"$known","editable":true,"content":"old"} + ]""".trimIndent() + val rpc = rpc() + + assertTrue(rpc.saveCommands(project.toString(), mapOf(known.toString() to "new", added.toString() to "created"))) + assertEquals("new", Files.readString(known)) + assertEquals("created", Files.readString(added)) + assertEquals(1, mock.requestCount("/kilocode/command/files")) + + assertFalse(rpc.saveCommands(project.toString(), mapOf(other.toString() to "nope"))) + assertEquals("old", Files.readString(other)) + } + @Test fun `url cached skills are read only`() = runBlocking { val cache = Path.of(System.getProperty("user.home"), ".cache", "kilo", "skills", "remote") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index d016709a685..573ad67a46e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -68,10 +68,12 @@ class MockCliServer : AutoCloseable { @Volatile var mcpStatus = 200 @Volatile var mcpActionStatus = 200 @Volatile var agentRemoveStatus = 200 + @Volatile var commandRemoveStatus = 200 @Volatile var skillRemoveStatus = 200 @Volatile var agentBuilderStatus = 200 @Volatile var lastMcpActionPath: String? = null @Volatile var lastAgentRemoveBody: String? = null + @Volatile var lastCommandRemoveBody: String? = null @Volatile var lastSkillRemoveBody: String? = null @Volatile var lastAgentBuilderPath: String? = null @Volatile var lastAgentBuilderBody: String? = null @@ -83,11 +85,13 @@ class MockCliServer : AutoCloseable { @Volatile var providersAfterAuthPut: String? = null @Volatile var agents = "[]" @Volatile var commands = "[]" + @Volatile var commandFiles = "[]" @Volatile var skills = "[]" @Volatile var providersStatus = 200 @Volatile var providerAuthStatus = 200 @Volatile var agentsStatus = 200 @Volatile var commandsStatus = 200 + @Volatile var commandFilesStatus = 200 @Volatile var skillsStatus = 200 // File search responses @@ -382,6 +386,11 @@ class MockCliServer : AutoCloseable { lastAgentRemoveBody = body respond(output, agentRemoveStatus, if (agentRemoveStatus == 200) "true" else """{"error":"Agent not found"}""") } + bare == "/kilocode/command/files" -> respond(output, commandFilesStatus, commandFiles) + bare == "/kilocode/command/remove" && method == "POST" -> { + lastCommandRemoveBody = body + respond(output, commandRemoveStatus, if (commandRemoveStatus == 200) "true" else """{"error":"Command not found"}""") + } bare == "/kilocode/skill/remove" && method == "POST" -> { lastSkillRemoveBody = body respond(output, skillRemoveStatus, if (skillRemoveStatus == 200) "true" else """{"error":"Skill not found"}""") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index 87f13a939c4..c74b6132c04 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloAgentBehaviorRpcApi import ai.kilocode.rpc.dto.AgentCreateDto import ai.kilocode.rpc.dto.AgentDetailDto +import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto @@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.SkillDto class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var agents = emptyList() var skills = emptyList() + var commandFiles = emptyList() var mcps = emptyList() var mcpConfigs = emptyMap() val agentCalls = mutableListOf() @@ -19,6 +21,10 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { val skillRemovals = mutableListOf>() val skillReloads = mutableListOf() val skillSaves = mutableListOf>() + val commandCalls = mutableListOf() + val commandRemovals = mutableListOf>() + val commandReloads = mutableListOf() + val commandSaves = mutableListOf>() val mcpCalls = mutableListOf() val mcpConfigCalls = mutableListOf() val mcpSaves = mutableListOf>() @@ -33,6 +39,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var afterMcpConnect: (suspend (String, String) -> Unit)? = null var createError: Exception? = null var skillsError: Exception? = null + var commandFilesError: Exception? = null var removeError: Exception? = null var removeSkillError: Exception? = null var saveSkillError: Exception? = null @@ -42,6 +49,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var removeSkillResult = true var reloadSkillResult = true var saveSkillResult = true + var removeCommandResult = true + var reloadCommandResult = true + var saveCommandResult = true var mcpConnectResult = true var mcpDisconnectResult = true var mcpAuthenticateResult = true @@ -123,6 +133,35 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { return emptyList() } + override suspend fun commandFiles(directory: String): List { + assertNotEdt("agentBehavior.commandFiles") + commandFilesError?.let { throw it } + commandCalls.add(directory) + return commandFiles + } + + override suspend fun removeCommand(directory: String, location: String): Boolean { + assertNotEdt("agentBehavior.removeCommand") + commandRemovals.add(directory to location) + if (removeCommandResult) commandFiles = commandFiles.filterNot { it.location == location } + return removeCommandResult + } + + override suspend fun reloadCommands(directory: String): Boolean { + assertNotEdt("agentBehavior.reloadCommands") + commandReloads.add(directory) + return reloadCommandResult + } + + override suspend fun saveCommands(directory: String, edits: Map): Boolean { + assertNotEdt("agentBehavior.saveCommands") + for ((location, content) in edits) commandSaves.add(Triple(directory, location, content)) + if (saveCommandResult) commandFiles = commandFiles.map { command -> + edits[command.location]?.let { command.copy(content = it) } ?: command + } + return saveCommandResult + } + override suspend fun mcpStatus(directory: String): List { assertNotEdt("agentBehavior.mcpStatus") mcpStatusError?.let { throw it } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt index b13c03d526a..ac82b9de313 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt @@ -2,6 +2,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.AgentDetailDto import ai.kilocode.rpc.dto.AgentCreateDto +import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.McpServerConfigDto @@ -38,6 +39,14 @@ interface KiloAgentBehaviorRpcApi : RemoteApi { suspend fun commands(directory: String): List + suspend fun commandFiles(directory: String): List + + suspend fun removeCommand(directory: String, location: String): Boolean + + suspend fun reloadCommands(directory: String): Boolean + + suspend fun saveCommands(directory: String, edits: Map): Boolean + suspend fun mcpStatus(directory: String): List suspend fun mcpConfig(directory: String): Map diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt new file mode 100644 index 00000000000..77c4a678117 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt @@ -0,0 +1,15 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CommandFileDto( + val name: String, + val description: String? = null, + val source: String? = null, + val builtin: Boolean = false, + val location: String, + val editable: Boolean = false, + val content: String? = null, + val hints: List = emptyList(), +) diff --git a/packages/opencode/src/kilocode/command-files.ts b/packages/opencode/src/kilocode/command-files.ts new file mode 100644 index 00000000000..b1748018057 --- /dev/null +++ b/packages/opencode/src/kilocode/command-files.ts @@ -0,0 +1,127 @@ +import { readdir, readFile, unlink } from "node:fs/promises" +import path from "node:path" +import { Global } from "@opencode-ai/core/global" +import { Schema } from "effect" +import { Command } from "@/command" +import { configEntryNameFromPath } from "@/config/entry-name" +import { WorkflowsMigrator } from "@/kilocode/workflows-migrator" + +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + source: Schema.optional(Schema.String), + builtin: Schema.Boolean, + location: Schema.String, + editable: Schema.Boolean, + content: Schema.optional(Schema.String), + hints: Schema.Array(Schema.String), +}).annotate({ identifier: "CommandFile" }) + +export type Info = Schema.Schema.Type + +type File = { + name: string + location: string + content: string +} + +const COMMAND_PREFIXES = ["command/", "commands/"] + +async function files(dir: string) { + const result: File[] = [] + for (const file of await scan(path.join(dir, "command"))) result.push(await command(dir, file)) + for (const file of await scan(path.join(dir, "commands"))) result.push(await command(dir, file)) + return result +} + +async function scan(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + const result: string[] = [] + for (const entry of entries) { + const next = path.join(dir, entry.name) + if (entry.isDirectory()) result.push(...(await scan(next))) + if (entry.isFile() && entry.name.endsWith(".md")) result.push(next) + } + return result +} + +async function command(dir: string, file: string): Promise { + const content = await readFile(file, "utf8") + return { + name: configEntryNameFromPath(path.relative(dir, file), COMMAND_PREFIXES), + location: file, + content, + } +} + +function precedence(files: File[]) { + const result = new Map() + for (const file of files) result.set(file.name, file) + return result +} + +function description(cmd: Command.Info, file?: File) { + if (cmd.description) return cmd.description + if (file) return WorkflowsMigrator.extractDescription(file.content) + return undefined +} + +function literal(cmd: Command.Info) { + return typeof cmd.template === "string" ? cmd.template : undefined +} + +export async function discover(input: { commands: readonly Command.Info[]; directories: readonly string[]; directory: string }) { + const all = [] + for (const dir of input.directories) all.push(...(await files(dir))) + for (const item of await WorkflowsMigrator.discoverWorkflows(input.directory)) { + all.push({ name: item.name, location: item.path, content: item.content }) + } + const by = precedence(all) + return input.commands + .filter((cmd) => cmd.source !== "skill") + .map((cmd): Info => { + const file = by.get(cmd.name) + if (file) { + return { + name: cmd.name, + description: description(cmd, file), + source: cmd.source, + builtin: false, + location: file.location, + editable: true, + content: file.content, + hints: cmd.hints, + } + } + return { + name: cmd.name, + description: description(cmd), + source: cmd.source, + builtin: true, + location: "builtin", + editable: false, + content: literal(cmd), + hints: cmd.hints, + } + }) +} + +export function target(location: string, commands: readonly Info[]) { + if (!path.isAbsolute(location)) throw new Error("command location must be absolute") + const file = path.resolve(location) + const command = commands.find((item) => item.editable && path.resolve(item.location) === file) + if (!command) throw new Error("command not found in registry") + if (!file.endsWith(".md")) throw new Error("command location must reference a markdown file") + const cache = path.join(Global.Path.cache, "commands") + const relative = path.relative(cache, file) + if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) { + throw new Error("remove cache-backed commands from configuration") + } + return file +} + +export async function remove(location: string, commands: readonly Info[]) { + await unlink(target(location, commands)) +} + +export * as CommandFiles from "./command-files" diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index ad9a8db7783..fa48f84e9c7 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -24,6 +24,7 @@ import { } from "@/kilocode/notebook/protocol" import { ModelUsage } from "@/kilocode/session/model-usage" import { SessionID } from "@/session/schema" +import { CommandFiles } from "@/kilocode/command-files" const root = "/kilocode" @@ -31,6 +32,10 @@ export const RemoveSkillPayload = Schema.Struct({ location: Schema.String, }) +export const RemoveCommandPayload = Schema.Struct({ + location: Schema.String, +}) + export const RemoveAgentPayload = Schema.Struct({ name: Schema.String, }) @@ -47,6 +52,8 @@ export const AgentManagerRejectPayload = Schema.Struct({ error: AgentManagerFail export const KilocodePaths = { heapSnapshot: `${root}/heap/snapshot`, agentRequirements: `${root}/agent/requirements`, + commandFiles: `${root}/command/files`, + removeCommand: `${root}/command/remove`, removeSkill: `${root}/skill/remove`, removeAgent: `${root}/agent/remove`, notebookList: `${root}/notebook`, @@ -83,6 +90,28 @@ export const KilocodeApi = HttpApi.make("kilocode") description: "Check whether the selected agent's requirements are available in the request directory.", }), ), + HttpApiEndpoint.get("commandFiles", KilocodePaths.commandFiles, { + query: WorkspaceRoutingQuery, + success: described(Schema.Array(CommandFiles.Info), "Command files"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.commandFiles", + summary: "List command files", + description: "List commands with editable file locations for settings clients.", + }), + ), + HttpApiEndpoint.post("removeCommand", KilocodePaths.removeCommand, { + query: WorkspaceRoutingQuery, + payload: RemoveCommandPayload, + success: described(Schema.Boolean, "Command removed"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.removeCommand", + summary: "Remove a command", + description: "Remove a command by deleting its markdown file from disk and clearing it from cache.", + }), + ), HttpApiEndpoint.post("removeSkill", KilocodePaths.removeSkill, { query: WorkspaceRoutingQuery, payload: RemoveSkillPayload, diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index f421f5d9335..229dadf9602 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -1,8 +1,10 @@ import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as KiloAgent from "@/kilocode/agent" +import { CommandFiles } from "@/kilocode/command-files" import * as KiloSkill from "@/kilocode/skill-remove" import { Agent } from "@/agent/agent" +import { Command } from "@/command" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot" @@ -21,12 +23,14 @@ import { NotebookRejectPayload, NotebookReplyPayload, RemoveAgentPayload, + RemoveCommandPayload, RemoveSkillPayload, } from "../groups/kilocode" export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) => Effect.gen(function* () { const agents = yield* Agent.Service + const commands = yield* Command.Service const skills = yield* Skill.Service const config = yield* Config.Service const store = yield* InstanceStore.Service @@ -43,6 +47,34 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return yield* agents.requirementStatus(ctx.query.agent) }) + const commandFiles = Effect.fn("KilocodeHttpApi.commandFiles")(function* () { + const instance = yield* InstanceState.context + const dirs = yield* config.directories() + const items = yield* commands.list() + return yield* Effect.tryPromise({ + try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }), + catch: (err) => err, + }).pipe(Effect.catch((err) => Effect.die(err))) + }) + + const removeCommand = Effect.fn("KilocodeHttpApi.removeCommand")(function* (ctx: { + payload: typeof RemoveCommandPayload.Type + }) { + const instance = yield* InstanceState.context + const dirs = yield* config.directories() + const items = yield* commands.list() + const entries = yield* Effect.tryPromise({ + try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }), + catch: (err) => err, + }).pipe(Effect.catch((err) => Effect.die(err))) + yield* Effect.tryPromise({ + try: () => CommandFiles.remove(ctx.payload.location, entries), + catch: () => new HttpApiError.BadRequest({}), + }) + yield* store.dispose(instance) + return true + }) + const removeSkill = Effect.fn("KilocodeHttpApi.removeSkill")(function* (ctx: { payload: typeof RemoveSkillPayload.Type }) { @@ -136,6 +168,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return handlers .handle("heapSnapshot", heapSnapshot) .handle("agentRequirements", agentRequirements) + .handle("commandFiles", commandFiles) + .handle("removeCommand", removeCommand) .handle("removeSkill", removeSkill) .handle("removeAgent", removeAgent) .handle("notebookList", notebookList) diff --git a/packages/opencode/test/kilocode/command-files.test.ts b/packages/opencode/test/kilocode/command-files.test.ts new file mode 100644 index 00000000000..354bf749625 --- /dev/null +++ b/packages/opencode/test/kilocode/command-files.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { CommandFiles } from "../../src/kilocode/command-files" +import type { Command } from "../../src/command" + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true }))) + roots.length = 0 +}) + +async function temp() { + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-command-files-")) + roots.push(dir) + return dir +} + +function cmd(input: Partial & Pick): Command.Info { + return { + name: input.name, + description: input.description, + source: input.source, + template: input.template ?? "body", + hints: input.hints ?? [], + } +} + +describe("CommandFiles", () => { + test("discovers editable command files and read-only builtins", async () => { + const dir = await temp() + const file = path.join(dir, ".kilo", "command", "review.md") + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, "---\ndescription: Review code\n---\n\nReview $ARGUMENTS") + + const items = await CommandFiles.discover({ + directory: dir, + directories: [path.join(dir, ".kilo")], + commands: [cmd({ name: "review", source: "command", hints: ["$ARGUMENTS"] }), cmd({ name: "init", source: "command" })], + }) + + expect(items.map((item) => item.name)).toEqual(["review", "init"]) + expect(items[0]).toMatchObject({ name: "review", editable: true, builtin: false, location: file }) + expect(items[0].content).toContain("Review $ARGUMENTS") + expect(items[1]).toMatchObject({ name: "init", editable: false, builtin: true, location: "builtin" }) + }) + + test("maps legacy workflows to editable commands", async () => { + const dir = await temp() + const file = path.join(dir, ".kilo", "workflows", "ship.md") + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, "# Ship\n\nRun release checks") + + const items = await CommandFiles.discover({ + directory: dir, + directories: [path.join(dir, ".kilo")], + commands: [cmd({ name: "ship", source: "command", description: "Workflow: ship" })], + }) + + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file }) + expect(items[0].content).toBe("# Ship\n\nRun release checks") + }) + + test("remove only accepts known editable markdown files", async () => { + const dir = await temp() + const file = path.join(dir, ".kilo", "command", "ok.md") + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, "OK") + const entries = [ + { name: "ok", location: file, editable: true, builtin: false, hints: [] }, + { name: "init", location: "builtin", editable: false, builtin: true, hints: [] }, + ] + + await expect(CommandFiles.remove("builtin", entries)).rejects.toThrow("absolute") + await expect(CommandFiles.remove(path.join(dir, "other.md"), entries)).rejects.toThrow("not found") + await CommandFiles.remove(file, entries) + await expect(CommandFiles.remove(file, entries)).rejects.toThrow() + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 11c459be779..7a689c520e1 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -183,6 +183,8 @@ import type { KilocodeAgentManagerReplyResponses, KilocodeAgentRequirementsErrors, KilocodeAgentRequirementsResponses, + KilocodeCommandFilesErrors, + KilocodeCommandFilesResponses, KilocodeHeapSnapshotErrors, KilocodeHeapSnapshotResponses, KilocodeNotebookListErrors, @@ -193,6 +195,8 @@ import type { KilocodeNotebookReplyResponses, KilocodeRemoveAgentErrors, KilocodeRemoveAgentResponses, + KilocodeRemoveCommandErrors, + KilocodeRemoveCommandResponses, KilocodeRemoveSkillErrors, KilocodeRemoveSkillResponses, KilocodeSessionImportMessageErrors, @@ -8038,6 +8042,81 @@ export class Kilocode extends HeyApiClient { }) } + /** + * List command files + * + * List commands with editable file locations for settings clients. + */ + public commandFiles( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + KilocodeCommandFilesResponses, + KilocodeCommandFilesErrors, + ThrowOnError + >({ + url: "/kilocode/command/files", + ...options, + ...params, + }) + } + + /** + * Remove a command + * + * Remove a command by deleting its markdown file from disk and clearing it from cache. + */ + public removeCommand( + parameters?: { + directory?: string + workspace?: string + location?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeRemoveCommandResponses, + KilocodeRemoveCommandErrors, + ThrowOnError + >({ + url: "/kilocode/command/remove", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Remove a skill * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b95780c020a..02f843df6bb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3231,6 +3231,17 @@ export type AgentRequirementResult = { } } +export type CommandFile = { + name: string + description?: string + source?: string + builtin: boolean + location: string + editable: boolean + content?: string + hints: Array +} + export type NotebookOutput = { mime: string text?: string @@ -12538,6 +12549,64 @@ export type KilocodeAgentRequirementsResponses = { export type KilocodeAgentRequirementsResponse = KilocodeAgentRequirementsResponses[keyof KilocodeAgentRequirementsResponses] +export type KilocodeCommandFilesData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/command/files" +} + +export type KilocodeCommandFilesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeCommandFilesError = KilocodeCommandFilesErrors[keyof KilocodeCommandFilesErrors] + +export type KilocodeCommandFilesResponses = { + /** + * Command files + */ + 200: Array +} + +export type KilocodeCommandFilesResponse = KilocodeCommandFilesResponses[keyof KilocodeCommandFilesResponses] + +export type KilocodeRemoveCommandData = { + body?: { + location: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/command/remove" +} + +export type KilocodeRemoveCommandErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type KilocodeRemoveCommandError = KilocodeRemoveCommandErrors[keyof KilocodeRemoveCommandErrors] + +export type KilocodeRemoveCommandResponses = { + /** + * Command removed + */ + 200: boolean +} + +export type KilocodeRemoveCommandResponse = KilocodeRemoveCommandResponses[keyof KilocodeRemoveCommandResponses] + export type KilocodeRemoveSkillData = { body?: { location: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6e46bad83b1..fe6d6064a00 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14909,6 +14909,142 @@ ] } }, + "/kilocode/command/files": { + "get": { + "tags": ["kilocode"], + "operationId": "kilocode.commandFiles", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Command files", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommandFile" + }, + "description": "Command files" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "List commands with editable file locations for settings clients.", + "summary": "List command files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.commandFiles({\n ...\n})" + } + ] + } + }, + "/kilocode/command/remove": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.removeCommand", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Command removed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Command removed" + } + } + } + }, + "400": { + "description": "BadRequest | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + } + }, + "description": "Remove a command by deleting its markdown file from disk and clearing it from cache.", + "summary": "Remove a command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": ["location"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.removeCommand({\n ...\n})" + } + ] + } + }, "/kilocode/skill/remove": { "post": { "tags": ["kilocode"], @@ -34344,6 +34480,40 @@ "required": ["agent", "directory", "enabled", "state", "skills", "mcps", "vscode_extensions"], "additionalProperties": false }, + "CommandFile": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "type": "string" + }, + "builtin": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "editable": { + "type": "boolean" + }, + "content": { + "type": "string" + }, + "hints": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "builtin", "location", "editable", "hints"], + "additionalProperties": false + }, "NotebookOutput": { "type": "object", "properties": { From c7fd8431b8fd3d172509b895eef7e1cb2188730c Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 14:48:17 -0400 Subject: [PATCH 006/157] feat(cli): expose command metadata in command-file endpoints --- .changeset/commands-settings-jetbrains.md | 2 +- .../kilocode/backend/cli/KiloCliDataParser.kt | 12 ++++++++ .../backend/rpc/KiloWorkspaceDtoMapper.kt | 4 +++ .../backend/workspace/KiloWorkspaceState.kt | 4 +++ .../backend/cli/KiloCliDataParserTest.kt | 6 +++- .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 6 +++- .../kotlin/ai/kilocode/rpc/dto/CommandDto.kt | 4 +++ .../ai/kilocode/rpc/dto/CommandFileDto.kt | 4 +++ .../opencode/src/kilocode/command-files.ts | 12 ++++++++ .../test/kilocode/command-files.test.ts | 28 +++++++++++++++++-- packages/sdk/js/src/v2/gen/types.gen.ts | 4 +++ packages/sdk/openapi.json | 12 ++++++++ 12 files changed, 93 insertions(+), 5 deletions(-) diff --git a/.changeset/commands-settings-jetbrains.md b/.changeset/commands-settings-jetbrains.md index fc7b8272360..0c451ea9f32 100644 --- a/.changeset/commands-settings-jetbrains.md +++ b/.changeset/commands-settings-jetbrains.md @@ -2,4 +2,4 @@ "@kilocode/cli": minor --- -Add kilocode command-file endpoints so clients can list editable command/workflow files and remove them. +Add kilocode command-file endpoints so clients can list editable command/workflow files, inspect model and reasoning variant metadata, and remove them. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index eec74b0c1cb..a1d16e49db9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -631,8 +631,12 @@ object KiloCliDataParser { CommandInfo( name = obj.str("name") ?: "", description = obj.str("description"), + agent = obj.str("agent"), + model = obj.str("model"), + variant = obj.str("variant"), source = obj.str("source"), hints = obj["hints"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + subtask = obj.flagOrNull("subtask"), ) } @@ -663,9 +667,13 @@ object KiloCliDataParser { CommandDto( name = name, description = obj.str("description"), + agent = obj.str("agent"), + model = obj.str("model"), + variant = obj.str("variant"), source = obj.str("source"), hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), template = obj.str("template"), + subtask = obj.flagOrNull("subtask"), ) } @@ -677,11 +685,15 @@ object KiloCliDataParser { CommandFileDto( name = name, description = obj.str("description"), + agent = obj.str("agent"), + model = obj.str("model"), + variant = obj.str("variant"), source = obj.str("source"), builtin = obj.bool("builtin"), location = location, editable = obj.bool("editable"), content = obj.str("content"), + subtask = obj.flagOrNull("subtask"), hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt index 981ea1ad28b..ad89fb9be0f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt @@ -56,8 +56,12 @@ internal object KiloWorkspaceDtoMapper { fun command(c: CommandInfo) = CommandDto( name = c.name, description = c.description, + agent = c.agent, + model = c.model, + variant = c.variant, source = c.source, hints = c.hints, + subtask = c.subtask, ) fun skill(s: SkillInfo) = SkillDto( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 37c58d853d6..3d926aa84da 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -135,8 +135,12 @@ data class AgentInfo( data class CommandInfo( val name: String, val description: String?, + val agent: String?, + val model: String?, + val variant: String?, val source: String?, val hints: List, + val subtask: Boolean?, ) data class SkillInfo( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 93514a7a750..25f339f9a99 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1817,7 +1817,7 @@ class KiloCliDataParserTest { @Test fun `parseCommands - maps name, description, source, and hints`() { val raw = """[ - {"name":"init","description":"guided AGENTS.md setup","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command"}, + {"name":"init","description":"guided AGENTS.md setup","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command","subtask":true}, {"name":"mcp-tool","template":"","hints":["${'$'}1","${'$'}2"],"source":"mcp"} ]""" @@ -1826,8 +1826,12 @@ class KiloCliDataParserTest { assertEquals(2, result.size) assertEquals("init", result[0].name) assertEquals("guided AGENTS.md setup", result[0].description) + assertEquals("reviewer", result[0].agent) + assertEquals("anthropic/claude-sonnet-4-6", result[0].model) + assertEquals("high", result[0].variant) assertEquals("command", result[0].source) assertEquals(listOf("\$ARGUMENTS"), result[0].hints) + assertEquals(true, result[0].subtask) assertEquals("mcp", result[1].source) assertEquals(listOf("\$1", "\$2"), result[1].hints) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 514fa0d7e28..b979bc81d3b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -125,7 +125,7 @@ class KiloAgentBehaviorRpcApiImplTest { val file = Files.createDirectories(dir.resolve("command")).resolve("review.md") Files.writeString(file, "---\ndescription: Review code\n---\n\nReview $" + "ARGUMENTS") mock.commandFiles = """[ - {"name":"review","description":"Review code","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review"}, + {"name":"review","description":"Review code","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review","subtask":true}, {"name":"init","source":"command","builtin":true,"location":"builtin","editable":false,"content":"Init"} ]""".trimIndent() val rpc = rpc() @@ -133,6 +133,10 @@ class KiloAgentBehaviorRpcApiImplTest { val commands = rpc.commandFiles("/test project") assertEquals(listOf("review", "init"), commands.map { it.name }) assertEquals(true, commands.single { it.name == "review" }.editable) + assertEquals("reviewer", commands.single { it.name == "review" }.agent) + assertEquals("anthropic/claude-sonnet-4-6", commands.single { it.name == "review" }.model) + assertEquals("high", commands.single { it.name == "review" }.variant) + assertEquals(true, commands.single { it.name == "review" }.subtask) assertEquals(false, commands.single { it.name == "init" }.editable) assertTrue(rpc.removeCommand("/test project", file.toString())) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt index 4d0e6540aac..f14eab4aaa5 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt @@ -6,7 +6,11 @@ import kotlinx.serialization.Serializable data class CommandDto( val name: String, val description: String? = null, + val agent: String? = null, + val model: String? = null, + val variant: String? = null, val source: String? = null, val hints: List = emptyList(), val template: String? = null, + val subtask: Boolean? = null, ) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt index 77c4a678117..9c4267dcf03 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt @@ -6,10 +6,14 @@ import kotlinx.serialization.Serializable data class CommandFileDto( val name: String, val description: String? = null, + val agent: String? = null, + val model: String? = null, + val variant: String? = null, val source: String? = null, val builtin: Boolean = false, val location: String, val editable: Boolean = false, val content: String? = null, + val subtask: Boolean? = null, val hints: List = emptyList(), ) diff --git a/packages/opencode/src/kilocode/command-files.ts b/packages/opencode/src/kilocode/command-files.ts index b1748018057..2841994af09 100644 --- a/packages/opencode/src/kilocode/command-files.ts +++ b/packages/opencode/src/kilocode/command-files.ts @@ -9,11 +9,15 @@ import { WorkflowsMigrator } from "@/kilocode/workflows-migrator" export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + variant: Schema.optional(Schema.String), source: Schema.optional(Schema.String), builtin: Schema.Boolean, location: Schema.String, editable: Schema.Boolean, content: Schema.optional(Schema.String), + subtask: Schema.optional(Schema.Boolean), hints: Schema.Array(Schema.String), }).annotate({ identifier: "CommandFile" }) @@ -85,22 +89,30 @@ export async function discover(input: { commands: readonly Command.Info[]; direc return { name: cmd.name, description: description(cmd, file), + agent: cmd.agent, + model: cmd.model, + variant: cmd.variant, source: cmd.source, builtin: false, location: file.location, editable: true, content: file.content, + subtask: cmd.subtask, hints: cmd.hints, } } return { name: cmd.name, description: description(cmd), + agent: cmd.agent, + model: cmd.model, + variant: cmd.variant, source: cmd.source, builtin: true, location: "builtin", editable: false, content: literal(cmd), + subtask: cmd.subtask, hints: cmd.hints, } }) diff --git a/packages/opencode/test/kilocode/command-files.test.ts b/packages/opencode/test/kilocode/command-files.test.ts index 354bf749625..8e38eedf3bf 100644 --- a/packages/opencode/test/kilocode/command-files.test.ts +++ b/packages/opencode/test/kilocode/command-files.test.ts @@ -22,8 +22,12 @@ function cmd(input: Partial & Pick): Command return { name: input.name, description: input.description, + agent: input.agent, + model: input.model, + variant: input.variant, source: input.source, template: input.template ?? "body", + subtask: input.subtask, hints: input.hints ?? [], } } @@ -38,11 +42,31 @@ describe("CommandFiles", () => { const items = await CommandFiles.discover({ directory: dir, directories: [path.join(dir, ".kilo")], - commands: [cmd({ name: "review", source: "command", hints: ["$ARGUMENTS"] }), cmd({ name: "init", source: "command" })], + commands: [ + cmd({ + name: "review", + source: "command", + agent: "reviewer", + model: "anthropic/claude-sonnet-4-6", + variant: "high", + subtask: true, + hints: ["$ARGUMENTS"], + }), + cmd({ name: "init", source: "command" }), + ], }) expect(items.map((item) => item.name)).toEqual(["review", "init"]) - expect(items[0]).toMatchObject({ name: "review", editable: true, builtin: false, location: file }) + expect(items[0]).toMatchObject({ + name: "review", + editable: true, + builtin: false, + location: file, + agent: "reviewer", + model: "anthropic/claude-sonnet-4-6", + variant: "high", + subtask: true, + }) expect(items[0].content).toContain("Review $ARGUMENTS") expect(items[1]).toMatchObject({ name: "init", editable: false, builtin: true, location: "builtin" }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 64eb6aa5c4c..459fa4d8dee 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3237,11 +3237,15 @@ export type AgentRequirementResult = { export type CommandFile = { name: string description?: string + agent?: string + model?: string + variant?: string source?: string builtin: boolean location: string editable: boolean content?: string + subtask?: boolean hints: Array } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b67a8255660..11853b6aac6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -34497,6 +34497,15 @@ "description": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, "source": { "type": "string" }, @@ -34512,6 +34521,9 @@ "content": { "type": "string" }, + "subtask": { + "type": "boolean" + }, "hints": { "type": "array", "items": { From c47cfeceebcd6b2ae5c0d416bde00f7e57449df8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 14:53:48 -0400 Subject: [PATCH 007/157] fix(jetbrains): align reverted diff action --- .changeset/jetbrains-revert-diff-card.md | 5 + .../kilocode/backend/cli/KiloCliDataParser.kt | 59 +++++++- .../backend/cli/KiloCliDataParserTest.kt | 9 +- .../client/session/ui/RevertBanner.kt | 129 ++++++++++++++++-- .../session/ui/SessionMessageListPanel.kt | 1 + .../session/views/base/BaseQuestionView.kt | 16 ++- .../resources/messages/KiloBundle.properties | 1 + .../messages/KiloBundle_ar.properties | 1 + .../messages/KiloBundle_bs.properties | 1 + .../messages/KiloBundle_da.properties | 1 + .../messages/KiloBundle_de.properties | 1 + .../messages/KiloBundle_es.properties | 1 + .../messages/KiloBundle_fr.properties | 1 + .../messages/KiloBundle_ja.properties | 1 + .../messages/KiloBundle_ko.properties | 1 + .../messages/KiloBundle_nl.properties | 1 + .../messages/KiloBundle_no.properties | 1 + .../messages/KiloBundle_pl.properties | 1 + .../messages/KiloBundle_pt_BR.properties | 1 + .../messages/KiloBundle_ru.properties | 1 + .../messages/KiloBundle_th.properties | 1 + .../messages/KiloBundle_tr.properties | 1 + .../messages/KiloBundle_uk.properties | 1 + .../messages/KiloBundle_zh_CN.properties | 1 + .../messages/KiloBundle_zh_TW.properties | 1 + .../session/ui/SessionMessageListPanelTest.kt | 111 ++++++++++++++- .../views/base/BaseQuestionViewTest.kt | 9 +- .../kotlin/ai/kilocode/rpc/dto/SessionDto.kt | 1 + 28 files changed, 332 insertions(+), 27 deletions(-) create mode 100644 .changeset/jetbrains-revert-diff-card.md diff --git a/.changeset/jetbrains-revert-diff-card.md b/.changeset/jetbrains-revert-diff-card.md new file mode 100644 index 00000000000..7c779212745 --- /dev/null +++ b/.changeset/jetbrains-revert-diff-card.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show reverted-card diff actions inline with the session header and open rolled-back changes in the diff viewer. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index d18a8c0dee4..25c44b5d90b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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 { + 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? { + 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 = 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 // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 93514a7a750..ab67406dbf2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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" } } } @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index 7eb0106b7c5..5579e96a468 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -1,22 +1,32 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Dimension import javax.swing.JPanel +import javax.swing.ScrollPaneConstants class RevertBanner( private val model: SessionModel, @@ -24,15 +34,54 @@ class RevertBanner( private val redoAllAction: () -> Unit, private val cancelAction: () -> Unit, focus: (() -> Unit)? = null, + private var openDiff: SessionDiffOpener = { _, _, _ -> }, + private var sessionId: String? = null, ) : BorderLayoutPanel(), SessionView, SessionEditorStyleTarget { override val sessionViewKind = SessionView.Kind.Default + companion object { + /** Cap the reverted-file list to this many rows before scrolling. */ + const val MAX_FILE_ROWS = 10 + } + private val card = BaseQuestionView(focus = focus) + private val title = JBLabel() + + private val diff = toolbarButton( + ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer), + ).apply { + isEnabled = false + isVisible = false + } + + private val header = PartHeader().apply { + leading(JBLabel(AllIcons.Actions.Back)) + left(title) + left(PartHeader.centered(diff)) + } + private val body = Stack.vertical(UiStyle.Gap.lg()) private val files = Stack.vertical(UiStyle.Gap.xs()) + private val scroll = object : JBScrollPane( + files, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, + ) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + val cap = rowCap() + return Dimension(size.width, if (cap > 0) minOf(size.height, cap) else size.height) + } + }.apply { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + isOpaque = false + viewport.isOpaque = false + } + private val rows = LinkedHashMap() private var progress: RevertProgress? = null @@ -46,8 +95,8 @@ class RevertBanner( init { isOpaque = false - card.setHeaderIcon(AllIcons.Actions.Back, KiloBundle.message("revert.message.rollback")) - body.next(files).next(hint).next(notice) + card.setTopPanel(header) + body.next(scroll).next(hint).next(notice) card.setContent(body) card.setActions(listOf( BaseQuestionView.Action("redo", KiloBundle.message("revert.banner.redo"), primary = false) { redoAction() }, @@ -58,22 +107,33 @@ class RevertBanner( update() } + @RequiresEdt + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { + this.openDiff = openDiff + this.sessionId = sessionId + } + @RequiresEdt fun update() { val revert = model.revert() isVisible = revert != null if (revert == null) return val total = model.revertedCount() - card.setHeader(KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total)) + title.text = KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total) card.setActionVisible("all", total > 1) notice.isVisible = revert.snapshot == null - val keep = model.diff.mapTo(LinkedHashSet()) { it.file } + val diffs = resolveDiffs(revert) + val names = disambiguate(diffs.map { it.file }) + diff.isVisible = diffs.isNotEmpty() + diff.isEnabled = diffs.isNotEmpty() + val keep = diffs.mapTo(LinkedHashSet()) { it.file } rows.entries.removeIf { it.key !in keep } - val order = model.diff.map { item -> + scroll.isVisible = diffs.isNotEmpty() + val order = diffs.map { item -> val row = rows.getOrPut(item.file) { - Row(item.file) + Row(item) } - row.update(item.file, item.additions, item.deletions) + row.update(item, names[item.file] ?: item.file) row.panel } if (files.components.toList() != order) { @@ -105,15 +165,42 @@ class RevertBanner( override fun applyStyle(style: SessionEditorStyle) { card.applyStyle(style) + title.font = style.headerFont + title.foreground = UiStyle.Colors.fg() progress?.applyStyle(style) hint.foreground = UIUtil.getLabelForeground() notice.foreground = UIUtil.getContextHelpForeground() rows.values.forEach { it.applyStyle() } } - private class Row(file: String) { - private val label = JBLabel(file) - private val badge = DiffStatBadge(0, 0) + /** + * The rolled-back diff to render. Prefer the snapshot diff the CLI attaches to the revert; fall + * back to the session's current file diff when the pinned CLI doesn't provide it. Empty when no + * snapshot exists (files were not restored, so there is nothing to diff). + */ + private fun resolveDiffs(revert: ai.kilocode.rpc.dto.SessionRevertDto): List = + if (revert.snapshot == null) emptyList() else revert.diffs.ifEmpty { model.diff } + + private fun openDiffViewer() { + val revert = model.revert() ?: return + val diffs = resolveDiffs(revert) + if (diffs.isEmpty()) return + openDiff(diffs, KiloBundle.message("revert.banner.openDiff.title"), "revert:${sessionId ?: "pending"}:${revert.messageID}") + } + + /** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */ + private fun rowCap(): Int { + val comps = files.components + if (comps.size <= MAX_FILE_ROWS) return 0 + val gap = UiStyle.Gap.xs() + return (0 until MAX_FILE_ROWS).sumOf { comps[it].preferredSize.height } + gap * (MAX_FILE_ROWS - 1) + } + + private class Row(item: DiffFileDto) { + private val label = JBLabel(item.file).apply { + toolTipText = item.file + } + private val badge = DiffStatBadge(item.additions, item.deletions) val panel: JPanel = Stack.horizontal(UiStyle.Gap.sm()) .next(label) .next(badge) @@ -122,9 +209,10 @@ class RevertBanner( applyStyle() } - fun update(file: String, additions: Int, deletions: Int) { - if (label.text != file) label.text = file - badge.update(additions, deletions) + fun update(item: DiffFileDto, text: String) { + if (label.text != text) label.text = text + if (label.toolTipText != item.file) label.toolTipText = item.file + badge.update(item.additions, item.deletions) } fun applyStyle() { @@ -132,3 +220,18 @@ class RevertBanner( } } } + +internal fun disambiguate(paths: List): Map { + val parts = paths.associateWith { split(it) } + return paths.groupBy { parts[it]?.lastOrNull().orEmpty() }.values.flatMap { group -> + if (group.size == 1) return@flatMap listOf(group.first() to (parts[group.first()]?.lastOrNull() ?: group.first())) + val depth = (1..(group.maxOf { parts[it]?.size ?: 1 })).firstOrNull { count -> + group.map { suffix(parts[it].orEmpty(), count) }.toSet().size == group.size + } ?: group.maxOf { parts[it]?.size ?: 1 } + group.map { it to suffix(parts[it].orEmpty(), depth) } + }.toMap() +} + +private fun split(path: String): List = path.split('/', '\\').filter { it.isNotEmpty() } + +private fun suffix(parts: List, depth: Int): String = parts.takeLast(depth).joinToString("/") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 5f60ef388b3..8e6edd1ed6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -214,6 +214,7 @@ class SessionMessageListPanel( fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { this.openDiff = openDiff this.sessionId = sessionId + banner?.setDiffOpener(openDiff, sessionId) turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 5d39e41feb1..113a1c7b924 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -93,7 +93,9 @@ class BaseQuestionView( } private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true) - private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false) + private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false).apply { + isVisible = false + } private var top: JComponent? = null private var content: JComponent? = null @@ -131,6 +133,7 @@ class BaseQuestionView( fun setHeader(text: String, description: String? = null) { headerText.text = text setDescription(description) + syncNorth() } /** @@ -141,6 +144,7 @@ class BaseQuestionView( fun setDescription(text: String?) { descriptionText.text = text ?: "" descriptionText.isVisible = !text.isNullOrBlank() + syncNorth() } // ---- public slot API ---- @@ -170,8 +174,7 @@ class BaseQuestionView( if (icon == null && attached) header.remove(this.icon) this.icon.revalidate() this.icon.repaint() - header.revalidate() - header.repaint() + syncNorth() } /** @@ -297,12 +300,14 @@ class BaseQuestionView( private fun syncNorth() { north.removeAll() top?.let { north.next(it) } - north.next(header) + if (hasHeader()) north.next(header) if (content != null) north.fill(gap) north.revalidate() north.repaint() } + private fun hasHeader() = icon.icon != null || headerText.text.isNotBlank() || descriptionText.isVisible + private fun syncFooter() { val layout = footer.layout as BorderLayout val west = layout.getLayoutComponent(BorderLayout.WEST) @@ -331,7 +336,8 @@ class BaseQuestionView( private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { val area = object : JBTextArea(value) { - override fun getPreferredSize() = withWidth(super.getPreferredSize().height) + override fun getPreferredSize() = + withWidth(super.getPreferredSize().height) override fun getMaximumSize(): Dimension { val size = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 56e2bbb3ef6..da8ff8a09ac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -44,6 +44,7 @@ revert.banner.redo=Redo revert.banner.redo.all=Redo All revert.banner.hint=You can redo these changes until you send a new message revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.message.rollback=Rollback to this message session.status.rollingback=Rolling back\u2026 session.status.redoing=Redoing\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 15944a645df..5d1fc08ba83 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=تعديل مسار الم settings.agentBehavior.skills.sources.editUrl.title=تعديل URL المهارات session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=تم التراجع عن رسالة واحدة revert.banner.count.other=تم التراجع عن {0} رسائل revert.banner.redo=إعادة diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index df5882b5000..d857e0f611d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Uredi putanju vještina settings.agentBehavior.skills.sources.editUrl.title=Uredi URL vještina session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} poruka vraćena revert.banner.count.other={0} poruka vraćeno revert.banner.redo=Ponovi diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 65d9d0909cd..a75a3b5239c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger færdighedssti settings.agentBehavior.skills.sources.editUrl.title=Rediger færdigheds-URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} besked rullet tilbage revert.banner.count.other={0} beskeder rullet tilbage revert.banner.redo=Gentag diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 9b0ca266323..3d4cd77b63a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Skill-Pfad bearbeiten settings.agentBehavior.skills.sources.editUrl.title=Skill-URL bearbeiten session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} Nachricht zurückgesetzt revert.banner.count.other={0} Nachrichten zurückgesetzt revert.banner.redo=Wiederholen diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 0d19e12cfd9..d9243cafa2f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar ruta de habilidades settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mensaje revertido revert.banner.count.other={0} mensajes revertidos revert.banner.redo=Rehacer diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 683bf55d439..ae0c82fff40 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Modifier le chemin des comp settings.agentBehavior.skills.sources.editUrl.title=Modifier l’URL des compétences session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} message annulé revert.banner.count.other={0} messages annulés revert.banner.redo=Rétablir diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index e9e1c52f3cb..04da2eb8463 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=スキルパスを編集 settings.agentBehavior.skills.sources.editUrl.title=スキル URL を編集 session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} 件のメッセージをロールバックしました revert.banner.count.other={0} 件のメッセージをロールバックしました revert.banner.redo=やり直し diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index c2e935a4ed7..42e0dcad68d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=스킬 경로 편집 settings.agentBehavior.skills.sources.editUrl.title=스킬 URL 편집 session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0}개 메시지가 롤백됨 revert.banner.count.other={0}개 메시지가 롤백됨 revert.banner.redo=다시 실행 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e43ba741bd8..193c71cc7ba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Vaardigheidspad bewerken settings.agentBehavior.skills.sources.editUrl.title=Vaardigheids-URL bewerken session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} bericht teruggedraaid revert.banner.count.other={0} berichten teruggedraaid revert.banner.redo=Opnieuw uitvoeren diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index d391bcc32a3..92b9a8f79e8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger ferdighetssti settings.agentBehavior.skills.sources.editUrl.title=Rediger ferdighets-URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} melding rullet tilbake revert.banner.count.other={0} meldinger rullet tilbake revert.banner.redo=Gjør om diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index ef4c5efe137..94a3cba2758 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Edytuj ścieżkę umiejętn settings.agentBehavior.skills.sources.editUrl.title=Edytuj URL umiejętności session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Cofnięto {0} wiadomość revert.banner.count.other=Cofnięto {0} wiadomości revert.banner.redo=Ponów diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 2bf9c4c4cce..3d31ef77333 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar caminho de habilidad settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mensagem revertida revert.banner.count.other={0} mensagens revertidas revert.banner.redo=Refazer diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index aef654244b3..31af59b74c3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Изменить путь settings.agentBehavior.skills.sources.editUrl.title=Изменить URL навыков session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Отменено сообщений: {0} revert.banner.count.other=Отменено сообщений: {0} revert.banner.redo=Повторить diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 8959598aa7a..49dedd7bcbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=แก้ไขเส้น settings.agentBehavior.skills.sources.editUrl.title=แก้ไข URL ทักษะ session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=ย้อนกลับข้อความ {0} รายการแล้ว revert.banner.count.other=ย้อนกลับข้อความ {0} รายการแล้ว revert.banner.redo=ทำซ้ำ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index b954188a5ca..221755be455 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Beceri Yolunu Düzenle settings.agentBehavior.skills.sources.editUrl.title=Beceri URL'sini Düzenle session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mesaj geri alındı revert.banner.count.other={0} mesaj geri alındı revert.banner.redo=Yinele diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 77ea64c7752..bbe9ae6b190 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Редагувати шля settings.agentBehavior.skills.sources.editUrl.title=Редагувати URL навичок session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Відкочено повідомлень: {0} revert.banner.count.other=Відкочено повідомлень: {0} revert.banner.redo=Повторити diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 909073cae3e..bb29932afc0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=编辑技能路径 settings.agentBehavior.skills.sources.editUrl.title=编辑技能 URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=已回滚 {0} 条消息 revert.banner.count.other=已回滚 {0} 条消息 revert.banner.redo=重做 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index bc74d81c93a..dc251b70f45 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=編輯技能路徑 settings.agentBehavior.skills.sources.editUrl.title=編輯技能 URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=已回復 {0} 則訊息 revert.banner.count.other=已回復 {0} 則訊息 revert.banner.redo=重做 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index ab7f800934b..3e23202907b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -15,6 +15,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView @@ -29,6 +30,7 @@ import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.client.ui.DiffStatBadge 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.MessageDto @@ -60,6 +62,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.RepaintManager +import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities import javax.swing.border.Border @@ -1080,8 +1083,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { banner.update() assertNotNull(find(banner)) + assertNotNull(components(banner).filterIsInstance().singleOrNull()) - val buttons = components(banner).filterIsInstance() + val buttons = components(banner).filterIsInstance().filter { it.text.isNotEmpty() } assertEquals( listOf(KiloBundle.message("revert.banner.redo"), KiloBundle.message("revert.banner.redo.all")), buttons.map { it.text }, @@ -1098,7 +1102,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { fun `test rollback banner reuses file rows across updates`() { val banner = RevertBanner(model, {}, {}, {}) model.upsertMessage(msg("u1", "user")) - model.setRevert(SessionRevertDto("u1")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0), DiffFileDto("src/B.kt", 2, 1))) banner.update() val rows = components(banner).filterIsInstance().filter { stack -> @@ -1122,6 +1126,102 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("-2", badges[1].removedLabelForTest().text) } + fun `test rollback banner caps file list with scroll pane`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff((1..80).map { DiffFileDto("src/file-$it.kt", it, 0) }) + + banner.update() + + val scroll = components(banner).filterIsInstance().single() + assertTrue(scroll.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED) + assertTrue(scroll.horizontalScrollBarPolicy == ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED) + val rows = rowLabels(banner).mapNotNull { it.parent } + val rowHeight = rows.first().preferredSize.height + val cap = rowHeight * RevertBanner.MAX_FILE_ROWS + UiStyle.Gap.xs() * (RevertBanner.MAX_FILE_ROWS - 1) + assertEquals(cap, scroll.preferredSize.height) + } + + fun `test rollback banner shortens duplicate file names with parents`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf( + DiffFileDto("apps/main/src/App.kt", 1, 0), + DiffFileDto("packages/ui/src/App.kt", 2, 1), + DiffFileDto("packages/ui/src/Button.kt", 3, 0), + )) + + banner.update() + + val labels = rowLabels(banner).map { it.text to it.toolTipText } + assertTrue(labels.contains("main/src/App.kt" to "apps/main/src/App.kt")) + assertTrue(labels.contains("ui/src/App.kt" to "packages/ui/src/App.kt")) + assertTrue(labels.contains("Button.kt" to "packages/ui/src/Button.kt")) + } + + fun `test rollback banner opens rolled back diff`() { + val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") + val opened = mutableListOf>() + val titles = mutableListOf() + val keys = mutableListOf() + val banner = RevertBanner(model, {}, {}, {}) + banner.setDiffOpener({ files, title, key -> + opened.add(files) + titles.add(title) + keys.add(key) + }, "ses_1") + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1", diffs = listOf(diff))) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertTrue(button.isVisible) + assertTrue(button.isEnabled) + button.doClick() + + assertEquals(listOf(diff), opened.single()) + assertEquals(KiloBundle.message("revert.banner.openDiff.title"), titles.single()) + assertEquals("revert:ses_1:u1", keys.single()) + } + + fun `test rollback banner hides open diff without a snapshot`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = null)) + model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0, PATCH))) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertFalse(button.isVisible) + assertFalse(button.isEnabled) + } + + fun `test rollback banner opens session diff when revert diff is absent`() { + val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") + val opened = mutableListOf>() + val banner = RevertBanner(model, {}, {}, {}) + banner.setDiffOpener({ files, _, _ -> opened.add(files) }, "ses_1") + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf(diff)) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertTrue(button.isVisible) + assertTrue(button.isEnabled) + button.doClick() + + assertEquals(listOf(diff), opened.single()) + } + fun `test rollback banner shows redo all only for multiple reverted messages`() { val banner = RevertBanner(model, {}, {}, {}) model.upsertMessage(msg("u1", "user")) @@ -1168,7 +1268,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { banner.setReverting(SessionState.Reverting("Rolling back...", SessionState.Reverting.Kind.ROLLBACK, "u1")) - val buttons = components(banner).filterIsInstance() + val buttons = components(banner).filterIsInstance().filter { it.text.isNotEmpty() } assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo") }.all { !it.isEnabled }) assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo.all") }.all { !it.isEnabled }) val progress = components(banner).filterIsInstance().single() @@ -1521,6 +1621,11 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { return out } + private fun rowLabels(root: Component): List = components(root) + .filterIsInstance() + .filter { stack -> stack.components.any { it is DiffStatBadge } } + .mapNotNull { stack -> components(stack).filterIsInstance().firstOrNull() } + private fun taskText(view: TaskToolView): List { val scroll = components(view).filterIsInstance().single() val stack = components(scroll.viewport.view).filterIsInstance().single() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 6018178065f..9f38bca3202 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -21,12 +21,11 @@ class BaseQuestionViewTest : BasePlatformTestCase() { // ------ initial state ------ - fun `test header and description text areas are in the component tree by default`() { + fun `test empty card does not render a header row by default`() { edt { val panel = BaseQuestionView() assertTrue("Root layout should be BorderLayout", panel.layout is BorderLayout) - val areas = findAll(panel) - assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2) + assertNull("Header row should be omitted until header content exists", headerRow(panel)) } } @@ -87,6 +86,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { fun `test setTopPanel adds component before header`() { edt { val panel = BaseQuestionView() + panel.setHeader("Title") val top = JLabel("top") panel.setTopPanel(top) @@ -280,9 +280,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } - fun `test header row has no west icon gap by default`() { + fun `test header row has no west icon gap without icon`() { edt { val panel = BaseQuestionView() + panel.setHeader("Title") val header = headerRow(panel)!! val west = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) assertNull("header should not reserve icon space when icon is absent", west) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt index 82818fccb49..26c54b7d080 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt @@ -21,6 +21,7 @@ data class SessionRevertDto( val partID: String? = null, val snapshot: String? = null, val diff: String? = null, + val diffs: List = emptyList(), ) @Serializable From 9b4738ee55fc2c84ee2f8c7bbeb0ebd98bb69dbf Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:26:01 -0400 Subject: [PATCH 008/157] fix(jetbrains): load reverted session diffs --- .../jetbrains-reverted-session-diff-list.md | 5 +++ .../session/controller/SessionController.kt | 21 ++++++++++ .../controller/RevertDiffLoadingTest.kt | 38 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 .changeset/jetbrains-reverted-session-diff-list.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt diff --git a/.changeset/jetbrains-reverted-session-diff-list.md b/.changeset/jetbrains-reverted-session-diff-list.md new file mode 100644 index 00000000000..4f9ce88e550 --- /dev/null +++ b/.changeset/jetbrains-reverted-session-diff-list.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show the rolled-back file list when reopening a session whose last message was reverted. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 1370c72aa16..22bea5de8e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1011,6 +1011,7 @@ class SessionController( } } recoverPending(id) + seedRevertDiff(id) runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -1060,6 +1061,7 @@ class SessionController( } } recoverPending(session.id) + seedRevertDiff(session.id) runEdt { if (disposed) return@runEdt subscribeEvents() @@ -1091,6 +1093,25 @@ class SessionController( } } + /** + * Seed [SessionModel.diff] when opening a reverted session. The rolled-back file list in + * [ai.kilocode.client.session.ui.RevertBanner] falls back to `model.diff` when the CLI does not + * attach a diff to the revert marker. On a live revert a `session.diff` event seeds that; on + * reload nothing does, so fetch the persisted session diff once here. Skipped for sessions + * without a revert or once a diff is already present (e.g. a concurrent `session.diff` event). + */ + private suspend fun seedRevertDiff(id: String) { + var fetch = false + runEdt { fetch = !disposed && sid == id && model.revert() != null && model.diff.isEmpty() } + if (!fetch) return + val diffs = runCatching { sessions.diff(id, directory) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: return + runEdt { + if (disposed || sid != id) return@runEdt + if (model.revert() == null || model.diff.isNotEmpty()) return@runEdt + updateModel { model.setDiff(diffs) } + } + } + private fun startSessionLoading(token: SessionLoadState.Loading) { assertEdt() setSessionLoadState(token) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt new file mode 100644 index 00000000000..e2d831baa19 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt @@ -0,0 +1,38 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.SessionRevertDto + +/** + * A session reverted in a previous run has no `session.diff` event to replay on open, and the + * pinned CLI may not attach a diff to the revert marker. [SessionController.seedRevertDiff] fetches + * the persisted session diff so the reverted-files banner has something to render. + */ +class RevertDiffLoadingTest : SessionControllerTestBase() { + + fun `test opening a reverted session seeds model diff from the diff rpc`() { + rpc.session = session("ses_test").copy(revert = SessionRevertDto(messageID = "msg1", snapshot = "snap")) + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + rpc.history.add(MessageWithPartsDto(msg("msg2", "ses_test", "assistant"), emptyList())) + rpc.diffs["ses_test"] = mutableListOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")) + + val c = controller("ses_test") + flush() + + assertEquals("msg1", c.model.revert()?.messageID) + assertEquals(listOf("src/A.kt"), c.model.diff.map { it.file }) + } + + fun `test opening a session without a revert does not fetch the diff`() { + rpc.session = session("ses_test") + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + rpc.diffs["ses_test"] = mutableListOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")) + + val c = controller("ses_test") + flush() + + assertNull(c.model.revert()) + assertTrue(c.model.diff.isEmpty()) + } +} From fa9f4a5a5260b9dd9a2eb4c5e091181f88b8799a Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:32:39 -0400 Subject: [PATCH 009/157] fix(cli): align command file management with runtime discovery --- .../kilocode/backend/cli/KiloCliDataParser.kt | 10 +++ .../rpc/KiloAgentBehaviorRpcApiImpl.kt | 66 ++++++++++++++----- .../backend/cli/KiloCliDataParserTest.kt | 2 + .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 16 ++++- .../kilocode/backend/testing/MockCliServer.kt | 2 +- .../opencode/src/kilocode/command-files.ts | 19 ++---- .../test/kilocode/command-files.test.ts | 40 ++++++++++- 7 files changed, 122 insertions(+), 33 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index a1d16e49db9..919dc4b74bc 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -743,6 +743,16 @@ object KiloCliDataParser { return if (prim.isString) prim.content else null } + fun parsePathConfig(raw: String): String? { + val prim = runCatching { tryParseObject(raw)?.get("config")?.jsonPrimitive }.getOrNull() ?: return null + return if (prim.isString) prim.content else null + } + + fun parsePathHome(raw: String): String? { + val prim = runCatching { tryParseObject(raw)?.get("home")?.jsonPrimitive }.getOrNull() ?: return null + return if (prim.isString) prim.content else null + } + fun parseModelState(raw: String): ModelStateDto { val obj = tryParseObject(raw) ?: return ModelStateDto() return ModelStateDto( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt index fa006abd004..244e5b2f2da 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt @@ -76,7 +76,7 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun reloadSkills(directory: String): Boolean { LOG.info("Skills reload requested dir=$directory") - if (hasActiveSession(directory)) { + if (hasActiveSession(directory, "Skills")) { LOG.warn("Skills reload blocked by active session dir=$directory") return false } @@ -145,7 +145,7 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun reloadCommands(directory: String): Boolean { LOG.info("Commands reload requested dir=$directory") - if (hasActiveSession(directory)) { + if (hasActiveSession(directory, "Commands")) { LOG.warn("Commands reload blocked by active session dir=$directory") return false } @@ -160,8 +160,9 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = LOG.info("Commands save requested dir=$directory count=${edits.size}") app.requireReady() val known = knownCommands(directory) + val roots = commandRoots(directory) val paths = edits.map { (location, content) -> - val path = writableCommandPath(directory, location, known) ?: return false + val path = writableCommandPath(directory, location, known, roots) ?: return false path to content } withContext(Dispatchers.IO) { @@ -232,24 +233,24 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return true } - private fun hasActiveSession(directory: String): Boolean { + private fun hasActiveSession(directory: String, label: String): Boolean { val active = app.sessions.statuses.value.filterValues { it.type != "idle" } if (active.isNotEmpty()) { - LOG.info("Skills reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}") + LOG.info("$label reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}") return true } val permissions = runCatching { app.chat.pendingPermissions(directory) }.onFailure { err -> - LOG.warn("Skills reload pending permission check failed dir=$directory", err) + LOG.warn("$label reload pending permission check failed dir=$directory", err) }.getOrDefault(emptyList()) if (permissions.isNotEmpty()) { - LOG.info("Skills reload pending permissions dir=$directory count=${permissions.size}") + LOG.info("$label reload pending permissions dir=$directory count=${permissions.size}") return true } val questions = runCatching { app.chat.pendingQuestions(directory) }.onFailure { err -> - LOG.warn("Skills reload pending question check failed dir=$directory", err) + LOG.warn("$label reload pending question check failed dir=$directory", err) }.getOrDefault(emptyList()) if (questions.isNotEmpty()) { - LOG.info("Skills reload pending questions dir=$directory count=${questions.size}") + LOG.info("$label reload pending questions dir=$directory count=${questions.size}") return true } return false @@ -295,13 +296,13 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return path } - private fun writableCommandPath(directory: String, location: String, known: Set): Path? { + private fun writableCommandPath(directory: String, location: String, known: Set, roots: Set): Path? { val path = resolveCommandPath(location) if (path == null) { LOG.warn("Command save rejected: invalid location dir=$directory location=$location") return null } - if (path in known || newCommandPath(directory, path)) return path + if (path in known || newCommandPath(path, roots)) return path LOG.warn("Command save rejected: unknown command dir=$directory path=$path") return null } @@ -339,13 +340,46 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return path } - private fun newCommandPath(directory: String, path: Path): Boolean { - val root = try { - Path.of(directory).normalize().resolve(".kilo").resolve("command").normalize() + private suspend fun commandRoots(directory: String): Set = buildSet { + addProjectCommandRoots(this, directory) + val paths = runCatching { request(directory, "/path", null) }.getOrNull() + val config = paths?.let(KiloCliDataParser::parsePathConfig) + if (config != null) addConfigCommandRoots(this, config) + val home = paths?.let(KiloCliDataParser::parsePathHome) + if (home != null) addHomeCommandRoots(this, home) + } + + private fun addProjectCommandRoots(roots: MutableSet, dir: String) { + val base = try { + Path.of(dir).normalize() } catch (_: InvalidPathException) { - return false + return } - return path.startsWith(root) + for (cfg in listOf(".kilo", ".kilocode")) { + for (name in listOf("command", "commands")) roots.add(base.resolve(cfg).resolve(name).normalize()) + } + } + + private fun addConfigCommandRoots(roots: MutableSet, dir: String) { + val base = try { + Path.of(dir).normalize() + } catch (_: InvalidPathException) { + return + } + for (name in listOf("command", "commands")) roots.add(base.resolve(name).normalize()) + } + + private fun addHomeCommandRoots(roots: MutableSet, home: String) { + val base = try { + Path.of(home).normalize() + } catch (_: InvalidPathException) { + return + } + for (cfg in listOf(".kilo", ".kilocode")) addConfigCommandRoots(roots, base.resolve(cfg).toString()) + } + + private fun newCommandPath(path: Path, roots: Set): Boolean { + return roots.any { root -> path.startsWith(root) } } private fun urlCached(path: Path): Boolean { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 25f339f9a99..f6b7be9cbc3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1882,6 +1882,8 @@ class KiloCliDataParserTest { fun `parsePathState - extracts state from valid path response`() { val raw = """{"home":"/home/user","state":"/home/user/.local/state/kilo","config":"/home/user/.config/kilo","worktree":"/project","directory":"/project"}""" assertEquals("/home/user/.local/state/kilo", KiloCliDataParser.parsePathState(raw)) + assertEquals("/home/user/.config/kilo", KiloCliDataParser.parsePathConfig(raw)) + assertEquals("/home/user", KiloCliDataParser.parsePathHome(raw)) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index b979bc81d3b..6daa8b0096f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -157,7 +157,7 @@ class KiloAgentBehaviorRpcApiImplTest { fun `save commands validates known and new project command paths`() = runBlocking { val project = Files.createTempDirectory("kilo-command-project") val known = Files.createDirectories(project.resolve(".kilo/command")).resolve("known.md") - val added = project.resolve(".kilo/command/new.md") + val added = project.resolve(".kilo/commands/new.md") val other = Files.createTempFile("kilo-command-other", ".md") Files.writeString(known, "old") Files.writeString(other, "old") @@ -175,6 +175,20 @@ class KiloAgentBehaviorRpcApiImplTest { assertEquals("old", Files.readString(other)) } + @Test + fun `save commands validates new global command paths`() = runBlocking { + val project = Files.createTempDirectory("kilo-command-project") + val config = Files.createTempDirectory("kilo-command-config") + val added = config.resolve("commands/global.md") + mock.path = """{"home":"/tmp","state":"/tmp","config":"$config","worktree":"$project","directory":"$project"}""" + val rpc = rpc() + + assertTrue(rpc.saveCommands(project.toString(), mapOf(added.toString() to "global command"))) + + assertEquals("global command", Files.readString(added)) + assertEquals(1, mock.requestCount("/path")) + } + @Test fun `url cached skills are read only`() = runBlocking { val cache = Path.of(System.getProperty("user.home"), ".cache", "kilo", "skills", "remote") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 573ad67a46e..cf60691cfc5 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -372,7 +372,7 @@ class MockCliServer : AutoCloseable { respond(output, organizationSetStatus, "true") } path == "/global/event" -> handleSse(output, latch) - path == "/path" -> respond(output, 200, this.path) + bare == "/path" -> respond(output, 200, this.path) bare == "/provider" -> respond(output, providersStatus, providers) bare == "/provider/auth" -> respond(output, providerAuthStatus, providerAuth) bare == "/agent" -> respond(output, agentsStatus, agents) diff --git a/packages/opencode/src/kilocode/command-files.ts b/packages/opencode/src/kilocode/command-files.ts index 2841994af09..35496d2b472 100644 --- a/packages/opencode/src/kilocode/command-files.ts +++ b/packages/opencode/src/kilocode/command-files.ts @@ -1,6 +1,7 @@ -import { readdir, readFile, unlink } from "node:fs/promises" +import { readFile, unlink } from "node:fs/promises" import path from "node:path" import { Global } from "@opencode-ai/core/global" +import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { Command } from "@/command" import { configEntryNameFromPath } from "@/config/entry-name" @@ -33,18 +34,8 @@ const COMMAND_PREFIXES = ["command/", "commands/"] async function files(dir: string) { const result: File[] = [] - for (const file of await scan(path.join(dir, "command"))) result.push(await command(dir, file)) - for (const file of await scan(path.join(dir, "commands"))) result.push(await command(dir, file)) - return result -} - -async function scan(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) - const result: string[] = [] - for (const entry of entries) { - const next = path.join(dir, entry.name) - if (entry.isDirectory()) result.push(...(await scan(next))) - if (entry.isFile() && entry.name.endsWith(".md")) result.push(next) + for (const file of await Glob.scan("{command,commands}/**/*.md", { cwd: dir, absolute: true, dot: true, symlink: true })) { + result.push(await command(dir, file)) } return result } @@ -76,10 +67,10 @@ function literal(cmd: Command.Info) { export async function discover(input: { commands: readonly Command.Info[]; directories: readonly string[]; directory: string }) { const all = [] - for (const dir of input.directories) all.push(...(await files(dir))) for (const item of await WorkflowsMigrator.discoverWorkflows(input.directory)) { all.push({ name: item.name, location: item.path, content: item.content }) } + for (const dir of input.directories) all.push(...(await files(dir))) const by = precedence(all) return input.commands .filter((cmd) => cmd.source !== "skill") diff --git a/packages/opencode/test/kilocode/command-files.test.ts b/packages/opencode/test/kilocode/command-files.test.ts index 8e38eedf3bf..1a183f23fe4 100644 --- a/packages/opencode/test/kilocode/command-files.test.ts +++ b/packages/opencode/test/kilocode/command-files.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { CommandFiles } from "../../src/kilocode/command-files" @@ -88,6 +88,44 @@ describe("CommandFiles", () => { expect(items[0].content).toBe("# Ship\n\nRun release checks") }) + test("prefers command file attribution over same-named legacy workflow", async () => { + const dir = await temp() + const workflow = path.join(dir, ".kilo", "workflows", "ship.md") + const file = path.join(dir, ".kilo", "command", "ship.md") + await mkdir(path.dirname(workflow), { recursive: true }) + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(workflow, "# Legacy Ship") + await writeFile(file, "# Command Ship") + + const items = await CommandFiles.discover({ + directory: dir, + directories: [path.join(dir, ".kilo")], + commands: [cmd({ name: "ship", source: "command" })], + }) + + expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file }) + expect(items[0].content).toBe("# Command Ship") + }) + + test("discovers symlinked command files", async () => { + const dir = await temp() + const real = path.join(dir, "linked", "review.md") + const link = path.join(dir, ".kilo", "command", "review.md") + await mkdir(path.dirname(real), { recursive: true }) + await mkdir(path.dirname(link), { recursive: true }) + await writeFile(real, "Review from symlink") + await symlink(real, link) + + const items = await CommandFiles.discover({ + directory: dir, + directories: [path.join(dir, ".kilo")], + commands: [cmd({ name: "review", source: "command" })], + }) + + expect(items[0]).toMatchObject({ name: "review", editable: true, builtin: false, location: link }) + expect(items[0].content).toBe("Review from symlink") + }) + test("remove only accepts known editable markdown files", async () => { const dir = await temp() const file = path.join(dir, ".kilo", "command", "ok.md") From 6eba81785718bfb3af579877fb332336f3a6eae3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:44:25 -0400 Subject: [PATCH 010/157] fix(jetbrains): show absolute reverted file tooltips --- .../client/session/ui/RevertBanner.kt | 29 +++++++++++++++++-- .../session/ui/SessionMessageListPanelTest.kt | 27 +++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index 5579e96a468..06ca8f68f04 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -24,7 +24,11 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Component +import java.awt.Container import java.awt.Dimension +import java.nio.file.Path +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -133,7 +137,7 @@ class RevertBanner( val row = rows.getOrPut(item.file) { Row(item) } - row.update(item, names[item.file] ?: item.file) + row.update(item, names[item.file] ?: item.file, absolute(item.file)) row.panel } if (files.components.toList() != order) { @@ -188,6 +192,15 @@ class RevertBanner( openDiff(diffs, KiloBundle.message("revert.banner.openDiff.title"), "revert:${sessionId ?: "pending"}:${revert.messageID}") } + private fun absolute(file: String): String { + val path = runCatching { Path.of(file) }.getOrNull() ?: return file + if (path.isAbsolute) return path.normalize().toString() + val root = model.session?.directory + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { Path.of(it) }.getOrNull() } + return (root?.resolve(path) ?: path.toAbsolutePath()).normalize().toString() + } + /** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */ private fun rowCap(): Int { val comps = files.components @@ -207,17 +220,27 @@ class RevertBanner( init { applyStyle() + tip(item.file) } - fun update(item: DiffFileDto, text: String) { + fun update(item: DiffFileDto, text: String, tip: String) { if (label.text != text) label.text = text - if (label.toolTipText != item.file) label.toolTipText = item.file + if (panel.toolTipText != tip) tip(tip) badge.update(item.additions, item.deletions) } fun applyStyle() { label.foreground = UIUtil.getLabelForeground() } + + private fun tip(text: String) { + tip(panel, text) + } + + private fun tip(node: Component, text: String) { + if (node is JComponent && node.toolTipText != text) node.toolTipText = text + if (node is Container) node.components.forEach { tip(it, text) } + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 3e23202907b..95ff55bf948 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -39,6 +39,8 @@ import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.SessionRevertDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.Disposable @@ -1161,6 +1163,31 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(labels.contains("Button.kt" to "packages/ui/src/Button.kt")) } + fun `test rollback banner uses full path tooltip for entire file row`() { + val banner = RevertBanner(model, {}, {}, {}) + model.setSession(SessionDto( + id = "ses", + projectID = "proj", + directory = "/workspace/root", + title = "Session", + version = "1", + time = SessionTimeDto(0.0, 0.0), + )) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf( + DiffFileDto("project/dir1/shared-alpha.txt", 0, 4), + DiffFileDto("project/dir2/shared-alpha.txt", 0, 4), + )) + + banner.update() + + val label = rowLabels(banner).first { it.text == "dir1/shared-alpha.txt" } + val row = label.parent as JComponent + assertEquals("/workspace/root/project/dir1/shared-alpha.txt", row.toolTipText) + assertTrue(components(row).filterIsInstance().all { it.toolTipText == "/workspace/root/project/dir1/shared-alpha.txt" }) + } + fun `test rollback banner opens rolled back diff`() { val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") val opened = mutableListOf>() From 5231fe55c51453185fa26074547e1bfcc0f6223a Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:49:37 -0400 Subject: [PATCH 011/157] test(cli): cover command file endpoints in httpapi exerciser --- .../server/httpapi-exercise-scenarios.ts | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 2e3e6c4f9ce..e3b682befa0 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -3,7 +3,7 @@ import { mkdir, rm } from "fs/promises" import path from "path" import { KiloMemory } from "@kilocode/kilo-memory/effect" import { MemoryPaths } from "@kilocode/kilo-memory/effect/paths" -import { array, check, object } from "../../server/httpapi-exercise/assertions" +import { array, check, isRecord, object } from "../../server/httpapi-exercise/assertions" import { http, route } from "../../server/httpapi-exercise/dsl" import type { Scenario, ScenarioContext } from "../../server/httpapi-exercise/types" import { anacondaDesktopScenarios } from "../anaconda-desktop/httpapi-exercise-scenarios" @@ -37,6 +37,13 @@ const agent = async (dir: string) => { ) } +const command = async (dir: string) => { + await Bun.write( + path.join(dir, ".kilo/command/httpapi-remove.md"), + "---\ndescription: HTTP API command remove\nmodel: anthropic/claude-sonnet-4-6\nvariant: high\n---\nRun command.\n", + ) +} + function memory(ctx: ScenarioContext) { const dir = directory(ctx) return MemoryPaths.root({ ctx: { directory: dir, worktree: dir } }) @@ -543,6 +550,44 @@ export const kiloScenarios: Scenario[] = [ array(body.mcps) array(body.vscode_extensions) }), + http.protected + .get("/kilocode/command/files", "kilocode.commandFiles") + .inProject({ git: true, init: command }) + .json(200, (body, ctx) => { + array(body) + const item = body.find((item) => isRecord(item) && item.name === "httpapi-remove") + object(item) + check(item.description === "HTTP API command remove", "command file should include description") + check( + item.location === path.join(directory(ctx), ".kilo/command/httpapi-remove.md"), + "command file should include location", + ) + check(item.editable === true, "command file should be editable") + check(item.builtin === false, "command file should not be builtin") + check(item.model === "anthropic/claude-sonnet-4-6", "command file should include model metadata") + check(item.variant === "high", "command file should include variant metadata") + check(typeof item.content === "string" && item.content.includes("Run command."), "command file should include content") + }), + http.protected + .post("/kilocode/command/remove", "kilocode.removeCommand") + .inProject({ git: true, init: command }) + .mutating() + .preserveDatabase() + .at((ctx) => ({ + path: "/kilocode/command/remove", + headers: ctx.headers(), + body: { location: path.join(directory(ctx), ".kilo/command/httpapi-remove.md") }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "command removal should return true") + const location = path.join(directory(ctx), ".kilo/command/httpapi-remove.md") + check( + !(yield* Effect.promise(() => Bun.file(location).exists())), + "removed command should not remain on disk", + ) + }), + ), http.protected .post("/kilocode/skill/remove", "kilocode.removeSkill") .inProject({ git: true, init: skill }) From 65d678b7ff5c5df47d04999521210a0ff0f269ed Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 16:07:34 -0400 Subject: [PATCH 012/157] test(cli): relax Windows cleanup runner deadline --- packages/opencode/test/kilocode/test-runner-cleanup.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts index 23f1432fc3b..7c5dbf18025 100644 --- a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts +++ b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts @@ -144,7 +144,8 @@ describe("test runner cleanup", () => { const stderr = new Response(proc.stderr).text() try { - const code = await deadline(proc.exited, 15_000) + const limit = process.platform === "win32" ? 30_000 : 15_000 + const code = await deadline(proc.exited, limit) const output = await Promise.all([stdout, stderr]) expect(code, output[1] || output[0]).not.toBe(0) expect(output[0]).toContain("TIME") @@ -167,7 +168,7 @@ describe("test runner cleanup", () => { await proc.exited await fs.rm(file, { force: true }) } - }, 30_000) + }, 45_000) test.skipIf(process.platform === "win32")( "bounds inherited output after the test process exits", From 4cdbe9d32551dd97c9d3e6067abfac0e04e2a580 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 16:55:50 -0400 Subject: [PATCH 013/157] fix(jetbrains): reconcile session layout cache --- .../client/session/ui/SessionLayout.kt | 27 +++++++++++ .../client/session/ui/SessionLayoutTest.kt | 48 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt index 94d57811264..c4b3d8e6f41 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -152,6 +152,33 @@ open class SessionLayoutPanel( layout = SessionLayout(gap, pad) } + override fun doLayout() { + super.doLayout() + reconcileValidateRoot() + } + + /** + * Keep the parent [SessionLayout]'s cached height honest across validate-root boundaries. + * + * A validate root, such as a settled [ai.kilocode.client.session.views.TurnView], can be laid out + * independently by `RepaintManager`. Its `isValid` flag can flip back to `true` before the parent + * transcript remeasures it, so [SessionLayout] may otherwise keep stacking it at a stale cached + * height until some unrelated resize changes the cache key. + * + * Once this root has laid out its own content, its rendered height should match its preferred + * height. If it does not, the parent cache is stale: drop this entry and revalidate the parent so + * the outer transcript geometry follows the content. Non-roots are skipped because their + * invalidation already propagates to the parent and keeps `isValid` an honest cache signal. + */ + private fun reconcileValidateRoot() { + if (!isValidateRoot()) return + val host = parent ?: return + val layout = host.layout as? SessionLayout ?: return + if (preferredSize.height == height) return + layout.forget(this) + (host as? javax.swing.JComponent)?.revalidate() + } + override fun getScrollableTracksViewportWidth() = true override fun getScrollableTracksViewportHeight() = false override fun getPreferredScrollableViewportSize(): Dimension = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt index 6e39e42c605..ced62e863ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt @@ -361,6 +361,34 @@ class SessionLayoutTest : BasePlatformTestCase() { assertEquals(sCount + 1, second.count) } + fun `test validate root child reconciles stale parent cache after self layout`() { + val p = panel(width = 300) + val child = rootProbe(root = true) + p.add(child) + p.doLayout() + child.markValid() + child.preferred = 80 + + child.doLayout() + p.doLayout() + + assertEquals(80, child.height) + } + + fun `test non validate root child keeps parent cache until it invalidates upward`() { + val p = panel(width = 300) + val child = rootProbe(root = false) + p.add(child) + p.doLayout() + child.markValid() + child.preferred = 80 + + child.doLayout() + p.doLayout() + + assertEquals(20, child.height) + } + // ---- helpers ------ /** A fixed-height JLabel. The width is reported as 0 until layout sets it. */ @@ -394,4 +422,24 @@ class SessionLayoutTest : BasePlatformTestCase() { return Dimension(0, height) } } + + private fun rootProbe(root: Boolean) = object : SessionLayoutPanel() { + var preferred = 20 + private var valid = false + + override fun isValid() = valid + + override fun invalidate() { + valid = false + super.invalidate() + } + + fun markValid() { + valid = true + } + + override fun isValidateRoot() = root + + override fun getPreferredSize(): Dimension = Dimension(0, preferred) + } } From e26acef176992b922515f9cbec3ab48eaf237d54 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 10:41:05 +0200 Subject: [PATCH 014/157] fix(vscode): keep feedback label on one line --- packages/kilo-vscode/webview-ui/src/styles/welcome.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/webview-ui/src/styles/welcome.css b/packages/kilo-vscode/webview-ui/src/styles/welcome.css index e2814931437..a1e1eb08208 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/welcome.css +++ b/packages/kilo-vscode/webview-ui/src/styles/welcome.css @@ -210,6 +210,7 @@ font-family: inherit; font-size: var(--kilo-font-size-12); font-weight: 500; + white-space: nowrap; cursor: pointer; transition: background-color 0.15s, From bdc66f447f3eb31545fc1dfd9ad78c15ea7c0bb3 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 4 Aug 2026 08:45:00 +0000 Subject: [PATCH 015/157] chore: update kilo-vscode visual regression baselines --- .../settings/work-style-onboarding-default-chromium-linux.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png index 2597a60c84d..13b431ad977 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/work-style-onboarding-default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3f86dabc39700b3a86508c1e6499a546eeabf720832b62c9ce12f2d97378ebd -size 34649 +oid sha256:00092414080e2f70fb9b3166e8d06a1692af93e33717d0ab8fea5fe96e6a6982 +size 34468 From 6414e64b291619457c13d089102059e4e0f29b06 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 09:38:14 -0400 Subject: [PATCH 016/157] fix(jetbrains): render multi-hunk diffs --- .changeset/jetbrains-multihunk-diff-viewer.md | 5 +++ .../client/diff/DiffPatchReconstruct.kt | 13 ++++--- .../client/diff/DiffPatchReconstructTest.kt | 37 ++++++++++++++++++- .../client/diff/KiloDiffEditorContentTest.kt | 25 +++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 .changeset/jetbrains-multihunk-diff-viewer.md diff --git a/.changeset/jetbrains-multihunk-diff-viewer.md b/.changeset/jetbrains-multihunk-diff-viewer.md new file mode 100644 index 00000000000..b4a871eebb9 --- /dev/null +++ b/.changeset/jetbrains-multihunk-diff-viewer.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render multi-hunk modified-file diffs correctly in the JetBrains diff viewer. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt index 654ef86da03..ca2c92cf46e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -53,11 +53,14 @@ internal object DiffPatchReconstruct { } } } - // 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) + // A patch may carry several hunks (limited-context git output) or a single full-context hunk. + // We concatenate every hunk body into contiguous before/after text: unchanged context lines + // anchor each region so the resulting side-by-side still colors adds/removes correctly. The + // elided gaps between hunks collapse (line numbers restart at 1), which is acceptable for a + // "what changed" view and far better than the all-green raw-patch fallback. We still bail when + // there is no hunk, or when the header lengths don't match the reconstructed body (truncated + // context), because that would place lines against the wrong side. + 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) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt index 24ab5b67c5a..0215144879a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -133,7 +133,42 @@ class DiffPatchReconstructTest { } @Test - fun `multi hunk partial context patch is not renderable`() { + fun `multi hunk patch reconstructs concatenated changed regions`() { + // Turn/tool diffs are ordinary limited-context git output with several hunks. The reconstruction + // stitches each hunk body into contiguous before/after text so the diff editor colors the + // changes instead of dumping the raw patch as all-added lines. Inter-hunk gaps collapse. + 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 + three + @@ -20,3 +20,3 @@ + twenty + -x + +X + z + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertTrue(sides.renderable) + assertEquals("one\ntwo\nthree\ntwenty\nx\nz", sides.before) + assertEquals("one\nTWO\nthree\ntwenty\nX\nz", sides.after) + } + + @Test + fun `multi hunk patch with truncated context is not renderable`() { + // header claims 3 old / 3 new lines per hunk but the body carries only 2 of each: reconstructing + // would misalign the sides, so fall back to the raw-patch view. val dto = DiffFileDto( file = "src/A.kt", additions = 2, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 7bf4d3c12bd..de63af8b033 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -291,6 +291,31 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { assertEquals("hello\nworld", contents[1]) } + fun `test diff request reconstructs multi hunk modified patch`() { + // Regression: multi-hunk modified diffs used to fall back to an empty original + raw patch on + // the right, rendering every line as added. They now reconstruct into a real side-by-side diff. + val patch = """ + diff --git a/src/App.kt b/src/App.kt + --- a/src/App.kt + +++ b/src/App.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + three + @@ -20,3 +20,3 @@ + twenty + -x + +X + z + """.trimIndent() + val request = diffRequest(project, file("src/App.kt", 2, 2, patch = patch)) as SimpleDiffRequest + val contents = request.contents.map(::content) + + assertEquals("one\ntwo\nthree\ntwenty\nx\nz", contents[0]) + assertEquals("one\nTWO\nthree\ntwenty\nX\nz", contents[1]) + } + fun `test tree displays absolute files relative to workspace`() { val parent = Disposer.newDisposable() try { From bfab19c047d142b24e5b0a4d9f4a5785b7d1cae8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 10:50:03 -0400 Subject: [PATCH 017/157] fix(jetbrains): resolve revert tooltip fallback --- .changeset/bright-reasoning-icon.md | 5 ----- .changeset/jetbrains-multihunk-diff-viewer.md | 5 ----- .changeset/jetbrains-revert-diff-card.md | 2 +- .changeset/jetbrains-reverted-session-diff-list.md | 5 ----- .changeset/smooth-copy-icon.md | 5 ----- .../kotlin/ai/kilocode/client/session/ui/RevertBanner.kt | 2 +- 6 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 .changeset/bright-reasoning-icon.md delete mode 100644 .changeset/jetbrains-multihunk-diff-viewer.md delete mode 100644 .changeset/jetbrains-reverted-session-diff-list.md delete mode 100644 .changeset/smooth-copy-icon.md diff --git a/.changeset/bright-reasoning-icon.md b/.changeset/bright-reasoning-icon.md deleted file mode 100644 index 7f04efb0fb2..00000000000 --- a/.changeset/bright-reasoning-icon.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Update the JetBrains reasoning block icon to a lightbulb shape. diff --git a/.changeset/jetbrains-multihunk-diff-viewer.md b/.changeset/jetbrains-multihunk-diff-viewer.md deleted file mode 100644 index b4a871eebb9..00000000000 --- a/.changeset/jetbrains-multihunk-diff-viewer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Render multi-hunk modified-file diffs correctly in the JetBrains diff viewer. diff --git a/.changeset/jetbrains-revert-diff-card.md b/.changeset/jetbrains-revert-diff-card.md index 7c779212745..fe8f75e3d3b 100644 --- a/.changeset/jetbrains-revert-diff-card.md +++ b/.changeset/jetbrains-revert-diff-card.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Show reverted-card diff actions inline with the session header and open rolled-back changes in the diff viewer. +Improve JetBrains session transcript layout, icons, reverted-change summaries, and multi-hunk diff rendering. diff --git a/.changeset/jetbrains-reverted-session-diff-list.md b/.changeset/jetbrains-reverted-session-diff-list.md deleted file mode 100644 index 4f9ce88e550..00000000000 --- a/.changeset/jetbrains-reverted-session-diff-list.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show the rolled-back file list when reopening a session whose last message was reverted. diff --git a/.changeset/smooth-copy-icon.md b/.changeset/smooth-copy-icon.md deleted file mode 100644 index 817db023967..00000000000 --- a/.changeset/smooth-copy-icon.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Sharpen the chat message copy icon in JetBrains. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index 06ca8f68f04..e1273bdfc11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -198,7 +198,7 @@ class RevertBanner( val root = model.session?.directory ?.takeIf { it.isNotBlank() } ?.let { runCatching { Path.of(it) }.getOrNull() } - return (root?.resolve(path) ?: path.toAbsolutePath()).normalize().toString() + return root?.resolve(path)?.normalize()?.toString() ?: file } /** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */ From 5a955778daec603e11495343a432555736b4778f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 18:14:20 +0200 Subject: [PATCH 018/157] fix(vscode): speed snapshots and scope project state --- .changeset/fix-multi-project-session-scope.md | 5 + packages/kilo-vscode/.vscodeignore | 1 + packages/kilo-vscode/script/dev-snapshot.ts | 59 ++++--- packages/kilo-vscode/script/ffmpeg-helper.ts | 4 +- packages/kilo-vscode/script/local-bin.ts | 153 +++++++++++++----- packages/kilo-vscode/src/KiloProvider.ts | 106 ++++++++++-- .../src/agent-manager/AgentManagerProvider.ts | 6 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 4 + .../kilo-vscode/src/agent-manager/host.ts | 2 + .../src/agent-manager/vscode-host.ts | 1 + .../kilo-vscode/src/kilo-provider-utils.ts | 10 +- .../src/kilo-provider/git-status.ts | 4 +- .../kilo-vscode/tests/unit/git-ops.test.ts | 17 ++ .../tests/unit/kilo-provider-followup.test.ts | 107 ++++++++++++ .../kilo-provider-session-refresh.test.ts | 43 +++++ 15 files changed, 442 insertions(+), 80 deletions(-) create mode 100644 .changeset/fix-multi-project-session-scope.md diff --git a/.changeset/fix-multi-project-session-scope.md b/.changeset/fix-multi-project-session-scope.md new file mode 100644 index 00000000000..0f32e3327ac --- /dev/null +++ b/.changeset/fix-multi-project-session-scope.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Speed up local VS Code snapshot installs and scope Agent Manager session events and Git status to the active project, including edits inside nested repositories. diff --git a/packages/kilo-vscode/.vscodeignore b/packages/kilo-vscode/.vscodeignore index 157f9841d78..4f0ac692047 100644 --- a/packages/kilo-vscode/.vscodeignore +++ b/packages/kilo-vscode/.vscodeignore @@ -29,6 +29,7 @@ AGENTS.md # not ship in the VSIX, otherwise production installs are detected as local # builds and may inject a dev-only bwrap fallback (see ServerManager.localCli). bin/.cli-version +bin/.ffmpeg-target # Include WAV assets used by extension-host notification playback !audio-wav/** diff --git a/packages/kilo-vscode/script/dev-snapshot.ts b/packages/kilo-vscode/script/dev-snapshot.ts index edbe590573e..dcf5b5d9b77 100755 --- a/packages/kilo-vscode/script/dev-snapshot.ts +++ b/packages/kilo-vscode/script/dev-snapshot.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" +import { createRequire } from "node:module" import { join, dirname } from "node:path" import { tmpdir } from "node:os" import { rmSync, mkdirSync, existsSync } from "node:fs" @@ -26,37 +27,51 @@ console.log(`Commit: ${sha}`) console.log(`Mode: ${mode}\n`) console.log("🧹 Cleaning build directories...") -for (const dir of ["bin", "dist"]) { - const dirPath = join(root, dir) - if (existsSync(dirPath)) { - rmSync(dirPath, { recursive: true, force: true }) - console.log(` ✓ Cleaned ${dir}/`) - } +const dist = join(root, "dist") +if (existsSync(dist)) { + rmSync(dist, { recursive: true, force: true }) + console.log(" ✓ Cleaned dist/") } const outDir = join(tmpdir(), "kilo-vscode-snapshots") mkdirSync(outDir, { recursive: true }) -console.log("\n📦 Rebuilding SDK...") -await $`bun run --cwd ../sdk/js build`.cwd(root) +console.log("\n📦 Preparing SDK...") +await $`bun run prepare:sdk`.cwd(root) -console.log("\n🔧 Preparing CLI binary...") -await $`bun script/local-bin.ts --force`.cwd(root) - -console.log("\n✅ Type-checking...") -await $`bun run typecheck`.cwd(root) - -console.log("\n🔍 Linting...") -await $`bun run lint`.cwd(root) - -console.log("\n🏗️ Building extension...") -await $`node ${join(root, "esbuild.js")} --production`.cwd(root) +console.log("\n🔧 Preparing CLI binary and validating extension...") +await $`bun script/local-bin.ts --compiled`.cwd(root) +await $`bun run build:check:production`.cwd(root) console.log("\n📦 Packaging VSIX...") const vsixPath = join(outDir, `kilo-vscode-snapshot-${sha}-${user}.vsix`) -await $`bunx vsce package ${snapshotVersion} --no-update-package-json --no-dependencies --skip-license -o ${vsixPath}`.cwd( - root, -) +const require = createRequire(import.meta.url) +const zlib = require("node:zlib") as typeof import("node:zlib") +const DeflateRaw = zlib.DeflateRaw +if (shouldInstall) { + // Local installs favor fast packaging and extraction over archive size. + Object.defineProperty(zlib, "DeflateRaw", { + value: function (options?: ConstructorParameters[0]) { + return new DeflateRaw({ ...options, level: 0 }) + }, + }) +} +const { createVSIX } = await import("@vscode/vsce") +const marker = join(root, "bin", ".cli-version") +const cache = existsSync(marker) ? await Bun.file(marker).text() : undefined +if (cache !== undefined) rmSync(marker) +try { + await createVSIX({ + cwd: root, + packagePath: vsixPath, + version: snapshotVersion, + updatePackageJson: false, + dependencies: false, + skipLicense: true, + }) +} finally { + if (cache !== undefined) await Bun.write(marker, cache) +} if (shouldInstall) { const execPath = process.env.VSCODE_EXEC_PATH ?? "" diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts index c0b5e440ef4..1ee04fd78da 100644 --- a/packages/kilo-vscode/script/ffmpeg-helper.ts +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -23,7 +23,8 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" const dest = join(bin, exe) - if (existsSync(dest)) return + const marker = join(bin, ".ffmpeg-target") + if (existsSync(dest) && existsSync(marker) && (await Bun.file(marker).text()).trim() === target) return const tmp = join(bin, ".ffmpeg-tmp") rmSync(tmp, { recursive: true, force: true }) @@ -37,6 +38,7 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis await $`tar -xzf ${join(tmp, name)} -C ${tmp}`.quiet() copyFileSync(join(tmp, "package", exe), dest) if (!target.startsWith("win32")) chmodSync(dest, 0o755) + await Bun.write(marker, `${target}\n`) } finally { rmSync(tmp, { recursive: true, force: true }) } diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 9a2f11f550b..1de6ab95c23 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" +import { createHash } from "node:crypto" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" import { @@ -15,6 +16,7 @@ import { currentBwrapTarget, ensureBwrapForTarget } from "./bwrap-helper" import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") +const compiledOnly = process.argv.includes("--compiled") /** * Ensures the VS Code extension has a CLI binary at `packages/kilo-vscode/bin/kilo`. @@ -32,10 +34,8 @@ const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const repoDir = join(packagesDir, "..") const opencodeDir = join(packagesDir, "opencode") -const coreDir = join(packagesDir, "core") -const gatewayDir = join(packagesDir, "kilo-gateway") -const indexingDir = join(packagesDir, "kilo-indexing") const sandboxDir = join(packagesDir, "kilo-sandbox") +const rootFile = join(repoDir, "package.json") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" @@ -46,50 +46,123 @@ function log(msg: string) { console.log(`[local-bin] ${msg}`) } -async function cliSourceHash(): Promise { - try { - const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() - const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet() - const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() - const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() - const sandboxResult = await $`git log -1 --format=%H -- .`.cwd(sandboxDir).quiet() - return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}-${sandboxResult.text().trim()}` - } catch { - return null - } +type Package = { + name?: string + workspaces?: string[] | { packages?: string[] } + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record } -async function isDirty(): Promise { - try { - const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() - const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet() - const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() - const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() - const sandboxResult = await $`git status --porcelain -- .`.cwd(sandboxDir).quiet() - return ( - opencodeResult.text().trim().length > 0 || - coreResult.text().trim().length > 0 || - gatewayResult.text().trim().length > 0 || - indexingResult.text().trim().length > 0 || - sandboxResult.text().trim().length > 0 +async function cliInputs() { + const root: Package = await Bun.file(rootFile).json() + const workspaces = Array.isArray(root.workspaces) ? root.workspaces : (root.workspaces?.packages ?? []) + const files = ( + await Promise.all( + workspaces.map((pattern) => + Array.fromAsync(new Bun.Glob(`${pattern}/package.json`).scan({ cwd: repoDir, onlyFiles: true })), + ), ) - } catch { - return false + ).flat() + const entries = await Promise.all( + files.map(async (file) => ({ file, pkg: (await Bun.file(join(repoDir, file)).json()) as Package })), + ) + const packages = new Map(entries.flatMap((entry) => (entry.pkg.name ? [[entry.pkg.name, entry] as const] : []))) + const dirs = new Set() + + function visit(name: string) { + const entry = packages.get(name) + if (!entry) return + const dir = dirname(entry.file) + if (dirs.has(dir)) return + dirs.add(dir) + const deps = { + ...entry.pkg.dependencies, + ...entry.pkg.devDependencies, + ...entry.pkg.optionalDependencies, + ...entry.pkg.peerDependencies, + } + for (const dep of Object.keys(deps)) visit(dep) } + + // The CLI build embeds the console even though it is not a package dependency. + for (const dir of [opencodeDir, join(packagesDir, "kilo-console")]) { + const pkg: Package = await Bun.file(join(dir, "package.json")).json() + if (!pkg.name) throw new Error(`Workspace package at ${dir} has no name`) + visit(pkg.name) + } + + return [ + relative(repoDir, rootFile), + "bun.lock", + "patches", + ...[...dirs].sort(), + "packages/kilo-vscode/script/bwrap-helper.ts", + "packages/kilo-vscode/script/ffmpeg-helper.ts", + "packages/kilo-vscode/script/local-bin.ts", + "packages/kilo-vscode/src/services/cli-backend/cli-resources.ts", + ] } -async function isStale(): Promise { - if (await isDirty()) return true +async function cliSourceHash() { + const inputs = await cliInputs() + const [tree, diff, extra, branch] = await Promise.all([ + $`git ls-tree -r HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git diff --binary HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git ls-files --others --exclude-standard -z -- ${inputs}`.cwd(repoDir).quiet(), + $`git branch --show-current`.cwd(repoDir).quiet(), + ]) + const env = Object.fromEntries( + [ + "GH_REPO", + "KILO_BUMP", + "KILO_BWRAP_CACHE", + "KILO_CHANNEL", + "KILO_MODELS_URL", + "KILO_PRE_RELEASE", + "KILO_RELEASE", + "KILO_SKIP_BUNDLED_BWRAP", + "KILO_VERSION", + "MODELS_DEV_API_JSON", + "ZIG", + ].map((key) => [key, process.env[key] ?? ""]), + ) + const hash = createHash("sha256") + .update(tree.text()) + .update(diff.text()) + .update(branch.text()) + .update(JSON.stringify(env)) + const files = extra.text().split("\0").filter(Boolean).sort() + + for (const file of files) { + hash.update(file) + hash.update(new Uint8Array(await Bun.file(join(repoDir, file)).arrayBuffer())) + } + + const models = process.env.MODELS_DEV_API_JSON + if (models) hash.update(new Uint8Array(await Bun.file(models).arrayBuffer())) + return hash.digest("hex") +} + +async function isStale() { const hash = await cliSourceHash() - if (!hash) return false // can't determine — assume fresh try { - const stored = (await Bun.file(versionFile).text()).trim() - return stored !== hash + const stored: unknown = await Bun.file(versionFile).json() + if (!stored || typeof stored !== "object") return true + const input = Reflect.get(stored, "input") + const target = Reflect.get(stored, "target") + const kind = Reflect.get(stored, "kind") + return input !== hash || target !== platformTag() || (compiledOnly && kind !== "compiled") } catch { return true // no version file — treat as stale } } +async function writeVersion(kind: "compiled" | "wrapper") { + await Bun.write(versionFile, JSON.stringify({ input: await cliSourceHash(), target: platformTag(), kind }) + "\n") +} + function platformTag(): string { const os = process.platform === "win32" ? "windows" : process.platform return `cli-${os}-${process.arch}` @@ -115,6 +188,8 @@ async function findKiloBinaryInOpencodeDist(): Promise { // fall through to generic search } + if (compiledOnly) return null + // Fallback: find any dist/**/bin/kilo or kilo.exe const queue = [distDir] while (queue.length) { @@ -214,8 +289,7 @@ async function writeSourceWrapper() { await bundleKiloSandboxWorker() await ensureLocalHelpers() - const hash = await cliSourceHash() - if (hash) await Bun.write(versionFile, hash + "\n") + await writeVersion("wrapper") log( `Compiled CLI build failed; wrote source wrapper at ${relative(kiloVscodeDir, targetBinPath)} for local development.`, ) @@ -238,7 +312,7 @@ async function main() { return } - if (forceRebuild && !exists) { + if ((forceRebuild || compiledOnly) && !ready) { removeDist() } @@ -256,7 +330,7 @@ async function main() { } const sourceBinPath = await ensureBuiltBinary().catch(async (err) => { - if (forceRebuild) throw err + if (forceRebuild || compiledOnly) throw err await writeSourceWrapper() log(`Wrapper fallback reason: ${err instanceof Error ? err.message : String(err)}`) return null @@ -270,8 +344,7 @@ async function main() { chmodSync(targetBinPath, 0o755) await ensureLocalHelpers() - const hash = await cliSourceHash() - if (hash) await Bun.write(versionFile, hash + "\n") + await writeVersion("compiled") log(`Copied CLI binary from ${relative(packagesDir, sourceBinPath)} -> ${relative(kiloVscodeDir, targetBinPath)}`) } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index be4377e46ff..4c61c085d5f 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -50,7 +50,6 @@ import { import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" -import { getWorkspaceRoot } from "./review-utils" import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item" import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller" import type { RemoteStatusService } from "./services/RemoteStatusService" @@ -441,6 +440,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private statsGitOps: GitOps | null = null private cachedStats: unknown = null private cachedGitRepo = false + private cachedGitDirectory: string | undefined + private gitStatusRevision = 0 + private sessionRefreshRevision = 0 private onBeforeMessage: ((msg: Record) => Promise | null>) | null = null @@ -1664,6 +1666,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to SSE events for this webview (filtered by tracked sessions) this.unsubscribeEvent = this.connectionService.onEventFiltered( (payload, directory) => { + if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return false + if (!directory && isEventFromForeignProject(payload, this.projectID)) return false const event = unwrapSyncEvent(payload) if (!event) return false @@ -1675,6 +1679,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // message.part.* events are always session-scoped; drop if session unknown. if (!sessionId) return !isSessionScopedPartEvent(event.type) + if (!directory && !this.isCurrentProjectSession(sessionId)) return false if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) { return true @@ -1821,15 +1826,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.memory.fetch(), this.seedSessionStatusMap(), ]) - this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory()) - this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo }) + await this.refreshGitStatus(this.getWorkspaceDirectory()) this.sendNotificationSettings() this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) this.postMessage({ type: "extensionDataReady" }) - if (this.cachedGitRepo) this.startStatsPolling() - console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error) @@ -1890,6 +1892,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Non-blocking: refresh session metadata + status for the webview after switching. */ private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void { if (!this.client) return + void this.refreshGitStatus(dir) const revision = this.revisions.get(sessionID) const refresh = (this.refreshes.get(sessionID) ?? 0) + 1 this.refreshes.set(sessionID, refresh) @@ -2083,7 +2086,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** * Build the context object used by the extracted session-refresh helpers. */ - private get sessionRefreshContext(): SessionRefreshContext { + private getSessionRefreshContext(revision: number): SessionRefreshContext { const client = this.client return { pendingSessionRefresh: this.pendingSessionRefresh, @@ -2095,6 +2098,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper sessionDirectories: this.sessionDirectories, worktreeDirectories: this.opts.worktreeDirectories, workspaceDirectory: this.getWorkspaceDirectory(), + isCurrent: () => revision === this.sessionRefreshRevision, postMessage: (msg: unknown) => this.postMessage(msg), } } @@ -2105,10 +2109,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async flushPendingSessionRefresh(reason: string): Promise { if (!this.pendingSessionRefresh) return console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason }) - const ctx = this.sessionRefreshContext + const revision = ++this.sessionRefreshRevision + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined + const ctx = this.getSessionRefreshContext(revision) try { const resolved = await flushPendingSessionRefreshUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error) } @@ -2119,10 +2126,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Handle loading all sessions. */ private async handleLoadSessions(): Promise { - const ctx = this.sessionRefreshContext + const revision = ++this.sessionRefreshRevision + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined + const ctx = this.getSessionRefreshContext(revision) try { const resolved = await loadSessionsUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to load sessions:", error) this.postMessage({ @@ -4306,9 +4316,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return + if (directory && !this.isCurrentProjectDirectory(directory)) return if ( this.projectID && + (!this.opts.projectQualifier || !directory) && (event.type === "session.created" || event.type === "session.updated") && event.properties.info.projectID !== undefined && event.properties.info.projectID !== null && @@ -4367,6 +4378,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) return + if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID) + if (event.type === "session.updated" && typeof event.properties.info.cost === "number") { const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost) this.requestCostAlert(event.properties.sessionID, cost) @@ -4737,6 +4750,75 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return undefined } + private isCurrentProjectDirectory(directory: string): boolean { + if (!this.opts.projectQualifier?.()) return true + const dirs = [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])] + return dirs.some((dir) => sameDirectory(dir, directory)) + } + + private isCurrentProjectSession(sessionID: string): boolean { + if (!this.opts.projectQualifier || !this.opts.routeService) return true + if (this.isSessionRouteAmbiguous(sessionID)) return false + const directory = this.opts.routeService.trySessionDirectory(sessionID) + return !directory || this.isCurrentProjectDirectory(directory) + } + + private refreshGitStatusFromPart( + event: Extract, + sessionID?: string, + ) { + const part = event.properties.part as { + type?: string + metadata?: Record + state?: { input?: Record; metadata?: Record } + } + if (part.type !== "tool") return + const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath] + const file = values.find((value): value is string => typeof value === "string" && value.length > 0) + if (!file) return + const base = this.getWorkspaceDirectory(sessionID) + const value = file.split(",")[0].trim() + const pathName = path.isAbsolute(value) ? value : path.resolve(base, value) + void this.refreshGitStatus(path.dirname(pathName)) + } + + public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise { + const client = this.client + if (!client) return + const revision = ++this.gitStatusRevision + const direct = await hasGit(client, directory) + const root = await this.resolveGitRoot(directory) + if (revision !== this.gitStatusRevision) return + const repo = direct || root !== undefined + const target = root ?? directory + const changed = !this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target) + if (changed) { + this.cachedStats = null + this.statsPoller?.stop() + this.statsPoller = null + this.statsGitOps?.dispose() + this.statsGitOps = null + } + this.cachedGitDirectory = target + this.cachedGitRepo = repo + this.postMessage({ type: "gitStatus", repo }) + if (repo) { + if (!this.statsPoller) this.startStatsPolling() + return + } + this.statsPoller?.stop() + this.statsGitOps?.dispose() + this.statsPoller = null + this.statsGitOps = null + } + + private async resolveGitRoot(directory: string): Promise { + const git = this.statsGitOps ?? new GitOps({ log: () => {} }) + const root = await git.root(directory) + if (!this.statsGitOps) git.dispose() + return root + } + private getContextDirectory(): string { return resolveContextDirectory({ currentSessionID: this.currentSession?.id, @@ -4846,7 +4928,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsGitOps = git this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], - getWorkspaceRoot: () => getWorkspaceRoot(), + getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id), localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 39b844f9894..5453637a159 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1555,7 +1555,11 @@ export class AgentManagerProvider implements Disposable { void this.sendRepoInfo() if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c))) this.stateReady = this.initializeState() - else this.projectPollers.sync(this.contexts) + else { + this.panel?.sessions.refreshSessions() + this.projectPollers.sync(this.contexts) + } + this.panel?.sessions.refreshGitStatus?.() } private onWorkspaceChanged(): void { if (this.contexts.syncPinned()) { diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 92a2984b5a9..ea26906b893 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -171,6 +171,10 @@ export class GitOps { return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "") } + async root(cwd: string): Promise { + return this.raw(["rev-parse", "--show-toplevel"], cwd).catch(() => undefined) + } + /** * Resolve the remote name for a branch. Checks (in order): * 1. The configured upstream's remote (e.g. upstream from `upstream/main`) diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index f8e8cee3a64..296cb70caf3 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -66,6 +66,8 @@ export interface SessionProvider { isSessionRouteAmbiguous?(sessionId: string): boolean /** Exact directory for a project-qualified session ref, or undefined. */ routeSessionDirectoryFor?(ref: SessionRef): string | undefined + /** Re-check Git capability for the active project/session directory. */ + refreshGitStatus?(): void dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 4e1528f8a9b..a98f679d89e 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -151,6 +151,7 @@ export class VscodeHost implements Host { unregisterSessionRoute: (ref) => provider.unregisterSessionRoute(ref), isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId), routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref), + refreshGitStatus: () => void provider.refreshGitStatus(), dispose: () => provider.dispose(), } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c9825cb7713..c4bd2f9f74e 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -234,6 +234,7 @@ export interface SessionRefreshContext { sessionDirectories: Map worktreeDirectories?: () => string[] workspaceDirectory: string + isCurrent?: () => boolean postMessage(message: unknown): void } @@ -256,7 +257,7 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise() const extra = await Promise.all( [...worktreeDirs].map((dir) => @@ -276,6 +277,8 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise { - return client.project - .current({ directory }) + return Promise.resolve() + .then(() => client.project.current({ directory })) .then((r) => r.data?.vcs === "git") .catch(() => false) } diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index bd56efa8274..563620705d5 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -58,6 +58,23 @@ describe("GitOps", () => { }) }) + describe("root", () => { + it("resolves the nearest enclosing repository", async () => { + const git = ops(async (args) => { + if (args[0] === "rev-parse" && args[1] === "--show-toplevel") return "/workspace/frontend" + return "" + }) + expect(await git.root("/workspace/frontend/src")).toBe("/workspace/frontend") + }) + + it("returns undefined outside a repository", async () => { + const git = ops(async () => { + throw new Error("not a git repo") + }) + expect(await git.root("/workspace")).toBeUndefined() + }) + }) + describe("resolveRemote", () => { it("uses upstream remote when upstream is configured", async () => { const git = ops(async (args) => { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 1965da54521..732a08a92c5 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -8,8 +8,12 @@ type Internals = { webview: { postMessage: (message: unknown) => Promise } | null trackedSessionIds: Set currentSession: Session | null + projectID: string | undefined + isWebviewReady: boolean pendingFollowup: { dir: string; time: number } | null handleLoadMessages: (sessionID: string) => Promise + handleEvent: (event: Event, directory?: string) => void + refreshGitStatus: (directory?: string) => Promise initializeConnection: () => Promise syncWebviewState: () => Promise flushPendingSessionRefresh: () => Promise @@ -43,6 +47,18 @@ function created(input: { id: string; directory: string; parentID?: string }): E } as Event } +function info(input: { id: string; projectID: string; directory: string }): Session { + return { + id: input.id, + slug: `${input.id}-slug`, + projectID: input.projectID, + directory: input.directory, + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + } +} + function connection() { let filter: ((event: Event) => boolean) | undefined let listener: ((event: Event) => void) | undefined @@ -80,6 +96,97 @@ function connection() { } describe("KiloProvider follow-up sessions", () => { + it("scopes shared session events to the active project directory", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/repo/project-b", + projectQualifier: () => ({ projectId: "project-b" }), + }) + const internal = provider as unknown as Internals + const sent: unknown[] = [] + const sharedID = "ses-shared" + + internal.webview = { + postMessage: async (message: unknown) => { + sent.push(message) + return true + }, + } + internal.isWebviewReady = true + internal.currentSession = info({ id: sharedID, projectID: "backend-project-b", directory: "/repo/project-b" }) + internal.projectID = "backend-project-a" + internal.trackedSessionIds.add(sharedID) + + internal.handleEvent( + { + type: "message.updated", + properties: { + sessionID: sharedID, + info: { + id: "msg-project-a", + sessionID: sharedID, + role: "assistant", + time: { created: 1 }, + }, + }, + } as Event, + "/repo/project-a", + ) + expect(sent).toEqual([]) + + internal.handleEvent( + { + type: "session.created", + properties: { sessionID: sharedID, info: internal.currentSession }, + } as Event, + "/repo/project-b", + ) + expect(sent).toContainEqual({ + type: "sessionCreated", + session: { + id: sharedID, + title: "Session", + createdAt: new Date(1).toISOString(), + updatedAt: new Date(1).toISOString(), + parentID: null, + revert: null, + summary: null, + }, + }) + }) + + it("refreshes Git from the file path in a completed edit tool part", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/workspace", + projectQualifier: () => ({ projectId: "workspace" }), + }) + const internal = provider as unknown as Internals + const dirs: string[] = [] + const sessionID = "ses-edit" + internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" }) + internal.trackedSessionIds.add(sessionID) + internal.refreshGitStatus = async (directory) => { + if (directory) dirs.push(directory) + } + + internal.handleEvent( + { + type: "message.part.updated", + properties: { + sessionID, + part: { + type: "tool", + metadata: { filepath: "/workspace/frontend/src/app.ts" }, + }, + }, + } as Event, + "/workspace", + ) + + expect(dirs).toEqual(["/workspace/frontend/src"]) + }) + it("ignores subagents before adopting pending follow-up sessions", async () => { const service = connection() const provider = new KiloProvider({} as never, service as never) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 3cb7d11ed69..e96a67d6b71 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -9,11 +9,20 @@ type State = "connecting" | "connected" | "disconnected" | "error" type ProviderInternals = { connectionState: State pendingSessionRefresh: boolean + projectID: string | undefined webview: { postMessage: (message: unknown) => Promise } | null initializeConnection: () => Promise handleLoadSessions: () => Promise } +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + function createContext(overrides?: Partial): SessionRefreshContext & { sent: unknown[] } { const sent: unknown[] = [] return { @@ -100,6 +109,40 @@ function createConnection(client: ReturnType) { } describe("KiloProvider pending session refresh", () => { + it("does not let a late listing restore the previous project's identity", async () => { + const client = createClient() + const pending = new Map>>() + client.session.list = async (params: { directory: string }) => { + const next = deferred<{ data: unknown[] }>() + pending.set(params.directory, next) + return next.promise as never + } + const connection = createConnection(client) + await connection.connect() + let active = "a" + const provider = new KiloProvider({} as never, connection as never, undefined, { + rootDirectory: () => `/repo/${active}`, + projectQualifier: () => ({ projectId: active }), + }) + const internal = provider as unknown as ProviderInternals + internal.connectionState = "connected" + + const first = internal.handleLoadSessions() + active = "b" + const second = internal.handleLoadSessions() + + pending.get("/repo/b")!.resolve({ + data: [{ id: "ses-b", projectID: "backend-b", time: { created: 1, updated: 1 } }], + }) + await second + pending.get("/repo/a")!.resolve({ + data: [{ id: "ses-a", projectID: "backend-a", time: { created: 1, updated: 1 } }], + }) + await first + + expect(internal.projectID).toBe("backend-b") + }) + it("keeps worktree sessions with legacy project ids", async () => { const sent: unknown[] = [] const ctx = createContext({ From 6255cf815ddc5ae6f7a17022d8633e3bc64348c4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 18:49:26 +0200 Subject: [PATCH 019/157] fix(vscode): address snapshot review feedback --- packages/kilo-vscode/script/dev-snapshot.ts | 46 ++++----- packages/kilo-vscode/script/ffmpeg-helper.ts | 2 +- packages/kilo-vscode/script/local-bin.ts | 96 ++++++++++++------- packages/kilo-vscode/src/KiloProvider.ts | 5 +- .../tests/unit/kilo-provider-followup.test.ts | 1 + 5 files changed, 88 insertions(+), 62 deletions(-) diff --git a/packages/kilo-vscode/script/dev-snapshot.ts b/packages/kilo-vscode/script/dev-snapshot.ts index dcf5b5d9b77..14ed0ee64c3 100755 --- a/packages/kilo-vscode/script/dev-snapshot.ts +++ b/packages/kilo-vscode/script/dev-snapshot.ts @@ -46,32 +46,34 @@ await $`bun run build:check:production`.cwd(root) console.log("\n📦 Packaging VSIX...") const vsixPath = join(outDir, `kilo-vscode-snapshot-${sha}-${user}.vsix`) const require = createRequire(import.meta.url) -const zlib = require("node:zlib") as typeof import("node:zlib") -const DeflateRaw = zlib.DeflateRaw +const vsceRequire = createRequire(require.resolve("@vscode/vsce")) if (shouldInstall) { // Local installs favor fast packaging and extraction over archive size. - Object.defineProperty(zlib, "DeflateRaw", { - value: function (options?: ConstructorParameters[0]) { - return new DeflateRaw({ ...options, level: 0 }) - }, - }) + type Options = Record + type Zip = { + addFile(path: string, name: string, options?: Options): void + addBuffer(data: Uint8Array, name: string, options?: Options): void + } + const yazl = vsceRequire("yazl") as { ZipFile: { prototype: Zip } } + const zip = yazl.ZipFile.prototype + const file = zip.addFile + const buffer = zip.addBuffer + zip.addFile = function (this: Zip, path, name, options) { + return file.call(this, path, name, { ...options, compress: false }) + } + zip.addBuffer = function (this: Zip, data, name, options) { + return buffer.call(this, data, name, { ...options, compress: false }) + } } const { createVSIX } = await import("@vscode/vsce") -const marker = join(root, "bin", ".cli-version") -const cache = existsSync(marker) ? await Bun.file(marker).text() : undefined -if (cache !== undefined) rmSync(marker) -try { - await createVSIX({ - cwd: root, - packagePath: vsixPath, - version: snapshotVersion, - updatePackageJson: false, - dependencies: false, - skipLicense: true, - }) -} finally { - if (cache !== undefined) await Bun.write(marker, cache) -} +await createVSIX({ + cwd: root, + packagePath: vsixPath, + version: snapshotVersion, + updatePackageJson: false, + dependencies: false, + skipLicense: true, +}) if (shouldInstall) { const execPath = process.env.VSCODE_EXEC_PATH ?? "" diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts index 1ee04fd78da..eaf604a9d0e 100644 --- a/packages/kilo-vscode/script/ffmpeg-helper.ts +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -23,7 +23,7 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" const dest = join(bin, exe) - const marker = join(bin, ".ffmpeg-target") + const marker = join(bin, "..", "node_modules", ".kilo-ffmpeg-target") if (existsSync(dest) && existsSync(marker) && (await Bun.file(marker).text()).trim() === target) return const tmp = join(bin, ".ffmpeg-tmp") diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 1de6ab95c23..4b3c78e7498 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -40,7 +40,7 @@ const rootFile = join(repoDir, "package.json") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" const targetBinPath = join(targetBinDir, binName) -const versionFile = join(targetBinDir, ".cli-version") +const versionFile = join(kiloVscodeDir, "node_modules", ".kilo-cli-version") function log(msg: string) { console.log(`[local-bin] ${msg}`) @@ -106,47 +106,61 @@ async function cliInputs() { } async function cliSourceHash() { - const inputs = await cliInputs() - const [tree, diff, extra, branch] = await Promise.all([ - $`git ls-tree -r HEAD -- ${inputs}`.cwd(repoDir).quiet(), - $`git diff --binary HEAD -- ${inputs}`.cwd(repoDir).quiet(), - $`git ls-files --others --exclude-standard -z -- ${inputs}`.cwd(repoDir).quiet(), - $`git branch --show-current`.cwd(repoDir).quiet(), - ]) - const env = Object.fromEntries( - [ - "GH_REPO", - "KILO_BUMP", - "KILO_BWRAP_CACHE", - "KILO_CHANNEL", - "KILO_MODELS_URL", - "KILO_PRE_RELEASE", - "KILO_RELEASE", - "KILO_SKIP_BUNDLED_BWRAP", - "KILO_VERSION", - "MODELS_DEV_API_JSON", - "ZIG", - ].map((key) => [key, process.env[key] ?? ""]), - ) - const hash = createHash("sha256") - .update(tree.text()) - .update(diff.text()) - .update(branch.text()) - .update(JSON.stringify(env)) - const files = extra.text().split("\0").filter(Boolean).sort() + try { + const inputs = await cliInputs() + const [tree, diff, extra, branch] = await Promise.all([ + $`git ls-tree -r HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git diff --binary HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git ls-files --others --exclude-standard -z -- ${inputs}`.cwd(repoDir).quiet(), + $`git branch --show-current`.cwd(repoDir).quiet(), + ]) + const env = Object.fromEntries( + [ + "GH_REPO", + "KILO_BUMP", + "KILO_BWRAP_CACHE", + "KILO_CHANNEL", + "KILO_MODELS_URL", + "KILO_PRE_RELEASE", + "KILO_RELEASE", + "KILO_SKIP_BUNDLED_BWRAP", + "KILO_VERSION", + "MODELS_DEV_API_JSON", + "ZIG", + ].map((key) => [key, process.env[key] ?? ""]), + ) + const hash = createHash("sha256") + .update(tree.text()) + .update(diff.text()) + .update(branch.text()) + .update(JSON.stringify(env)) + const files = extra.text().split("\0").filter(Boolean).sort() - for (const file of files) { - hash.update(file) - hash.update(new Uint8Array(await Bun.file(join(repoDir, file)).arrayBuffer())) + for (const file of files) { + hash.update(file) + hash.update(new Uint8Array(await Bun.file(join(repoDir, file)).arrayBuffer())) + } + + const models = process.env.MODELS_DEV_API_JSON + if (models) hash.update(new Uint8Array(await Bun.file(models).arrayBuffer())) + return hash.digest("hex") + } catch (err) { + log(`Could not determine CLI source hash: ${err instanceof Error ? err.message : String(err)}`) + return null } - - const models = process.env.MODELS_DEV_API_JSON - if (models) hash.update(new Uint8Array(await Bun.file(models).arrayBuffer())) - return hash.digest("hex") } async function isStale() { const hash = await cliSourceHash() + if (!hash) { + if (!compiledOnly) return false + try { + const stored: unknown = await Bun.file(versionFile).json() + return Reflect.get(stored, "kind") !== "compiled" + } catch { + return true + } + } try { const stored: unknown = await Bun.file(versionFile).json() if (!stored || typeof stored !== "object") return true @@ -160,7 +174,12 @@ async function isStale() { } async function writeVersion(kind: "compiled" | "wrapper") { - await Bun.write(versionFile, JSON.stringify({ input: await cliSourceHash(), target: platformTag(), kind }) + "\n") + const input = await cliSourceHash() + if (!input) { + rmSync(versionFile, { force: true }) + return + } + await Bun.write(versionFile, JSON.stringify({ input, target: platformTag(), kind }) + "\n") } function platformTag(): string { @@ -296,6 +315,9 @@ async function writeSourceWrapper() { } async function main() { + for (const file of [join(targetBinDir, ".cli-version"), join(targetBinDir, ".ffmpeg-target")]) { + rmSync(file, { force: true }) + } const targetFile = Bun.file(targetBinPath) const exists = await targetFile.exists() const ready = exists && hasTreeSitterResources(targetBinPath) && hasKiloSandboxWorker(targetBinPath) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4c61c085d5f..76736609bfd 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -4316,7 +4316,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (directory && !this.isCurrentProjectDirectory(directory)) return + if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return if ( this.projectID && (!this.opts.projectQualifier || !directory) && @@ -4770,9 +4770,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const part = event.properties.part as { type?: string metadata?: Record - state?: { input?: Record; metadata?: Record } + state?: { status?: string; input?: Record; metadata?: Record } } if (part.type !== "tool") return + if (part.state?.status !== "completed") return const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath] const file = values.find((value): value is string => typeof value === "string" && value.length > 0) if (!file) return diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 732a08a92c5..b3af1f80eb2 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -178,6 +178,7 @@ describe("KiloProvider follow-up sessions", () => { part: { type: "tool", metadata: { filepath: "/workspace/frontend/src/app.ts" }, + state: { status: "completed" }, }, }, } as Event, From 3d079d29f067b323a61fb897cc711ae3068437cb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 18:51:00 +0200 Subject: [PATCH 020/157] fix(vscode): keep snapshot cache markers out of package --- packages/kilo-vscode/.vscodeignore | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kilo-vscode/.vscodeignore b/packages/kilo-vscode/.vscodeignore index 4f0ac692047..157f9841d78 100644 --- a/packages/kilo-vscode/.vscodeignore +++ b/packages/kilo-vscode/.vscodeignore @@ -29,7 +29,6 @@ AGENTS.md # not ship in the VSIX, otherwise production installs are detected as local # builds and may inject a dev-only bwrap fallback (see ServerManager.localCli). bin/.cli-version -bin/.ffmpeg-target # Include WAV assets used by extension-host notification playback !audio-wav/** From 0983e07d3336c9e8e3bb10598c451676a587bf35 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 12:51:57 -0400 Subject: [PATCH 021/157] feat(jetbrains): full-file diff detail for editor tabs Add on-demand full-content diff loading for the JetBrains diff editor tab while keeping inline turn cards hunk-bounded. Adds a full/file query path to the CLI session diff endpoint and a diffFile RPC that matches the requested path so multi-file turns are not collapsed to a single entry. --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 44 ++++++++++ .../backend/rpc/KiloSessionRpcApiImplTest.kt | 83 +++++++++++++++++++ .../kilocode/backend/testing/MockCliServer.kt | 6 ++ .../kilocode/client/app/KiloSessionService.kt | 3 + .../ai/kilocode/client/diff/DiffBlocks.kt | 3 + .../client/diff/KiloDiffEditorKind.kt | 28 ++++++- .../ai/kilocode/client/diff/DiffBlocksTest.kt | 26 ++++++ .../client/testing/FakeSessionRpcApi.kt | 7 ++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 3 + .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 2 + .../routes/instance/httpapi/groups/session.ts | 3 +- .../instance/httpapi/handlers/session.ts | 9 +- packages/opencode/src/session/summary.ts | 43 +++++++++- packages/opencode/src/snapshot/index.ts | 82 ++++++++++++++++++ 14 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 82442564a8c..c29e6e6c62b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -33,6 +33,12 @@ 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.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request /** * Backend implementation of [KiloSessionRpcApi]. @@ -155,6 +161,44 @@ class KiloSessionRpcApiImpl internal constructor( } } + override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? = ready { + val api = app.api ?: throw IllegalStateException("Kilo API is unavailable") + withContext(Dispatchers.IO) { + val url = (api.baseUrl.trimEnd('/') + "/").toHttpUrlOrNull() + ?.newBuilder() + ?.addPathSegment("session") + ?.addPathSegment(id) + ?.addPathSegment("diff") + ?.addQueryParameter("directory", directory) + ?.addQueryParameter("full", "true") + ?.addQueryParameter("file", file) + ?.apply { if (!messageId.isNullOrBlank()) addQueryParameter("messageID", messageId) } + ?.build() + ?: throw IllegalStateException("Kilo API URL is invalid") + api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> + if (!response.isSuccessful) throw IllegalStateException("Kilo API diff detail failed: ${response.code}") + val body = response.body?.string().orEmpty() + // A CLI without full/file support ignores those query params and returns the whole + // diff array, so match the requested path instead of taking the first entry — + // otherwise every file would resolve to the same first diff. + Json.parseToJsonElement(body).jsonArray + .mapNotNull { it.jsonObject.takeIf { obj -> obj["file"]?.jsonPrimitive?.content == file } } + .firstOrNull() + ?.let { item -> + DiffFileDto( + file, + item["additions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, + item["deletions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, + item["patch"]?.jsonPrimitive?.content, + item["status"]?.jsonPrimitive?.content, + item["before"]?.jsonPrimitive?.content, + item["after"]?.jsonPrimitive?.content, + ) + } + } + } + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt index c5f1f98158a..eb239a1f6f5 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt @@ -1,19 +1,41 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.AfterTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertTrue class KiloSessionRpcApiImplTest { + private val apps = mutableListOf() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() = runBlocking { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + } @Test fun `events logs normal completion`() = runBlocking(Dispatchers.Default) { @@ -49,4 +71,65 @@ class KiloSessionRpcApiImplTest { assertTrue(log.messages.any { it.contains("route=rpc-events stop=true failed message=stream failed") }, log.messages.joinToString("\n")) } + + @Test + fun `diffFile loads full detail with message scope`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + try { + mock.sessionDiff = """ + [{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"@@ -1 +1 @@\n-old\n+new\n","before":"old\nkeep\n","after":"new\nkeep\n"}] + """.trimIndent() + val api = KiloSessionRpcApiImpl(app(mock)) + + val diff = api.diffFile("ses_test", "/work", "src/Main.kt", "msg1") + + assertNotNull(diff) + assertEquals("old\nkeep\n", diff.before) + assertEquals("new\nkeep\n", diff.after) + val path = assertNotNull(mock.lastSessionDiffPath) + assertTrue(path.contains("full=true"), path) + assertTrue(path.contains("file=src%2FMain.kt"), path) + assertTrue(path.contains("messageID=msg1"), path) + } finally { + mock.close() + } + } + + @Test + fun `diffFile matches the requested file when server returns the whole diff`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + try { + // A CLI without full/file support ignores the params and returns every changed file; + // diffFile must select the requested path, not the first entry. + mock.sessionDiff = """ + [{"file":"src/A.kt","additions":1,"deletions":0,"status":"modified","patch":"a"}, + {"file":"src/B.kt","additions":2,"deletions":0,"status":"modified","patch":"b"}] + """.trimIndent() + val api = KiloSessionRpcApiImpl(app(mock)) + + val diff = api.diffFile("ses_test", "/work", "src/B.kt", null) + + assertNotNull(diff) + assertEquals("src/B.kt", diff.file) + assertEquals(2, diff.additions) + } finally { + mock.close() + } + } + + private suspend fun app(mock: MockCliServer): KiloBackendAppService { + val log = TestLog() + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } + app.connect() + val state = assertNotNull( + withTimeoutOrNull(35_000) { + app.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${app.appState.value}; logs=${log.messages}", + ) + assertIs(state, "App startup failed; logs=${log.messages}") + return app + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index d016709a685..fae9829a5e9 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -101,6 +101,7 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" + @Volatile var sessionDiff = "[]" @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @@ -140,6 +141,7 @@ class MockCliServer : AutoCloseable { @Volatile var lastSessionRenamePath: String? = null @Volatile var lastSessionRenameBody: String? = null @Volatile var lastSessionRenameMethod: String? = null + @Volatile var lastSessionDiffPath: String? = null @Volatile var pendingPermissions = "[]" @Volatile var pendingQuestions = "[]" @@ -431,6 +433,10 @@ class MockCliServer : AutoCloseable { lastSessionRenameMethod = method respond(output, sessionRenameStatus, sessionRenameResponse) } + bare.matches(Regex("/session/ses_[^/]+/diff")) && method == "GET" -> { + lastSessionDiffPath = path + respond(output, 200, sessionDiff) + } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 5a36f3f1905..dd39aaa22f1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -219,6 +219,9 @@ class KiloSessionService internal constructor( suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } + suspend fun diffFile(id: String, dir: String, file: String, messageId: String?): DiffFileDto? = + call { diffFile(id, dir, file, messageId) } + suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index 538a1686ace..c7a3c921e23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -22,13 +22,16 @@ internal fun diffRequest( val status = fileStatus(dto) val patch = dto.patch?.takeIf { it.isNotBlank() } val fallback = patch ?: KiloBundle.message("diff.editor.patch.unavailable") + val full = dto.before != null && dto.after != null val left = when { + full -> factory.create(project, dto.before.orEmpty(), type) DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty() sides.renderable -> factory.create(project, sides.before, type) status == FileStatus.DELETED -> factory.create(project, fallback, type) else -> factory.createEmpty() } val right = when { + full -> factory.create(project, dto.after.orEmpty(), type) DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty() sides.renderable -> factory.create(project, sides.after, type) status == FileStatus.DELETED -> factory.createEmpty() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index f8d61745f8e..6973eb01b9f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -150,17 +150,41 @@ internal class KiloDiffEditorService( val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty val workspace = service() val store = project.service() + val session = project.service() 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().diff(params["sessionId"].orEmpty(), dir) + else -> session.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) + return DiffEditorData.Files(detail(params, dir, files, session), branch) + } + + private suspend fun detail( + params: Map, + dir: String, + files: List, + session: KiloSessionService, + ): List { + if (params["source"] == "branch") return files + val id = params["sessionId"].takeIfPresent() ?: return files + val message = message(params) + return files.map { file -> + runCatching { session.diffFile(id, dir, file.file, message) } + .getOrNull() + ?: file + } + } + + private fun message(params: Map): String? { + val token = params["token"].takeIfPresent() ?: return null + val parts = token.split(":", limit = 3) + if (parts.size != 3 || parts[0] != "turn") return null + return parts[2].takeIfPresent() } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt new file mode 100644 index 00000000000..01307f892b1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.diff.contents.DocumentContent +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class DiffBlocksTest : BasePlatformTestCase() { + fun `test diffRequest uses full sides when available`() { + val request = diffRequest( + project, + DiffFileDto( + file = "src/Main.kt", + additions = 1, + deletions = 1, + patch = "@@ -1 +1 @@\n-old\n+new\n", + status = "modified", + before = "old\nkeep\n", + after = "new\nkeep\n", + ), + ) as SimpleDiffRequest + + assertEquals("old\nkeep\n", (request.contents[0] as DocumentContent).document.text) + assertEquals("new\nkeep\n", (request.contents[1] as DocumentContent).document.text) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index ce540a25d48..d6f1a0ee651 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -49,6 +49,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val history = mutableListOf() val histories = mutableMapOf>() val diffs = mutableMapOf>() + val diffFiles = mutableMapOf() var historyGate: CompletableDeferred? = null var historyCalls = 0 private set @@ -259,6 +260,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return diffs[id]?.toList().orEmpty() } + override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? { + assertNotEdt("diffFile") + return diffFiles["$id\u0000$directory\u0000$file\u0000${messageId.orEmpty()}"] + ?: diffs[id]?.firstOrNull { it.file == file } + } + 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)) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 0eaa3c5c01c..dccff6811cd 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -103,6 +103,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Load cumulative file changes for a session. */ suspend fun diff(id: String, directory: String): List + /** Load one full-content diff entry for a session or turn editor tab. */ + suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): 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? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 4d31c3a5517..ecf0da6a410 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -410,6 +410,8 @@ data class DiffFileDto( val deletions: Int, val patch: String? = null, val status: String? = null, + val before: String? = null, + val after: String? = null, ) // --- Config Update --- diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 02837849be9..afbb61bcd0a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -38,7 +38,8 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID", "full"]), // kilocode_change + full: Schema.optional(QueryBoolean), // kilocode_change }) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index eba9b764589..805df07f275 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -105,7 +105,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) + // kilocode_change start - pass full-file detail query fields through to summary service + return yield* summary.diff({ + sessionID: ctx.params.sessionID, + messageID: ctx.query.messageID, + full: ctx.query.full, + file: ctx.query.file, + }) + // kilocode_change end }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d540de86173..2dc3ea21064 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -67,7 +67,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly diff: (input: DiffInput) => Effect.Effect // kilocode_change readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect } @@ -82,10 +82,11 @@ export const layer = Layer.effect( const config = yield* Config.Service const storage = yield* Storage.Service // kilocode_change - const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { + // kilocode_change start - share snapshot ref extraction with lazy diff detail + const refs = (messages: SessionV1.WithParts[]) => { let from: string | undefined let to: string | undefined - for (const item of input.messages) { + for (const item of messages) { if (!from) { for (const part of item.parts) { if (part.type === "step-start" && part.snapshot) { @@ -98,9 +99,29 @@ export const layer = Layer.effect( if (part.type === "step-finish" && part.snapshot) to = part.snapshot } } + return { from, to } + } + // kilocode_change end + + // kilocode_change start - reuse snapshot refs for lazy diff detail + const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { + const { from, to } = refs(input.messages) // kilocode_change if (from && to) return yield* snapshot.diffFull(from, to) return [] }) + // kilocode_change end + + // kilocode_change start - lazy full-content detail for editor diff tabs + const computeFile = Effect.fn("SessionSummary.computeFile")(function* (input: { + messages: SessionV1.WithParts[] + file: string + }) { + const { from, to } = refs(input.messages) + if (!from || !to) return [] + const diff = yield* snapshot.diffFile(from, to, input.file) + return diff ? [diff] : [] + }) + // kilocode_change end const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { sessionID: SessionID @@ -144,7 +165,19 @@ export const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) - const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const diff = Effect.fn("SessionSummary.diff")(function* (input: DiffInput) { // kilocode_change + // kilocode_change start - compute on-demand full-file detail from turn/session snapshots + if (input.full && input.file) { + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const messages = input.messageID + ? all.filter( + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), + ) + : all + return yield* computeFile({ messages, file: input.file }) + } + // kilocode_change end + // kilocode_change start - retain cumulative diffs for legacy TUI and VS Code consumers if (!input.messageID) { const diffs = yield* storage @@ -192,6 +225,8 @@ export const defaultLayer = Layer.suspend(() => export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), + full: Schema.optional(Schema.Boolean), // kilocode_change + file: Schema.optional(Schema.String), // kilocode_change }) export type DiffInput = Schema.Schema.Type diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a1d74809783..38a10054c3a 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,6 +34,8 @@ export const FileDiff = Schema.Struct({ // session response and broke session loading on Desktop. file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), + before: Schema.optional(Schema.String), // kilocode_change + after: Schema.optional(Schema.String), // kilocode_change additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), @@ -64,6 +66,7 @@ interface GitResult { } export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change +export const MAX_DIFF_DETAIL_SIZE = 20 * 1024 * 1024 // kilocode_change type State = Omit @@ -82,6 +85,7 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect + readonly diffFile: (from: string, to: string, file: string) => Effect.Effect // kilocode_change } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -883,6 +887,78 @@ export const layer: Layer.Layer = ) }) + // kilocode_change start - lazy full-content detail for editor diff tabs + const diffFile = Effect.fnUntraced(function* (from: string, to: string, file: string) { + return yield* locked( + Effect.gen(function* () { + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", file])], + { cwd: state.directory }, + ) + const row = statuses.text.trim().split("\n").find(Boolean) + if (!row) return + const [code] = row.split("\t") + const status = code?.startsWith("A") ? "added" : code?.startsWith("D") ? "deleted" : "modified" + + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", file])], + { cwd: state.directory }, + ) + const stat = numstat.text.trim().split("\n").find(Boolean) + const [adds, dels] = stat?.split("\t") ?? [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : Number.parseInt(adds ?? "0", 10) + const deletions = binary ? 0 : Number.parseInt(dels ?? "0", 10) + + const patch = binary + ? "" + : ((yield* DiffFull.batch( + (cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }), + from, + to, + [file], + )).get(file) ?? "") + + if (binary) { + return { + file, + patch, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status, + } + } + + const content = yield* Effect.all( + { + before: + status === "added" + ? Effect.succeed("") + : git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), + after: + status === "deleted" + ? Effect.succeed("") + : git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), + }, + { concurrency: 2 }, + ) + const before = Buffer.byteLength(content.before) <= MAX_DIFF_DETAIL_SIZE ? content.before : undefined + const after = Buffer.byteLength(content.after) <= MAX_DIFF_DETAIL_SIZE ? content.after : undefined + + return { + file, + patch, + before, + after, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status, + } + }), + ) + }) + // kilocode_change end + yield* materialize() // kilocode_change - resume interrupted snapshot object materialization yield* cleanup().pipe( @@ -968,6 +1044,12 @@ export const layer: Layer.Layer = return yield* Effect.promise(() => pending) // kilocode_change end }), + // kilocode_change start - lazy full-content detail for editor diff tabs + diffFile: Effect.fn("Snapshot.diffFile")(function* (from: string, to: string, file: string) { + if (from === to) return + return yield* InstanceState.useEffect(state, (s) => s.diffFile(from, to, file)) + }), + // kilocode_change end }) }), ) From 8650fdd0c486fb2c0cd3cc05c931ee9c3f55dabc Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 13:15:08 -0400 Subject: [PATCH 022/157] refactor(jetbrains): reconstruct full-file diffs locally Roll back the CLI diff full/file server changes and rebuild whole-file diffs entirely in the JetBrains plugin: the backend reads the working-tree file and reverse-applies the existing hunk patch to recover the full before side, falling back to the hunk view when the tree has drifted. Works against any pinned CLI with no server change. --- .../backend/diff/DiffFullReconstruct.kt | 80 ++++++++++++++ .../backend/rpc/KiloSessionRpcApiImpl.kt | 53 ++------- .../backend/diff/DiffFullReconstructTest.kt | 55 ++++++++++ .../backend/rpc/KiloSessionRpcApiImplTest.kt | 103 +++++++----------- .../kilocode/backend/testing/MockCliServer.kt | 6 - .../kilocode/client/app/KiloSessionService.kt | 4 +- .../client/diff/KiloDiffEditorKind.kt | 26 ++--- .../client/testing/FakeSessionRpcApi.kt | 9 +- .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 8 +- .../routes/instance/httpapi/groups/session.ts | 3 +- .../instance/httpapi/handlers/session.ts | 9 +- packages/opencode/src/session/summary.ts | 43 +------- packages/opencode/src/snapshot/index.ts | 82 -------------- 13 files changed, 213 insertions(+), 268 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt new file mode 100644 index 00000000000..cd7e51379bc --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt @@ -0,0 +1,80 @@ +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. + */ +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(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() + val beforeBody = ArrayList() + + 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" + 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 ") } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index c29e6e6c62b..97dad25d056 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -33,12 +33,9 @@ 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.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]. @@ -161,41 +158,15 @@ class KiloSessionRpcApiImpl internal constructor( } } - override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? = ready { - val api = app.api ?: throw IllegalStateException("Kilo API is unavailable") - withContext(Dispatchers.IO) { - val url = (api.baseUrl.trimEnd('/') + "/").toHttpUrlOrNull() - ?.newBuilder() - ?.addPathSegment("session") - ?.addPathSegment(id) - ?.addPathSegment("diff") - ?.addQueryParameter("directory", directory) - ?.addQueryParameter("full", "true") - ?.addQueryParameter("file", file) - ?.apply { if (!messageId.isNullOrBlank()) addQueryParameter("messageID", messageId) } - ?.build() - ?: throw IllegalStateException("Kilo API URL is invalid") - api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> - if (!response.isSuccessful) throw IllegalStateException("Kilo API diff detail failed: ${response.code}") - val body = response.body?.string().orEmpty() - // A CLI without full/file support ignores those query params and returns the whole - // diff array, so match the requested path instead of taking the first entry — - // otherwise every file would resolve to the same first diff. - Json.parseToJsonElement(body).jsonArray - .mapNotNull { it.jsonObject.takeIf { obj -> obj["file"]?.jsonPrimitive?.content == file } } - .firstOrNull() - ?.let { item -> - DiffFileDto( - file, - item["additions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, - item["deletions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, - item["patch"]?.jsonPrimitive?.content, - item["status"]?.jsonPrimitive?.content, - item["before"]?.jsonPrimitive?.content, - item["after"]?.jsonPrimitive?.content, - ) - } - } + override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + val patch = file.patch + if (patch.isNullOrBlank()) return null + // Full-file diffs are rebuilt locally: 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 after = runCatching { Files.readString(Path.of(directory).resolve(file.file)) }.getOrNull() + val before = after?.let { DiffFullReconstruct.before(it, patch) } + if (after != null && before != null) file.copy(before = before, after = after) else null } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt new file mode 100644 index 00000000000..993ec8928e4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt @@ -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", "")) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt index eb239a1f6f5..2c6413d750a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt @@ -1,41 +1,25 @@ package ai.kilocode.backend.rpc -import ai.kilocode.backend.app.KiloAppState -import ai.kilocode.backend.app.KiloBackendAppService -import ai.kilocode.backend.testing.FakeCliServer -import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto -import kotlinx.coroutines.CoroutineScope +import ai.kilocode.rpc.dto.DiffFileDto import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.test.AfterTest +import java.nio.file.Files +import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class KiloSessionRpcApiImplTest { - private val apps = mutableListOf() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - @AfterTest - fun tearDown() = runBlocking { - apps.forEach { it.dispose() } - apps.clear() - scope.cancel() - } @Test fun `events logs normal completion`() = runBlocking(Dispatchers.Default) { @@ -73,63 +57,56 @@ class KiloSessionRpcApiImplTest { } @Test - fun `diffFile loads full detail with message scope`() = runBlocking(Dispatchers.Default) { - val mock = MockCliServer() + fun `diffSides rebuilds full before by reverse-applying the patch to the working tree`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") try { - mock.sessionDiff = """ - [{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"@@ -1 +1 @@\n-old\n+new\n","before":"old\nkeep\n","after":"new\nkeep\n"}] - """.trimIndent() - val api = KiloSessionRpcApiImpl(app(mock)) + val file = "src/Main.kt" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve(file), "a\nB2\nc\n") + val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - val diff = api.diffFile("ses_test", "/work", "src/Main.kt", "msg1") + val diff = KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified")) assertNotNull(diff) - assertEquals("old\nkeep\n", diff.before) - assertEquals("new\nkeep\n", diff.after) - val path = assertNotNull(mock.lastSessionDiffPath) - assertTrue(path.contains("full=true"), path) - assertTrue(path.contains("file=src%2FMain.kt"), path) - assertTrue(path.contains("messageID=msg1"), path) + assertEquals("a\nb2\nc\n", diff.before) + assertEquals("a\nB2\nc\n", diff.after) } finally { - mock.close() + delete(dir) } } @Test - fun `diffFile matches the requested file when server returns the whole diff`() = runBlocking(Dispatchers.Default) { - val mock = MockCliServer() + fun `diffSides returns null when the working tree drifted from the patch`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") try { - // A CLI without full/file support ignores the params and returns every changed file; - // diffFile must select the requested path, not the first entry. - mock.sessionDiff = """ - [{"file":"src/A.kt","additions":1,"deletions":0,"status":"modified","patch":"a"}, - {"file":"src/B.kt","additions":2,"deletions":0,"status":"modified","patch":"b"}] - """.trimIndent() - val api = KiloSessionRpcApiImpl(app(mock)) + val file = "src/Main.kt" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve(file), "a\nUNRELATED\nc\n") + val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - val diff = api.diffFile("ses_test", "/work", "src/B.kt", null) - - assertNotNull(diff) - assertEquals("src/B.kt", diff.file) - assertEquals(2, diff.additions) + assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"))) } finally { - mock.close() + delete(dir) } } - private suspend fun app(mock: MockCliServer): KiloBackendAppService { - val log = TestLog() - val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } - app.connect() - val state = assertNotNull( - withTimeoutOrNull(35_000) { - app.appState.first { - it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired - } - }, - "App startup timed out in ${app.appState.value}; logs=${log.messages}", - ) - assertIs(state, "App startup failed; logs=${log.messages}") - return app + @Test + fun `diffSides returns null for added files and missing patches`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") + try { + Files.writeString(dir.resolve("new.kt"), "hello\n") + val added = "--- /dev/null\n+++ b/new.kt\n@@ -0,0 +1 @@\n+hello\n" + + assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, added, "added"))) + assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, null, "added"))) + } finally { + delete(dir) + } + } + + private fun delete(dir: java.nio.file.Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index fae9829a5e9..d016709a685 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -101,7 +101,6 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" - @Volatile var sessionDiff = "[]" @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @@ -141,7 +140,6 @@ class MockCliServer : AutoCloseable { @Volatile var lastSessionRenamePath: String? = null @Volatile var lastSessionRenameBody: String? = null @Volatile var lastSessionRenameMethod: String? = null - @Volatile var lastSessionDiffPath: String? = null @Volatile var pendingPermissions = "[]" @Volatile var pendingQuestions = "[]" @@ -433,10 +431,6 @@ class MockCliServer : AutoCloseable { lastSessionRenameMethod = method respond(output, sessionRenameStatus, sessionRenameResponse) } - bare.matches(Regex("/session/ses_[^/]+/diff")) && method == "GET" -> { - lastSessionDiffPath = path - respond(output, 200, sessionDiff) - } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index dd39aaa22f1..f2925161a4b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -219,8 +219,8 @@ class KiloSessionService internal constructor( suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } - suspend fun diffFile(id: String, dir: String, file: String, messageId: String?): DiffFileDto? = - call { diffFile(id, dir, file, messageId) } + suspend fun diffSides(dir: String, file: DiffFileDto): DiffFileDto? = + call { diffSides(dir, file) } suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index 6973eb01b9f..0c4fde54f11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -161,30 +161,20 @@ internal class KiloDiffEditorService( if (files.isEmpty()) return DiffEditorData.Empty val branch = params["branch"].takeIfPresent() ?: if (params["source"] == "branch") workspace.branchName(dir) else null - return DiffEditorData.Files(detail(params, dir, files, session), branch) + return DiffEditorData.Files(detail(dir, files, session), branch) } + // Enrich modified files with full before/after content so the editor shows whole-file diffs. + // Added/deleted/binary files already render fully from their patch, so they skip the round-trip; + // a null result (working tree drifted from the patch) falls back to the hunk view. private suspend fun detail( - params: Map, dir: String, files: List, session: KiloSessionService, - ): List { - if (params["source"] == "branch") return files - val id = params["sessionId"].takeIfPresent() ?: return files - val message = message(params) - return files.map { file -> - runCatching { session.diffFile(id, dir, file.file, message) } - .getOrNull() - ?: file - } - } - - private fun message(params: Map): String? { - val token = params["token"].takeIfPresent() ?: return null - val parts = token.split(":", limit = 3) - if (parts.size != 3 || parts[0] != "turn") return null - return parts[2].takeIfPresent() + ): List = files.map { file -> + val patch = file.patch + if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file + else runCatching { session.diffSides(dir, file) }.getOrNull() ?: file } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index d6f1a0ee651..1087cf75313 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -49,7 +49,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val history = mutableListOf() val histories = mutableMapOf>() val diffs = mutableMapOf>() - val diffFiles = mutableMapOf() + val diffSides = mutableMapOf() var historyGate: CompletableDeferred? = null var historyCalls = 0 private set @@ -260,10 +260,9 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return diffs[id]?.toList().orEmpty() } - override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? { - assertNotEdt("diffFile") - return diffFiles["$id\u0000$directory\u0000$file\u0000${messageId.orEmpty()}"] - ?: diffs[id]?.firstOrNull { it.file == file } + override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + assertNotEdt("diffSides") + return diffSides[file.file] } override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index dccff6811cd..e9752ae0be9 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -103,8 +103,12 @@ interface KiloSessionRpcApi : RemoteApi { /** Load cumulative file changes for a session. */ suspend fun diff(id: String, directory: String): List - /** Load one full-content diff entry for a session or turn editor tab. */ - suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? + /** + * Rebuild full before/after content for one changed file so the diff editor can show a whole-file + * diff. Returns null when the file's working-tree content no longer matches the patch (fall back to + * the hunk view). Added/deleted files return null because the frontend reconstructs those directly. + */ + suspend fun diffSides(directory: String, file: DiffFileDto): 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? diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index afbb61bcd0a..02837849be9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -38,8 +38,7 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID", "full"]), // kilocode_change - full: Schema.optional(QueryBoolean), // kilocode_change + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), }) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 805df07f275..eba9b764589 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -105,14 +105,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - // kilocode_change start - pass full-file detail query fields through to summary service - return yield* summary.diff({ - sessionID: ctx.params.sessionID, - messageID: ctx.query.messageID, - full: ctx.query.full, - file: ctx.query.file, - }) - // kilocode_change end + return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 2dc3ea21064..d540de86173 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -67,7 +67,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly diff: (input: DiffInput) => Effect.Effect // kilocode_change + readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect } @@ -82,11 +82,10 @@ export const layer = Layer.effect( const config = yield* Config.Service const storage = yield* Storage.Service // kilocode_change - // kilocode_change start - share snapshot ref extraction with lazy diff detail - const refs = (messages: SessionV1.WithParts[]) => { + const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { let from: string | undefined let to: string | undefined - for (const item of messages) { + for (const item of input.messages) { if (!from) { for (const part of item.parts) { if (part.type === "step-start" && part.snapshot) { @@ -99,29 +98,9 @@ export const layer = Layer.effect( if (part.type === "step-finish" && part.snapshot) to = part.snapshot } } - return { from, to } - } - // kilocode_change end - - // kilocode_change start - reuse snapshot refs for lazy diff detail - const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { - const { from, to } = refs(input.messages) // kilocode_change if (from && to) return yield* snapshot.diffFull(from, to) return [] }) - // kilocode_change end - - // kilocode_change start - lazy full-content detail for editor diff tabs - const computeFile = Effect.fn("SessionSummary.computeFile")(function* (input: { - messages: SessionV1.WithParts[] - file: string - }) { - const { from, to } = refs(input.messages) - if (!from || !to) return [] - const diff = yield* snapshot.diffFile(from, to, input.file) - return diff ? [diff] : [] - }) - // kilocode_change end const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { sessionID: SessionID @@ -165,19 +144,7 @@ export const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) - const diff = Effect.fn("SessionSummary.diff")(function* (input: DiffInput) { // kilocode_change - // kilocode_change start - compute on-demand full-file detail from turn/session snapshots - if (input.full && input.file) { - const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) - const messages = input.messageID - ? all.filter( - (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), - ) - : all - return yield* computeFile({ messages, file: input.file }) - } - // kilocode_change end - + const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { // kilocode_change start - retain cumulative diffs for legacy TUI and VS Code consumers if (!input.messageID) { const diffs = yield* storage @@ -225,8 +192,6 @@ export const defaultLayer = Layer.suspend(() => export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), - full: Schema.optional(Schema.Boolean), // kilocode_change - file: Schema.optional(Schema.String), // kilocode_change }) export type DiffInput = Schema.Schema.Type diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 38a10054c3a..a1d74809783 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,8 +34,6 @@ export const FileDiff = Schema.Struct({ // session response and broke session loading on Desktop. file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), - before: Schema.optional(Schema.String), // kilocode_change - after: Schema.optional(Schema.String), // kilocode_change additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), @@ -66,7 +64,6 @@ interface GitResult { } export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change -export const MAX_DIFF_DETAIL_SIZE = 20 * 1024 * 1024 // kilocode_change type State = Omit @@ -85,7 +82,6 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect - readonly diffFile: (from: string, to: string, file: string) => Effect.Effect // kilocode_change } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -887,78 +883,6 @@ export const layer: Layer.Layer = ) }) - // kilocode_change start - lazy full-content detail for editor diff tabs - const diffFile = Effect.fnUntraced(function* (from: string, to: string, file: string) { - return yield* locked( - Effect.gen(function* () { - const statuses = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", file])], - { cwd: state.directory }, - ) - const row = statuses.text.trim().split("\n").find(Boolean) - if (!row) return - const [code] = row.split("\t") - const status = code?.startsWith("A") ? "added" : code?.startsWith("D") ? "deleted" : "modified" - - const numstat = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", file])], - { cwd: state.directory }, - ) - const stat = numstat.text.trim().split("\n").find(Boolean) - const [adds, dels] = stat?.split("\t") ?? [] - const binary = adds === "-" && dels === "-" - const additions = binary ? 0 : Number.parseInt(adds ?? "0", 10) - const deletions = binary ? 0 : Number.parseInt(dels ?? "0", 10) - - const patch = binary - ? "" - : ((yield* DiffFull.batch( - (cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }), - from, - to, - [file], - )).get(file) ?? "") - - if (binary) { - return { - file, - patch, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - status, - } - } - - const content = yield* Effect.all( - { - before: - status === "added" - ? Effect.succeed("") - : git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), - after: - status === "deleted" - ? Effect.succeed("") - : git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), - }, - { concurrency: 2 }, - ) - const before = Buffer.byteLength(content.before) <= MAX_DIFF_DETAIL_SIZE ? content.before : undefined - const after = Buffer.byteLength(content.after) <= MAX_DIFF_DETAIL_SIZE ? content.after : undefined - - return { - file, - patch, - before, - after, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - status, - } - }), - ) - }) - // kilocode_change end - yield* materialize() // kilocode_change - resume interrupted snapshot object materialization yield* cleanup().pipe( @@ -1044,12 +968,6 @@ export const layer: Layer.Layer = return yield* Effect.promise(() => pending) // kilocode_change end }), - // kilocode_change start - lazy full-content detail for editor diff tabs - diffFile: Effect.fn("Snapshot.diffFile")(function* (from: string, to: string, file: string) { - if (from === to) return - return yield* InstanceState.useEffect(state, (s) => s.diffFile(from, to, file)) - }), - // kilocode_change end }) }), ) From 6948a170fe73616ade6a2ad21b4028199d935b59 Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 14:55:01 -0700 Subject: [PATCH 023/157] feat(charts-telemetry): Add telemetry for charting tool --- packages/opencode/src/kilocode/tool/chart.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/kilocode/tool/chart.ts b/packages/opencode/src/kilocode/tool/chart.ts index c928c46ea79..11253134f2e 100644 --- a/packages/opencode/src/kilocode/tool/chart.ts +++ b/packages/opencode/src/kilocode/tool/chart.ts @@ -1,6 +1,7 @@ // kilocode_change - new file import { Effect, Schema } from "effect" import * as Tool from "../../tool/tool" +import { Telemetry } from "@kilocode/kilo-telemetry" const Parameters = Schema.Struct({ title: Schema.String.annotate({ @@ -66,6 +67,8 @@ export const ChartTool = Tool.define( } } + Telemetry.trackToolUsed("chart", ctx.sessionID) + return { title: params.title, output: JSON.stringify(spec), From 1d103bfa56b4d52648166f206a0d7292577f3642 Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 15:05:46 -0700 Subject: [PATCH 024/157] feat(charts-telemetry): Add telemetry unit test for tracking tool --- .../kilo-telemetry/src/__tests__/telemetry.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index c86a249f51f..9246facb48c 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -126,4 +126,15 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackSuggestionShown).toBe("function") expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") }) + + test("trackToolUsed sends Tool Used event with tool name and sessionId", () => { + const capture = spyOn(Client, "capture").mockImplementation(() => {}) + + try { + Telemetry.trackToolUsed("chart", "session-123") + expect(capture).toHaveBeenCalledWith(TelemetryEvent.TOOL_USED, expect.objectContaining({ tool: "chart", sessionId: "session-123" })) + } finally { + capture.mockRestore() + } + }) }) From ebebad02e71c5771ccd791ae483b34db7b461d79 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 18:13:55 -0400 Subject: [PATCH 025/157] feat(jetbrains): authoritative full-file editor diffs with local fallback The diff editor tab now shows whole-file diffs (with collapsible unchanged regions) for modified files, while inline session cards stay hunk-bounded. diffSides resolves full before/after with a safe precedence: 1. Authoritative: query the CLI's snapshot diff (full=true&file=...), which reads the exact turn's before/after from snapshot commits via git show and is correct even for historical/reverted turns. 2. Fallback: reverse-apply the hunk patch onto the working-tree file locally, so it works against the current pinned CLI until the new one is released. 3. Hunk view: if neither yields full content (drift/binary/added/deleted), render the existing patch. The CLI change is minimal and additive: full-content logic lives in the kilo-owned diff-full.ts (DiffFull.detail); shared files only gain thin kilocode_change hooks (FileDiff before/after, Snapshot.diffFile, the summary full/file branch, and the diff query fields). --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 48 +++++++++- .../backend/rpc/KiloSessionRpcApiImplTest.kt | 90 ++++++++++++++++++- .../kilocode/backend/testing/MockCliServer.kt | 6 ++ .../kilocode/client/app/KiloSessionService.kt | 4 +- .../client/diff/KiloDiffEditorKind.kt | 22 +++-- .../client/testing/FakeSessionRpcApi.kt | 2 +- .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 10 ++- .../src/kilocode/snapshot/diff-full.ts | 58 ++++++++++++ .../routes/instance/httpapi/groups/session.ts | 3 +- .../instance/httpapi/handlers/session.ts | 9 +- packages/opencode/src/session/summary.ts | 25 +++++- packages/opencode/src/snapshot/index.ts | 27 +++++- .../opencode/test/kilocode/diff-full.test.ts | 64 +++++++++++++ .../opencode/test/session/compaction.test.ts | 1 + 14 files changed, 345 insertions(+), 24 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 97dad25d056..28c8117b2a6 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -33,6 +33,13 @@ 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 @@ -158,11 +165,15 @@ class KiloSessionRpcApiImpl internal constructor( } } - override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { val patch = file.patch if (patch.isNullOrBlank()) return null - // Full-file diffs are rebuilt locally: 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. + // 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 { 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 after = runCatching { Files.readString(Path.of(directory).resolve(file.file)) }.getOrNull() val before = after?.let { DiffFullReconstruct.before(it, patch) } @@ -170,6 +181,37 @@ class KiloSessionRpcApiImpl internal constructor( } } + // 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) return@runCatching null + val item = Json.parseToJsonElement(response.body?.string().orEmpty()).jsonArray + .firstOrNull { it.jsonObject["file"]?.jsonPrimitive?.contentOrNull == file.file } + ?.jsonObject + ?: return@runCatching null + val before = item["before"]?.jsonPrimitive?.contentOrNull + val after = item["after"]?.jsonPrimitive?.contentOrNull + if (before != null && after != null) file.copy(before = before, after = after) else null + } + }.getOrNull() + } + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt index 2c6413d750a..a883eebc5b7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt @@ -1,25 +1,46 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import java.nio.file.Files import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue class KiloSessionRpcApiImplTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val apps = mutableListOf() + + @AfterTest + fun tearDown() = runBlocking { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + } + @Test fun `events logs normal completion`() = runBlocking(Dispatchers.Default) { @@ -65,7 +86,7 @@ class KiloSessionRpcApiImplTest { Files.writeString(dir.resolve(file), "a\nB2\nc\n") val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - val diff = KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified")) + val diff = KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"), null) assertNotNull(diff) assertEquals("a\nb2\nc\n", diff.before) @@ -84,7 +105,7 @@ class KiloSessionRpcApiImplTest { Files.writeString(dir.resolve(file), "a\nUNRELATED\nc\n") val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"))) + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"), null)) } finally { delete(dir) } @@ -97,13 +118,74 @@ class KiloSessionRpcApiImplTest { Files.writeString(dir.resolve("new.kt"), "hello\n") val added = "--- /dev/null\n+++ b/new.kt\n@@ -0,0 +1 @@\n+hello\n" - assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, added, "added"))) - assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, null, "added"))) + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto("new.kt", 1, 0, added, "added"), null)) + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto("new.kt", 1, 0, null, "added"), null)) } finally { delete(dir) } } + @Test + fun `diffSides prefers authoritative CLI content over local reconstruction`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + try { + mock.sessionDiff = + """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p","before":"OLD\n","after":"NEW\n"}]""" + val api = KiloSessionRpcApiImpl(app(mock)) + + // No working-tree file exists here, so a non-null result can only come from the CLI path. + val diff = api.diffSides("ses_test", "/does-not-exist", DiffFileDto("src/Main.kt", 1, 1, "p", "modified"), "msg1") + + assertNotNull(diff) + assertEquals("OLD\n", diff.before) + assertEquals("NEW\n", diff.after) + val path = assertNotNull(mock.lastSessionDiffPath) + assertTrue(path.contains("full=true"), path) + assertTrue(path.contains("file=src%2FMain.kt"), path) + assertTrue(path.contains("messageID=msg1"), path) + } finally { + mock.close() + } + } + + @Test + fun `diffSides falls back to local reconstruction when the CLI omits full content`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + val dir = createTempDirectory("kilo-diff") + try { + // A CLI without full/file support returns the file entry but no before/after. + mock.sessionDiff = """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p"}]""" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve("src/Main.kt"), "a\nB2\nc\n") + val patch = "--- a/src/Main.kt\n+++ b/src/Main.kt\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" + val api = KiloSessionRpcApiImpl(app(mock)) + + val diff = api.diffSides("ses_test", dir.toString(), DiffFileDto("src/Main.kt", 1, 1, patch, "modified"), "msg1") + + assertNotNull(diff) + assertEquals("a\nb2\nc\n", diff.before) + assertEquals("a\nB2\nc\n", diff.after) + } finally { + delete(dir) + mock.close() + } + } + + private suspend fun app(mock: MockCliServer): KiloBackendAppService { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog()).also { apps.add(it) } + app.connect() + val state = assertNotNull( + withTimeoutOrNull(35_000) { + app.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${app.appState.value}", + ) + assertIs(state, "App startup failed") + return app + } + private fun delete(dir: java.nio.file.Path) { Files.walk(dir).use { paths -> paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index d016709a685..910df5eefa4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -101,6 +101,8 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" + @Volatile var sessionDiff = "[]" + @Volatile var lastSessionDiffPath: String? = null @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @@ -431,6 +433,10 @@ class MockCliServer : AutoCloseable { lastSessionRenameMethod = method respond(output, sessionRenameStatus, sessionRenameResponse) } + bare.matches(Regex("/session/ses_[^/]+/diff")) && method == "GET" -> { + lastSessionDiffPath = path + respond(output, 200, sessionDiff) + } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index f2925161a4b..35df48eaa59 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -219,8 +219,8 @@ class KiloSessionService internal constructor( suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } - suspend fun diffSides(dir: String, file: DiffFileDto): DiffFileDto? = - call { diffSides(dir, file) } + suspend fun diffSides(sessionId: String?, dir: String, file: DiffFileDto, messageId: String?): DiffFileDto? = + call { diffSides(sessionId, dir, file, messageId) } suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index 0c4fde54f11..43ed3b89491 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -161,20 +161,32 @@ internal class KiloDiffEditorService( if (files.isEmpty()) return DiffEditorData.Empty val branch = params["branch"].takeIfPresent() ?: if (params["source"] == "branch") workspace.branchName(dir) else null - return DiffEditorData.Files(detail(dir, files, session), branch) + return DiffEditorData.Files(detail(params, dir, files, session), branch) } // Enrich modified files with full before/after content so the editor shows whole-file diffs. // Added/deleted/binary files already render fully from their patch, so they skip the round-trip; // a null result (working tree drifted from the patch) falls back to the hunk view. private suspend fun detail( + params: Map, dir: String, files: List, session: KiloSessionService, - ): List = files.map { file -> - val patch = file.patch - if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file - else runCatching { session.diffSides(dir, file) }.getOrNull() ?: file + ): List { + val sessionId = params["sessionId"].takeIfPresent() + val message = message(params) + return files.map { file -> + val patch = file.patch + if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file + else runCatching { session.diffSides(sessionId, dir, file, message) }.getOrNull() ?: file + } + } + + // Turn diffs carry a "turn::" token; the turn id is the message the CLI scopes + // the authoritative snapshot diff to. Other sources (session, branch) have no per-turn message. + private fun message(params: Map): String? { + val parts = params["token"].takeIfPresent()?.split(":", limit = 3) ?: return null + return if (parts.size == 3 && parts[0] == "turn") parts[2].takeIfPresent() else null } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 1087cf75313..b3951bfab33 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -260,7 +260,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return diffs[id]?.toList().orEmpty() } - override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { assertNotEdt("diffSides") return diffSides[file.file] } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index e9752ae0be9..9c5773a8961 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -104,11 +104,13 @@ interface KiloSessionRpcApi : RemoteApi { suspend fun diff(id: String, directory: String): List /** - * Rebuild full before/after content for one changed file so the diff editor can show a whole-file - * diff. Returns null when the file's working-tree content no longer matches the patch (fall back to - * the hunk view). Added/deleted files return null because the frontend reconstructs those directly. + * Full before/after content for one changed file so the diff editor can show a whole-file diff. + * Prefers authoritative snapshot content from a CLI that supports it (correct even for historical + * turns); falls back to rebuilding locally from the working tree + hunk patch against any pinned + * CLI. Returns null when neither is available (fall back to the hunk view). Added/deleted files + * return null because the frontend reconstructs those directly. */ - suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? + suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): 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? diff --git a/packages/opencode/src/kilocode/snapshot/diff-full.ts b/packages/opencode/src/kilocode/snapshot/diff-full.ts index 82d2b6789cf..532f6c713dd 100644 --- a/packages/opencode/src/kilocode/snapshot/diff-full.ts +++ b/packages/opencode/src/kilocode/snapshot/diff-full.ts @@ -84,6 +84,64 @@ export namespace DiffFull { return map }) + // Cap the full-content sides we return for a single editor diff tab. Anything larger falls back + // to the hunk-only view rather than shipping tens of MB of text over RPC. + export const MAX_DETAIL_SIZE = 20 * 1024 * 1024 + + /** + * Authoritative full-content detail for one file between two snapshot refs, for the editor diff + * tab. Returns status, additions/deletions, the hunk patch, and the whole before/after file + * contents (read from the snapshot objects via `git show`), so the client can render a whole-file + * diff regardless of the current working tree. `before`/`after` are omitted when a side exceeds + * [MAX_DETAIL_SIZE]; binary files carry no content. `run.diff` wraps quote+args, `run.show` wraps + * the plain config (no quotepath) so `git show ref:path` resolves correctly. + */ + export const detail = Effect.fn("DiffFull.detail")(function* ( + run: { + diff: (cmd: string[]) => Effect.Effect + show: (cmd: string[]) => Effect.Effect + }, + from: string, + to: string, + path: string, + ) { + const names = yield* run.diff(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", path]) + const row = names.text.trim().split("\n").find(Boolean) + if (!row) return undefined + const code = row.split("\t")[0] ?? "" + const status: "added" | "deleted" | "modified" = + code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + + const numstat = yield* run.diff(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", path]) + const [adds, dels] = numstat.text.trim().split("\n").find(Boolean)?.split("\t") ?? [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : Number.parseInt(adds ?? "0", 10) + const deletions = binary ? 0 : Number.parseInt(dels ?? "0", 10) + + const patch = binary ? "" : ((yield* batch(run.diff, from, to, [path])).get(path) ?? "") + const base = { + file: path, + patch, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status, + } + if (binary) return base + + const content = yield* Effect.all( + { + before: status === "added" ? Effect.succeed("") : run.show(["show", `${from}:${path}`]).pipe(Effect.map((r) => r.text)), + after: status === "deleted" ? Effect.succeed("") : run.show(["show", `${to}:${path}`]).pipe(Effect.map((r) => r.text)), + }, + { concurrency: 2 }, + ) + return { + ...base, + before: Buffer.byteLength(content.before) <= MAX_DETAIL_SIZE ? content.before : undefined, + after: Buffer.byteLength(content.after) <= MAX_DETAIL_SIZE ? content.after : undefined, + } + }) + /** * Generate a structured + unified diff for a single file in the working * tree vs HEAD using `git diff --ignore-all-space --unified=3`. diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 02837849be9..a7f5464a689 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -38,7 +38,8 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID", "full"]), // kilocode_change - full is a query boolean + full: Schema.optional(QueryBoolean), // kilocode_change - request full-content detail }) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index eba9b764589..885a3f98bdf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -105,7 +105,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) + // kilocode_change start - pass full-content detail query fields through to the summary service + return yield* summary.diff({ + sessionID: ctx.params.sessionID, + messageID: ctx.query.messageID, + full: ctx.query.full, + file: ctx.query.file, + }) + // kilocode_change end }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d540de86173..1b058c7b779 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -67,7 +67,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly diff: (input: DiffInput) => Effect.Effect // kilocode_change - full-content detail input readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect } @@ -144,7 +144,26 @@ export const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) - const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const diff = Effect.fn("SessionSummary.diff")(function* (input: DiffInput) { // kilocode_change - full-content detail input + // kilocode_change start - authoritative full-content detail for one file (editor diff tabs) + if (input.full && input.file) { + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const messages = input.messageID + ? all.filter( + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), + ) + : all + let from: string | undefined + let to: string | undefined + for (const item of messages) { + if (!from) for (const part of item.parts) if (part.type === "step-start" && part.snapshot) { from = part.snapshot; break } + for (const part of item.parts) if (part.type === "step-finish" && part.snapshot) to = part.snapshot + } + if (!from || !to) return [] + const detail = yield* snapshot.diffFile(from, to, input.file) + return detail ? [detail] : [] + } + // kilocode_change end // kilocode_change start - retain cumulative diffs for legacy TUI and VS Code consumers if (!input.messageID) { const diffs = yield* storage @@ -192,6 +211,8 @@ export const defaultLayer = Layer.suspend(() => export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), + full: Schema.optional(Schema.Boolean), // kilocode_change - request full-content detail + file: Schema.optional(Schema.String), // kilocode_change - scope full detail to one file }) export type DiffInput = Schema.Schema.Type diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a1d74809783..173861141e6 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,6 +34,8 @@ export const FileDiff = Schema.Struct({ // session response and broke session loading on Desktop. file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), + before: Schema.optional(Schema.String), // kilocode_change - full-content sides for editor diff tabs + after: Schema.optional(Schema.String), // kilocode_change - full-content sides for editor diff tabs additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), @@ -82,6 +84,7 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect + readonly diffFile: (from: string, to: string, file: string) => Effect.Effect // kilocode_change - authoritative full-content detail } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -892,7 +895,23 @@ export const layer: Layer.Layer = Effect.forkScoped, ) - return { cleanup, track, patch, restore, revert, diff, diffFull } + // kilocode_change start - authoritative full-content detail for editor diff tabs + const diffFile = Effect.fnUntraced(function* (from: string, to: string, file: string) { + return yield* locked( + DiffFull.detail( + { + diff: (cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }), + show: (cmd) => git([...cfg, ...args(cmd)], { cwd: state.directory }), + }, + from, + to, + file, + ), + ) + }) + // kilocode_change end + + return { cleanup, track, patch, restore, revert, diff, diffFull, diffFile } // kilocode_change - diffFile }), ) @@ -968,6 +987,12 @@ export const layer: Layer.Layer = return yield* Effect.promise(() => pending) // kilocode_change end }), + // kilocode_change start - authoritative full-content detail for editor diff tabs + diffFile: Effect.fn("Snapshot.diffFile")(function* (from: string, to: string, file: string) { + if (from === to) return undefined + return yield* InstanceState.useEffect(state, (s) => s.diffFile(from, to, file)) + }), + // kilocode_change end }) }), ) diff --git a/packages/opencode/test/kilocode/diff-full.test.ts b/packages/opencode/test/kilocode/diff-full.test.ts index 3f67cee9237..90838910b02 100644 --- a/packages/opencode/test/kilocode/diff-full.test.ts +++ b/packages/opencode/test/kilocode/diff-full.test.ts @@ -183,6 +183,70 @@ describe("DiffFull.batch", () => { ) }) +describe("DiffFull.detail", () => { + const runners = (dir: string) => ({ diff: gitResult(dir), show: gitResult(dir) }) + + it.live("returns full before/after plus hunk patch for a modified file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "a.txt"), "keep\nold\ntail\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "a.txt"), "keep\nnew\ntail\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "a.txt") + expect(got?.status).toBe("modified") + expect(got?.before).toBe("keep\nold\ntail\n") + expect(got?.after).toBe("keep\nnew\ntail\n") + expect(got?.patch).toContain("-old") + expect(got?.patch).toContain("+new") + }), + ) + + it.live("returns an empty before for an added file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "base.txt"), "x\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "added.txt"), "hi\nthere\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "added.txt") + expect(got?.status).toBe("added") + expect(got?.before).toBe("") + expect(got?.after).toBe("hi\nthere\n") + }), + ) + + it.live("returns an empty after for a deleted file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "gone.txt"), "bye\nnow\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => $`git rm gone.txt`.cwd(dir).quiet()) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "gone.txt") + expect(got?.status).toBe("deleted") + expect(got?.before).toBe("bye\nnow\n") + expect(got?.after).toBe("") + }), + ) + + it.live("returns undefined for a file unchanged between refs", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "same.txt"), "same\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "other.txt"), "changed\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "same.txt") + expect(got).toBeUndefined() + }), + ) +}) + describe("DiffFull.file", () => { it.live("returns a structured + unified diff for a modified working-tree file", () => Effect.gen(function* () { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 91a0d0b6507..4bce7c4fca9 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -309,6 +309,7 @@ const snap = Layer.succeed( revert: () => Effect.void, diff: () => Effect.succeed(""), diffFull: () => Effect.succeed([]), + diffFile: () => Effect.succeed(undefined), }), ) // kilocode_change end From 41e2bf5643738b6d5e4a85c5244c42e19b50a6d0 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 18:26:46 -0400 Subject: [PATCH 026/157] fix(jetbrains): correct gutter line numbers in hunk-fallback diff editor When the diff editor tab falls back to reconstructing sides from the hunk patch, the content is just the concatenated hunk bodies, so IntelliJ numbered the gutter from 1 instead of the real file positions. DiffPatchReconstruct now records, in lockstep with the content it builds, the 0-based source-file line for each reconstructed document line (following the @@ headers and jumping across elided inter-hunk gaps). diffRequest attaches these via the public DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR so the gutter matches the file, mirroring what the inline card already shows. Full authoritative content is untouched since it already starts at line 1. --- .../ai/kilocode/client/diff/DiffBlocks.kt | 14 ++++- .../client/diff/DiffPatchReconstruct.kt | 42 ++++++++++---- .../ai/kilocode/client/diff/DiffBlocksTest.kt | 27 +++++++++ .../client/diff/DiffPatchReconstructTest.kt | 56 +++++++++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index c7a3c921e23..6b8b8d0f0f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -3,12 +3,15 @@ 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.contents.DocumentContent import com.intellij.diff.requests.DiffRequest import com.intellij.diff.requests.SimpleDiffRequest import com.intellij.diff.util.DiffUserDataKeys +import com.intellij.diff.util.DiffUserDataKeysEx import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.FileStatus +import java.util.function.IntUnaryOperator internal fun diffRequest( project: Project, @@ -26,14 +29,14 @@ internal fun diffRequest( val left = when { full -> factory.create(project, dto.before.orEmpty(), type) DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty() - sides.renderable -> factory.create(project, sides.before, type) + sides.renderable -> factory.create(project, sides.before, type).numbered(sides.leftLines) status == FileStatus.DELETED -> factory.create(project, fallback, type) else -> factory.createEmpty() } val right = when { full -> factory.create(project, dto.after.orEmpty(), type) DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty() - sides.renderable -> factory.create(project, sides.after, type) + sides.renderable -> factory.create(project, sides.after, type).numbered(sides.rightLines) status == FileStatus.DELETED -> factory.createEmpty() else -> factory.create(project, fallback, type) } @@ -46,3 +49,10 @@ 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) } + +// The hunk-fallback content is a concatenation of hunk bodies, so its own document lines restart at 1. +// Remap each document line to its real source-file line (0-based; the platform adds 1) so the gutter +// matches the file instead of showing 1..N. Lines with no mapping return -1 and the platform hides them. +private fun DocumentContent.numbered(lines: List): DocumentContent = apply { + if (lines.isNotEmpty()) putUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR, IntUnaryOperator { lines.getOrElse(it) { -1 } }) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt index ca2c92cf46e..b29812ca12d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -6,21 +6,29 @@ internal data class DiffSides( val before: String, val after: String, val renderable: Boolean, + // 0-based source-file line for each reconstructed document line, so the diff editor gutter shows + // real file positions (and jumps across the elided inter-hunk gaps) instead of restarting at 1. + val leftLines: List = emptyList(), + val rightLines: List = emptyList(), ) internal object DiffPatchReconstruct { - private val HUNK = Regex("^@@ -\\d+(?:,(\\d+))? \\+\\d+(?:,(\\d+))? @@") + 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() + val leftLines = mutableListOf() + val rightLines = mutableListOf() var hunks = 0 var oldLen = 0 var newLen = 0 var oldSeen = 0 var newSeen = 0 + var oldLine = 1 + var newLine = 1 // 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; @@ -29,8 +37,10 @@ internal object DiffPatchReconstruct { if (line.startsWith("@@")) { hunks += 1 HUNK.find(line)?.let { match -> - oldLen += match.groupValues[1].ifEmpty { "1" }.toInt() - newLen += match.groupValues[2].ifEmpty { "1" }.toInt() + oldLine = match.groupValues[1].toInt() + newLine = match.groupValues[3].toInt() + oldLen += match.groupValues[2].ifEmpty { "1" }.toInt() + newLen += match.groupValues[4].ifEmpty { "1" }.toInt() } continue } @@ -38,16 +48,16 @@ internal object DiffPatchReconstruct { if (line.startsWith("\\")) continue when (line.firstOrNull()) { ' ' -> { - before.appendLine(line.substring(1)) - after.appendLine(line.substring(1)) + before.appendLine(line.substring(1)); leftLines.add(oldLine++ - 1) + after.appendLine(line.substring(1)); rightLines.add(newLine++ - 1) oldSeen += 1 newSeen += 1 } - '-' -> { before.appendLine(line.substring(1)); oldSeen += 1 } - '+' -> { after.appendLine(line.substring(1)); newSeen += 1 } + '-' -> { before.appendLine(line.substring(1)); leftLines.add(oldLine++ - 1); oldSeen += 1 } + '+' -> { after.appendLine(line.substring(1)); rightLines.add(newLine++ - 1); newSeen += 1 } else -> { - before.appendLine("") - after.appendLine("") + before.appendLine(""); leftLines.add(oldLine++ - 1) + after.appendLine(""); rightLines.add(newLine++ - 1) oldSeen += 1 newSeen += 1 } @@ -61,9 +71,17 @@ internal object DiffPatchReconstruct { // there is no hunk, or when the header lengths don't match the reconstructed body (truncated // context), because that would place lines against the wrong side. 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) + val added = added(patch) + val deleted = deleted(patch) + val left = if (added) "" else before.toString().removeSuffix("\n") + val right = if (deleted) "" else after.toString().removeSuffix("\n") + return DiffSides( + left, + right, + true, + if (added) emptyList() else leftLines, + if (deleted) emptyList() else rightLines, + ) } fun added(patch: String?): Boolean = patch?.lineSequence()?.any { it == "--- /dev/null" } == true diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt index 01307f892b1..2d68628ea9f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.diff import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.diff.contents.DocumentContent import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.util.DiffUserDataKeysEx import com.intellij.testFramework.fixtures.BasePlatformTestCase class DiffBlocksTest : BasePlatformTestCase() { @@ -22,5 +23,31 @@ class DiffBlocksTest : BasePlatformTestCase() { assertEquals("old\nkeep\n", (request.contents[0] as DocumentContent).document.text) assertEquals("new\nkeep\n", (request.contents[1] as DocumentContent).document.text) + // Full content already starts at line 1, so no gutter remap is installed. + assertNull((request.contents[0] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)) + assertNull((request.contents[1] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)) + } + + fun `test diffRequest remaps hunk fallback gutter to real file lines`() { + val request = diffRequest( + project, + DiffFileDto( + file = "src/Main.kt", + additions = 1, + deletions = 1, + patch = "--- a/src/Main.kt\n+++ b/src/Main.kt\n@@ -126,3 +126,3 @@\n one\n-two\n+TWO\n three\n", + status = "modified", + ), + ) as SimpleDiffRequest + + val left = (request.contents[0] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)!! + val right = (request.contents[1] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)!! + // Document line 0 (0-based) maps to file line 125 (0-based); the platform renders it as 126. + assertEquals(125, left.applyAsInt(0)) + assertEquals(127, left.applyAsInt(2)) + assertEquals(125, right.applyAsInt(0)) + assertEquals(127, right.applyAsInt(2)) + // Out-of-range document lines are hidden. + assertEquals(-1, left.applyAsInt(9)) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt index 0215144879a..b55ef75ae72 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -165,6 +165,62 @@ class DiffPatchReconstructTest { assertEquals("one\nTWO\nthree\ntwenty\nX\nz", sides.after) } + @Test + fun `line maps carry real file positions across hunk gaps`() { + // Regression: the fallback content restarts at line 1, so the diff editor gutter showed 1..N + // instead of real file positions. The per-side maps (0-based; platform adds 1) must follow the + // @@ headers and jump across the elided gap between the two hunks. + 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 + @@ -126,3 +126,3 @@ + one + -two + +TWO + three + @@ -220,3 +221,3 @@ + twenty + -x + +X + z + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + // before doc lines: one/two/three (126,127,128) then twenty/x/z (220,221,222) -> 0-based + assertEquals(listOf(125, 126, 127, 219, 220, 221), sides.leftLines) + // after doc lines: one/TWO/three (126,127,128) then twenty/X/z (221,222,223) -> 0-based + assertEquals(listOf(125, 126, 127, 220, 221, 222), sides.rightLines) + } + + @Test + fun `added file has no left line map and after side maps from one`() { + 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(emptyList(), sides.leftLines) + assertEquals(listOf(0, 1), sides.rightLines) + } + @Test fun `multi hunk patch with truncated context is not renderable`() { // header claims 3 old / 3 new lines per hunk but the body carries only 2 of each: reconstructing From f09e628a4ee334eeef6b87145d558b5988417125 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 18:29:09 -0400 Subject: [PATCH 027/157] fix(cli): give DiffFull.detail a single return shape The binary branch returned an object without before/after while the normal branch included them, so the result was a union and callers could not read before/after without narrowing (the diff-full test failed typecheck). Return before/after as undefined for binary files so the shape is uniform; JSON still omits the undefined keys, so serialized output is unchanged. --- packages/opencode/src/kilocode/snapshot/diff-full.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/snapshot/diff-full.ts b/packages/opencode/src/kilocode/snapshot/diff-full.ts index 532f6c713dd..6beb5f97c8c 100644 --- a/packages/opencode/src/kilocode/snapshot/diff-full.ts +++ b/packages/opencode/src/kilocode/snapshot/diff-full.ts @@ -126,7 +126,9 @@ export namespace DiffFull { deletions: Number.isFinite(deletions) ? deletions : 0, status, } - if (binary) return base + // Uniform shape across branches: binary files carry no content, but keep the keys present + // (undefined) so the return type is a single object, not a union missing before/after. + if (binary) return { ...base, before: undefined, after: undefined } const content = yield* Effect.all( { From 9a4410f42511024e6ddc98badd5d309c3c9e0120 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:44:28 -0400 Subject: [PATCH 028/157] fix(jetbrains): route full diffs to session directory and scope single edits Two correctness fixes for the diff editor's full-file view plus diagnostics: - Open inline/turn/edit diffs against the session's own directory (SessionController.sessionDirectory) instead of the active workspace directory, so authoritative snapshot lookup and local reconstruction resolve against the repo where the session actually ran. - Scope single Edit/Patch diffs to their message: message() now parses the "tool::" token in addition to "turn:", so the authoritative snapshot query is scoped to that edit. A single edit's intermediate state cannot be rebuilt from the final working tree, so it requires the authoritative per-message snapshot. Also adds a dev-only working-tree resolver: when kilo.dev.worktree.root is set, resolve() re-roots a stored diff's file path onto the running worktree by matching the longest existing path suffix, so cross-worktree dev sessions can still reconstruct locally. In production resolve() returns the direct path only. Adds info logging around diffSides/authoritative/detail to make full-vs-hunk decisions observable. --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 43 ++++++++++++++----- .../client/diff/KiloDiffEditorKind.kt | 20 ++++++--- .../ai/kilocode/client/session/SessionUi.kt | 6 ++- .../session/controller/SessionController.kt | 1 + 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 28c8117b2a6..3861abeb3d3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -168,19 +168,40 @@ 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 { return it } + 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 after = runCatching { Files.readString(Path.of(directory).resolve(file.file)) }.getOrNull() + 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 ?: ""} 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? { @@ -199,16 +220,18 @@ class KiloSessionRpcApiImpl internal constructor( ?.build() ?: return@runCatching null api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> - if (!response.isSuccessful) return@runCatching null - val item = Json.parseToJsonElement(response.body?.string().orEmpty()).jsonArray - .firstOrNull { it.jsonObject["file"]?.jsonPrimitive?.contentOrNull == file.file } - ?.jsonObject - ?: return@runCatching null - val before = item["before"]?.jsonPrimitive?.contentOrNull - val after = item["after"]?.jsonPrimitive?.contentOrNull + 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 } - }.getOrNull() + }.onFailure { log.info("diffSides authoritative file=${file.file} error=${it.message}") }.getOrNull() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index 43ed3b89491..38e5b51d77c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -175,18 +175,28 @@ internal class KiloDiffEditorService( ): List { val sessionId = params["sessionId"].takeIfPresent() val message = message(params) + LOG.info("diff editor detail source=${params["source"]} files=${files.size} session=${!sessionId.isNullOrBlank()} message=${!message.isNullOrBlank()}") return files.map { file -> val patch = file.patch - if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file - else runCatching { session.diffSides(sessionId, dir, file, message) }.getOrNull() ?: file + if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) { + LOG.info("diff editor detail skip file=${file.file} patch=${!patch.isNullOrBlank()} status=${file.status}") + file + } else { + val detail = runCatching { session.diffSides(sessionId, dir, file, message) } + .onFailure { LOG.warn("diff editor detail failed file=${file.file}", it) } + .getOrNull() + LOG.info("diff editor detail file=${file.file} full=${detail?.before != null && detail?.after != null} before=${detail?.before?.length ?: 0} after=${detail?.after?.length ?: 0}") + detail ?: file + } } } - // Turn diffs carry a "turn::" token; the turn id is the message the CLI scopes - // the authoritative snapshot diff to. Other sources (session, branch) have no per-turn message. + // Turn diffs carry "turn::" and single-edit diffs carry "tool::"; + // the third segment is the message the CLI scopes the authoritative snapshot diff to. Other sources + // (session, branch) have no per-message scope. private fun message(params: Map): String? { val parts = params["token"].takeIfPresent()?.split(":", limit = 3) ?: return null - return if (parts.size == 3 && parts[0] == "turn") parts[2].takeIfPresent() else null + return if (parts.size == 3 && (parts[0] == "turn" || parts[0] == "tool")) parts[2].takeIfPresent() else null } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9b635e1bf4b..a7b30a796c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -822,15 +822,17 @@ class SessionUi( } private fun openInlineDiff(files: List, title: String, key: String) { + val dir = controller.sessionDirectory cs.launch { - val branch = workspaces.branchName(workspace.directory) + val branch = workspaces.branchName(dir) val label = branch?.let { KiloBundle.message("diff.editor.inline.title.named", title, it) } ?: title + LOG.info("open inline diff session=${controller.id ?: "pending"} dir=${ChatLogSummary.dir(dir)} files=${files.size}") withContext(Dispatchers.Main) { ensureDiffEditorKind() project.service().put(key, files) project.service().open( KiloDiffEditorKind.ID, - diffParams("inline", workspace.directory, controller.id, label, token = key), + diffParams("inline", dir, controller.id, label, token = key), ) Telemetry.send("Diff Editor Opened", mapOf("source" to "inline")) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 22bea5de8e3..a0f0013b98a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -189,6 +189,7 @@ class SessionController( val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession internal val id: String? get() = sid + internal val sessionDirectory: String get() = model.session?.directory ?: (ref as? SessionRef.Local)?.session?.directory ?: directory internal val refKey: String? get() = ref?.key internal val refType: SessionRef.Type? get() = ref?.type From 967126c1ee1506dc0ece0326da1ef0c00c936496 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:44:39 -0400 Subject: [PATCH 029/157] chore(jetbrains): fix repo-CLI dev build after upstream merge - Add a revertDto overload for the regenerated GlobalSessionRevert model so the backend compiles against the post-merge OpenAPI client. - Resolve the bun executable from BUN_BINARY/BUN, PATH, ~/.bun/bin, and common Homebrew locations in GenerateOpenApiSpecTask, so IDE-launched Gradle (which runs with a stripped PATH) can generate the OpenAPI spec in repo-CLI mode. --- .../backend/app/KiloBackendSessionManager.kt | 5 +++++ .../src/main/kotlin/GenerateOpenApiSpecTask.kt | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index f48dbf90387..004bc837b52 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -5,6 +5,7 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.GlobalSession +import ai.kilocode.jetbrains.api.model.GlobalSessionRevert import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.SessionDto @@ -348,6 +349,10 @@ class KiloBackendSessionManager( revertDto(it.messageID, it.partID, it.snapshot, it.diff) } + private fun revertDto(s: GlobalSessionRevert?) = s?.let { + revertDto(it.messageID, it.partID, it.snapshot, it.diff) + } + private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) = SessionRevertDto( messageID = message, diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index 5482dfbd492..115fb1000aa 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -76,7 +76,7 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { val err = ByteArrayOutputStream() val result = exec.exec { workingDir = root - commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate") + commandLine(bun(), "run", "--conditions=browser", "./src/index.ts", "generate") standardOutput = out errorOutput = err isIgnoreExitValue = true @@ -96,6 +96,22 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { writeSpec(result.exitValue, out, err) } + private fun bun(): String { + val env = listOfNotNull(System.getenv("BUN_BINARY"), System.getenv("BUN")) + val path = System.getenv("PATH") + ?.split(File.pathSeparator) + ?.map { File(it, if (windows()) "bun.exe" else "bun") } + .orEmpty() + val home = System.getProperty("user.home") + val common = listOf( + File(home, ".bun/bin/${if (windows()) "bun.exe" else "bun"}"), + File("/opt/homebrew/bin/bun"), + File("/usr/local/bin/bun"), + ) + return (env.map(::File) + path + common).firstOrNull { it.isFile && it.canExecute() }?.absolutePath + ?: "bun" + } + private fun writeSpec(code: Int, out: ByteArrayOutputStream, err: ByteArrayOutputStream) { if (code != 0) { throw GradleException( From 4c6bcf5b174ac77f6bcf29616fe0258753088685 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:54:53 -0400 Subject: [PATCH 030/157] fix(jetbrains): support pinned and repo CLI revert models Pinned CLI generation exposes GlobalSession.revert as SessionRevert, while the local repo CLI client can generate a separate GlobalSessionRevert model. Avoid importing the repo-only type so pinned builds keep compiling, while still mapping the regenerated repo-CLI shape through reflective getters when present. --- .../backend/app/KiloBackendSessionManager.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 004bc837b52..d6555546ee2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -5,7 +5,6 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.GlobalSession -import ai.kilocode.jetbrains.api.model.GlobalSessionRevert import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.SessionDto @@ -345,12 +344,15 @@ 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: GlobalSessionRevert?) = 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 + revertDto(message, str("getPartID"), str("getSnapshot"), str("getDiff")) + }.getOrNull() } private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) = From 9bfbc35c5c674b09600f169a87704c2dee50a1b4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 5 Aug 2026 09:36:25 +0200 Subject: [PATCH 031/157] fix(cli): safely restore grep signal controls --- .changeset/green-greps-settle.md | 5 + packages/core/src/cross-spawn-spawner.ts | 47 +++- packages/core/src/kilocode/ripgrep-grep.ts | 50 +++++ packages/core/src/kilocode/spawn-exit.ts | 14 ++ packages/core/src/ripgrep.ts | 48 ++-- .../test/kilocode/ripgrep-settlement.test.ts | 208 ++++++++++++++++++ .../src/kilocode/tool/grep-signal-controls.ts | 64 ++++++ packages/opencode/src/tool/grep.ts | 26 ++- .../tool/grep-signal-controls.test.ts | 128 +++++++++++ .../__snapshots__/parameters.test.ts.snap | 23 +- packages/opencode/test/tool/grep.test.ts | 2 +- .../opencode/test/tool/parameters.test.ts | 15 ++ 12 files changed, 596 insertions(+), 34 deletions(-) create mode 100644 .changeset/green-greps-settle.md create mode 100644 packages/core/src/kilocode/ripgrep-grep.ts create mode 100644 packages/core/src/kilocode/spawn-exit.ts create mode 100644 packages/core/test/kilocode/ripgrep-settlement.test.ts create mode 100644 packages/opencode/src/kilocode/tool/grep-signal-controls.ts create mode 100644 packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts diff --git a/.changeset/green-greps-settle.md b/.changeset/green-greps-settle.md new file mode 100644 index 00000000000..9f7cacabf79 --- /dev/null +++ b/.changeset/green-greps-settle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add bounded, context-aware grep controls without leaving agents waiting on completed searches. diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 5888693f014..cb5f41d2896 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -3,6 +3,7 @@ import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" import * as NodePath from "@effect/platform-node/NodePath" import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change import { tap as tapStdio, tapped } from "./kilocode/stdio-tap" // kilocode_change - Bun drops buffered stdio on close +import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { settle } from "./kilocode/exit-code" // kilocode_change - settle signal termination as 128 + signum import * as Deferred from "effect/Deferred" @@ -268,7 +269,7 @@ export const make = Effect.gen(function* () { return { stdout, stderr, all: Stream.merge(stdout, stderr) } } - const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => + const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions, settle: boolean) => Effect.callback((resume) => { const signal = Deferred.makeUnsafe() const proc = launch(command.command, command.args, opts) @@ -280,6 +281,7 @@ export const make = Effect.gen(function* () { }) proc.on("exit", (...args) => { exit = args + if (settle) Deferred.doneUnsafe(signal, Exit.succeed(args)) // kilocode_change - bounded grep must not await inherited pipes }) proc.on("close", (...args) => { if (end) return @@ -326,6 +328,18 @@ export const make = Effect.gen(function* () { return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) }) + // kilocode_change start - inspect descendants owned by commands that settle on direct exit + const groupAlive = (proc: NodeChildProcess.ChildProcess) => { + if (process.platform === "win32") return false + try { + process.kill(-proc.pid!, 0) + return true + } catch { + return false + } + } + // kilocode_change end + const timeout = ( proc: NodeChildProcess.ChildProcess, @@ -370,6 +384,7 @@ export const make = Effect.gen(function* () { switch (command._tag) { case "StandardCommand": { const validation = SpawnValidation.take(command) // kilocode_change - retain target validation through preparation + const direct = SpawnExit.take(command) // kilocode_change - opt selected commands into direct-exit settlement const dir = yield* cwd(command.options) // kilocode_change start - prepare agent-scoped commands through the selected sandbox backend const target = yield* prepareSandbox(command, dir, env(command.options)) @@ -396,21 +411,33 @@ export const make = Effect.gen(function* () { const [proc, signal] = yield* Effect.acquireRelease( // kilocode_change start - spawn the prepared command and options - spawn(target, { - cwd: dir, - env: env(target.options), - stdio: stdios(sin, sout, serr, extra), - detached: target.options.detached ?? process.platform !== "win32", - shell: target.options.shell, - // kilocode_change end - windowsHide: process.platform === "win32", - }), + spawn( + target, + { + cwd: dir, + env: env(target.options), + stdio: stdios(sin, sout, serr, extra), + detached: target.options.detached ?? process.platform !== "win32", + shell: target.options.shell, + // kilocode_change end + windowsHide: process.platform === "win32", + }, + direct, // kilocode_change + ), Effect.fnUntraced(function* ([proc, signal]) { const done = yield* Deferred.isDone(signal) const kill = timeout(proc, command, target.options) // kilocode_change if (done) { const [code] = yield* Deferred.await(signal) if (process.platform === "win32") return yield* Effect.void + // kilocode_change start - clean up only descendants owned by direct-settling commands + if (direct && groupAlive(proc)) { + yield* Effect.ignore(killGroup(command, proc, target.options.killSignal ?? "SIGTERM")) + yield* Effect.sleep("100 millis") + if (groupAlive(proc)) yield* Effect.ignore(killGroup(command, proc, "SIGKILL")) + return yield* Effect.void + } + // kilocode_change end if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) return yield* Effect.void } diff --git a/packages/core/src/kilocode/ripgrep-grep.ts b/packages/core/src/kilocode/ripgrep-grep.ts new file mode 100644 index 00000000000..cac163c7f5d --- /dev/null +++ b/packages/core/src/kilocode/ripgrep-grep.ts @@ -0,0 +1,50 @@ +import type { Match } from "@opencode-ai/schema/filesystem" + +export interface Options { + readonly context?: number + readonly literal?: boolean + readonly ignoreCase?: boolean +} + +export type GrepMatch = Match & { + readonly context: boolean + readonly textTruncated: boolean +} + +export const flags = (input: Options) => [ + ...(input.literal ? ["--fixed-strings"] : []), + ...(input.ignoreCase ? ["--ignore-case"] : []), + ...(input.context ? [`--context=${input.context}`] : []), +] + +export const stop = (limit: number) => { + let matches = 0 + return (row: { readonly context: boolean }) => !row.context && ++matches > limit +} + +export const select = < + A extends { + readonly context: boolean + readonly path: { readonly text: string } + readonly line_number: number + }, +>( + input: { readonly limit: number; readonly context?: number }, + items: readonly A[], +) => { + let count = 0 + const overflow = items.findIndex((row) => !row.context && ++count > input.limit) + const selected = items.slice(0, overflow === -1 ? items.length : overflow) + const matches = selected.filter((row) => !row.context) + return selected.filter( + (row) => + !row.context || + matches.some( + (match) => + match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0), + ), + ) +} + +export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch => + Object.assign(match, { context, textTruncated }) diff --git a/packages/core/src/kilocode/spawn-exit.ts b/packages/core/src/kilocode/spawn-exit.ts new file mode 100644 index 00000000000..3bd4c0f88a9 --- /dev/null +++ b/packages/core/src/kilocode/spawn-exit.ts @@ -0,0 +1,14 @@ +import type { ChildProcess } from "effect/unstable/process" + +const commands = new WeakSet() + +export function attach(command: ChildProcess.StandardCommand) { + commands.add(command) + return command +} + +export function take(command: ChildProcess.StandardCommand) { + const found = commands.has(command) + commands.delete(command) + return found +} diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5361c083a43..71ee912e249 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -1,9 +1,11 @@ export * as Ripgrep from "./ripgrep" -import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { makeGlobalNode } from "./effect/app-node" import { Entry, Match } from "@opencode-ai/schema/filesystem" +import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change +import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -21,7 +23,7 @@ const MAX_RECORD_BYTES = 64 * 1024 const MAX_SUBMATCHES = 100 const RawMatch = Schema.Struct({ - type: Schema.Literal("match"), + type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records data: Schema.Struct({ path: Schema.Struct({ text: Schema.String }), lines: Schema.Struct({ text: Schema.String }), @@ -37,7 +39,7 @@ const RawMatch = Schema.Struct({ }), }) -type RawMatchData = (typeof RawMatch.Type)["data"] +type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, @@ -69,7 +71,8 @@ export interface GlobInput { readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn } -export interface GrepInput { +export interface GrepInput extends KiloGrep.Options { + // kilocode_change readonly cwd: string readonly pattern: string readonly file?: string @@ -82,7 +85,7 @@ export interface GrepInput { export interface Interface { readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect, Error> // kilocode_change - readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change + readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change } // kilocode_change start - retain truncation state through model-facing tools @@ -114,6 +117,7 @@ const layer = Layer.effect( readonly parse: (line: string) => Effect.Effect readonly pattern?: string readonly onItem?: (item: A) => Effect.Effect + readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match readonly validate?: Effect.Effect // kilocode_change - spawn-bound target validation }) => { const program = Effect.scoped( @@ -124,16 +128,24 @@ const layer = Layer.effect( cwd: input.cwd, extendEnv: true, stdin: "ignore", + forceKillAfter: input.stop ? Duration.seconds(1) : undefined, // kilocode_change - bound grep interruption }) - const handle = yield* process.spawn( - input.validate ? SpawnValidation.attach(command, input.validate) : command, - ) + const validated = input.validate ? SpawnValidation.attach(command, input.validate) : command + const spawned = input.stop ? SpawnExit.attach(validated) : validated // kilocode_change + const handle = yield* process.spawn(spawned) // kilocode_change end const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( Effect.map((output) => output.buffer.toString("utf8")), Effect.forkScoped, ) let observed = 0 + let stopped = false // kilocode_change + const take = input.stop // kilocode_change start + ? Stream.takeUntil((row) => { + stopped = input.stop?.(row) ?? false + return stopped + }) + : Stream.take(input.limit + 1) // kilocode_change end const rows = yield* Stream.decodeText(handle.stdout).pipe( Stream.splitLines, Stream.filter((line) => line.length > 0), @@ -143,11 +155,12 @@ const layer = Layer.effect( if (!input.onItem || observed++ >= input.limit) return Effect.void return input.onItem(row) }), - Stream.take(input.limit + 1), + take, // kilocode_change Stream.runCollect, Effect.map((chunk) => [...chunk]), ) - const truncated = rows.length > input.limit + if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change + const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop owns truncation if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } const code = yield* handle.exitCode @@ -244,11 +257,13 @@ const layer = Layer.effect( grep: (input) => run({ ...input, + stop: KiloGrep.stop(input.limit), // kilocode_change args: [ "--no-config", "--json", "--hidden", "--no-messages", + ...KiloGrep.flags(input), // kilocode_change ...(input.include ? [`--glob=${input.include}`] : []), "--glob=!**/.git/**", "--", @@ -264,13 +279,19 @@ const layer = Layer.effect( }) ).pipe( Effect.flatMap((json) => { - if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") + if ( + !json || + typeof json !== "object" || + !("type" in json) || + (json.type !== "match" && json.type !== "context") // kilocode_change + ) return Effect.succeed(undefined) return Schema.decodeUnknownEffect(RawMatch)(json).pipe( Effect.map((match) => ({ ...match.data, path: { text: match.data.path.text.replace(/^\.[\\/]/, "") }, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), + context: match.type === "context", // kilocode_change })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) @@ -280,12 +301,12 @@ const layer = Layer.effect( // kilocode_change start - retain spawn metadata after mapping matches Effect.map((result) => ({ ...result, - items: result.items.map((match) => { + items: KiloGrep.select(input, result.items).map((match) => { const relative = match.path.text .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") - return Match.make({ + const item = Match.make({ entry: Entry.make({ path: RelativePath.make(relative), type: "file", @@ -299,6 +320,7 @@ const layer = Layer.effect( end: submatch.end, })), }) + return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000) }), })), // kilocode_change end diff --git a/packages/core/test/kilocode/ripgrep-settlement.test.ts b/packages/core/test/kilocode/ripgrep-settlement.test.ts new file mode 100644 index 00000000000..955aa424a08 --- /dev/null +++ b/packages/core/test/kilocode/ripgrep-settlement.test.ts @@ -0,0 +1,208 @@ +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Fiber, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { RipgrepBinary } from "@opencode-ai/core/ripgrep/binary" +import { tmpdir } from "../fixture/tmpdir" +import { it } from "../lib/effect" + +const record = (type: "match" | "context", line: number, text: string) => + JSON.stringify({ + type, + data: { + path: { text: "fixture.ts" }, + lines: { text: `${text}\n` }, + line_number: line, + absolute_offset: line * 10, + submatches: type === "match" ? [{ match: { text: "NEEDLE.*" }, start: 0, end: 8 }] : [], + }, + }) + +const alive = (pid: number) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const read = async (file: string) => { + const deadline = Date.now() + 1_000 + while (Date.now() < deadline) { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value) return value + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${file}`) +} + +const cleanup = async (file: string) => { + const pid = Number(await fs.readFile(file, "utf8").catch(() => "")) + if (!pid || !alive(pid)) return + try { + process.kill(pid, "SIGKILL") + } catch (err) { + if (alive(pid)) throw err + } +} + +const gone = async (pid: number) => { + const deadline = Date.now() + 1_000 + while (Date.now() < deadline) { + if (!alive(pid)) return true + await Bun.sleep(10) + } + return !alive(pid) +} + +const fixture = async (dir: string, source: string) => { + if (process.platform !== "win32") { + const binary = path.join(dir, "rg") + await fs.writeFile(binary, `#!${process.execPath}\n${source}`, { mode: 0o755 }) + return binary + } + + const script = path.join(dir, "fake-rg.cjs") + const binary = path.join(dir, "rg.cmd") + await fs.writeFile(script, source) + await fs.writeFile(binary, `@echo off\r\n"${process.execPath}" "%~dp0fake-rg.cjs" %*\r\n`) + return binary +} + +const layer = (binary: string) => + LayerNode.compile(Ripgrep.node, [ + [ + RipgrepBinary.node, + Layer.succeed(RipgrepBinary.Service, RipgrepBinary.Service.of({ filepath: Effect.succeed(binary) })), + ], + ] as const) + +describe("Kilo ripgrep settlement", () => { + it.live( + "settles a bounded parameterized grep when inherited output stays open", + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const retained = path.join(tmp.path, "retained.pid") + const owned = path.join(tmp.path, "owned.pid") + const args = path.join(tmp.path, "args.json") + const output = + [ + record("context", 1, "before"), + record("match", 2, "NEEDLE.*"), + record("context", 3, "after"), + record("context", 5, "later before"), + record("match", 6, "NEEDLE.*"), + ].join("\n") + "\n" + const source = `const { spawn } = require("node:child_process") +const { writeFileSync, writeSync } = require("node:fs") +const retained = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(0), 30_000)"], { + detached: true, + stdio: ["ignore", "inherit", "inherit"], +}) +retained.unref() +${ + process.platform === "win32" + ? "" + : `const owned = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(0), 30_000)"], { + stdio: "ignore", +}) +owned.unref()` +} +writeFileSync(${JSON.stringify(retained)}, String(retained.pid)) +${process.platform === "win32" ? "" : `writeFileSync(${JSON.stringify(owned)}, String(owned.pid))`} +writeFileSync(${JSON.stringify(args)}, JSON.stringify(process.argv.slice(2))) +writeSync(1, ${JSON.stringify(output)}) +` + const binary = yield* Effect.promise(() => fixture(tmp.path, source)) + + const result = yield* Ripgrep.Service.pipe( + Effect.flatMap((ripgrep) => + ripgrep.grep({ + cwd: tmp.path, + pattern: "NEEDLE.*", + include: "*.ts", + context: 1, + limit: 1, + literal: true, + ignoreCase: true, + }), + ), + Effect.provide(layer(binary)), + Effect.timeout("5 seconds"), + ) + const pid = Number(yield* Effect.promise(() => read(retained))) + const passed: unknown = JSON.parse(yield* Effect.promise(() => read(args))) + if (!Array.isArray(passed) || !passed.every((arg) => typeof arg === "string")) { + throw new Error("Fake ripgrep did not capture string arguments") + } + + expect(result.truncated).toBe(true) + expect(result.items.map((item) => [item.context, item.line, item.text.trim()])).toEqual([ + [true, 1, "before"], + [false, 2, "NEEDLE.*"], + [true, 3, "after"], + ]) + expect(passed).toContain("--fixed-strings") + expect(passed).toContain("--ignore-case") + expect(passed).toContain("--context=1") + expect(passed).toContain("--glob=*.ts") + expect(alive(pid)).toBe(true) + if (process.platform !== "win32") { + const child = Number(yield* Effect.promise(() => read(owned))) + expect(yield* Effect.promise(() => gone(child))).toBe(true) + } + }), + (tmp) => + Effect.promise(async () => { + await cleanup(path.join(tmp.path, "retained.pid")) + await cleanup(path.join(tmp.path, "owned.pid")) + await tmp[Symbol.asyncDispose]() + }), + ), + 10_000, + ) + + it.live( + "force kills a bounded grep that does not exit after cancellation", + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const ready = path.join(tmp.path, "ready.pid") + const source = `const { writeFileSync } = require("node:fs") +if (process.platform !== "win32") process.on("SIGTERM", () => {}) +writeFileSync(${JSON.stringify(ready)}, String(process.pid)) +setInterval(() => {}, 10_000) +` + const binary = yield* Effect.promise(() => fixture(tmp.path, source)) + + const controller = new AbortController() + const fiber = yield* Ripgrep.Service.pipe( + Effect.flatMap((ripgrep) => + ripgrep.grep({ cwd: tmp.path, pattern: "needle", context: 1, limit: 1, signal: controller.signal }), + ), + Effect.provide(layer(binary)), + Effect.exit, + Effect.forkScoped, + ) + const pid = Number(yield* Effect.promise(() => read(ready))) + controller.abort() + const exit = yield* Fiber.join(fiber).pipe(Effect.timeout("5 seconds")) + + expect(exit._tag).toBe("Failure") + expect(alive(pid)).toBe(false) + }), + (tmp) => + Effect.promise(async () => { + await cleanup(path.join(tmp.path, "ready.pid")) + await tmp[Symbol.asyncDispose]() + }), + ), + 10_000, + ) +}) diff --git a/packages/opencode/src/kilocode/tool/grep-signal-controls.ts b/packages/opencode/src/kilocode/tool/grep-signal-controls.ts new file mode 100644 index 00000000000..49eb582c645 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/grep-signal-controls.ts @@ -0,0 +1,64 @@ +import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" + +export const DEFAULT_LIMIT = 100 + +export const fields = { + context: Schema.optional(NonNegativeInt).annotate({ + description: "Number of context lines to show before and after each match (default 0)", + }), + limit: Schema.optional(PositiveInt).annotate({ + description: "Maximum matching lines to return (default 100)", + }), + literal: Schema.optional(Schema.Boolean).annotate({ + description: "Treat pattern as plain text instead of a regex (default false)", + }), + ignoreCase: Schema.optional(Schema.Boolean).annotate({ + description: "Match without regard to letter case (default false)", + }), +} + +type Input = { + readonly context?: number + readonly limit?: number + readonly literal?: boolean + readonly ignoreCase?: boolean +} + +export const metadata = (input: Input, limit: number, context: number) => ({ + context, + limit, + literal: input.literal, + ignoreCase: input.ignoreCase, +}) + +export const options = (input: Input, limit: number, context: number) => ({ + limit, + context, + literal: input.literal, + ignoreCase: input.ignoreCase, +}) + +export const describe = (description: string) => `${description} +- Searches file contents using regular expressions by default; use literal=true for plain-text patterns +- Use ignoreCase=true for case-insensitive matching, context=N for surrounding lines, and limit=N to bound matches (default 100) +- Context lines are explicitly labeled when requested` + +export const line = ( + row: { readonly line: number; readonly text: string; readonly context: boolean }, + context: number, +) => { + const label = context === 0 ? `Line ${row.line}` : `${row.context ? "[context]" : "[match]"} Line ${row.line}` + return ` ${label}: ${row.text}` +} + +export const limitNotice = (limit: number) => + `${limit} matches limit reached. Use limit=${Math.min(Number.MAX_SAFE_INTEGER, limit * 2)} for more, or refine pattern.` + +export const notices = (rows: readonly { readonly textTruncated: boolean }[]) => { + const output: string[] = [] + if (rows.some((row) => row.textTruncated)) { + output.push("", "Some matching or context lines were truncated. Use read for full lines.") + } + return output +} diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 30a1779208c..f4277831685 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -3,18 +3,20 @@ import { Effect, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import * as KiloGrep from "@/kilocode/tool/grep-signal-controls" // kilocode_change import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" export const Parameters = Schema.Struct({ - pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), + pattern: Schema.String.annotate({ description: "Pattern to search for in file contents (regex by default)" }), // kilocode_change path: Schema.optional(Schema.String).annotate({ description: "The directory to search in. Defaults to the current working directory.", }), include: Schema.optional(Schema.String).annotate({ description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', }), + ...KiloGrep.fields, // kilocode_change }) export const GrepTool = Tool.define( @@ -23,10 +25,12 @@ export const GrepTool = Tool.define( const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service return { - description: DESCRIPTION, + description: KiloGrep.describe(DESCRIPTION), // kilocode_change parameters: Parameters, - execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const limit = params.limit ?? KiloGrep.DEFAULT_LIMIT // kilocode_change + const context = params.context ?? 0 // kilocode_change const empty = { title: params.pattern, metadata: { matches: 0, truncated: false }, @@ -44,6 +48,7 @@ export const GrepTool = Tool.define( pattern: params.pattern, path: params.path, include: params.include, + ...KiloGrep.metadata(params, limit, context), // kilocode_change }, }) @@ -66,7 +71,7 @@ export const GrepTool = Tool.define( file: info?.type === "File" ? path.basename(search) : undefined, // kilocode_change - constrain exact-file searches pattern: params.pattern, include: params.include, - limit: 100, + ...KiloGrep.options(params, limit, context), // kilocode_change signal: ctx.abort, // kilocode_change - stop ripgrep when the tool call is cancelled }) // kilocode_change start @@ -74,18 +79,20 @@ export const GrepTool = Tool.define( if (matches.length === 0) return empty // kilocode_change end - const rows = matches.map((item) => ({ // kilocode_change + const rows = matches.map((item) => ({ + // kilocode_change path: path.resolve(cwd, item.entry.path), line: item.line, text: item.text, + context: item.context, // kilocode_change + textTruncated: item.textTruncated, // kilocode_change })) - const limit = 100 const truncated = result.truncated // kilocode_change const final = rows if (final.length === 0) return empty - const total = rows.length + const total = rows.filter((row) => !row.context).length // kilocode_change const hasMore = truncated // kilocode_change const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`] @@ -96,13 +103,14 @@ export const GrepTool = Tool.define( current = match.path output.push(`${match.path}:`) } - output.push(` Line ${match.line}: ${match.text}`) + output.push(KiloGrep.line(match, context)) // kilocode_change } if (truncated) { output.push("") - output.push("(Results truncated. Consider using a more specific path or pattern.)") + output.push(KiloGrep.limitNotice(limit)) // kilocode_change } + output.push(...KiloGrep.notices(rows)) // kilocode_change if (result.partial) output.push("", "(Some paths were inaccessible.)") // kilocode_change return { diff --git a/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts new file mode 100644 index 00000000000..cc077248a0a --- /dev/null +++ b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts @@ -0,0 +1,128 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Agent } from "../../../src/agent/agent" +import { Git } from "../../../src/git" +import { GrepTool } from "../../../src/tool/grep" +import { Truncate } from "../../../src/tool/truncate" +import { MessageID, SessionID } from "../../../src/session/schema" +import { TestInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const it = testEffect( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + FSUtil.defaultLayer, + Ripgrep.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + Git.defaultLayer, + ), +) + +const ctx = { + sessionID: SessionID.make("ses_grep_signal_controls"), + messageID: MessageID.make("msg_grep_signal_controls"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const file = (test: { readonly directory: string }, name: string) => path.join(test.directory, name) + +const init = Effect.gen(function* () { + const info = yield* GrepTool + return yield* info.init() +}) + +describe("Kilo grep signal-to-noise controls", () => { + it.instance("preserves the default match output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(file(test, "default.txt"), "before\nneedle\nafter\n")) + const grep = yield* init + const result = yield* grep + .execute({ pattern: "needle", path: test.directory }, ctx) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain("Line 2: needle") + expect(result.output).not.toContain("[match]") + }), + ) + + it.instance("executes all signal controls and settles at the custom limit", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => + Bun.write( + file(test, "controls.txt"), + ["regex before", "NEEDLEzzz", "regex after", "before", "NEEDLE.*", "after", "far", "needle.*"].join("\n") + + "\n", + ), + ) + const grep = yield* init + const result = yield* grep + .execute( + { + pattern: "needle.*", + path: test.directory, + include: "*.txt", + context: 1, + limit: 1, + literal: true, + ignoreCase: true, + }, + ctx, + ) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata).toEqual({ matches: 1, truncated: true }) + expect(result.output).toContain("[context] Line 4: before") + expect(result.output).toContain("[match] Line 5: NEEDLE.*") + expect(result.output).toContain("[context] Line 6: after") + expect(result.output).not.toContain("NEEDLEzzz") + expect(result.output).not.toContain("Line 7: far") + expect(result.output).not.toContain("Line 8: needle.*") + expect(result.output).toContain("1 matches limit reached. Use limit=2 for more, or refine pattern.") + }), + ) + + it.instance("does not count context lines toward the match limit", () => + Effect.gen(function* () { + const test = yield* TestInstance + const content = Array.from( + { length: 35 }, + (_, index) => `before-${index}\nneedle-${index}\nafter-${index}\ngap-${index}`, + ).join("\n") + yield* Effect.promise(() => Bun.write(file(test, "context-limit.txt"), `${content}\n`)) + const grep = yield* init + const result = yield* grep + .execute({ pattern: "needle", path: test.directory, context: 1, limit: 100 }, ctx) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata.matches).toBe(35) + expect(result.metadata.truncated).toBe(false) + expect(result.output).toContain("needle-34") + expect(result.output).not.toContain("matches limit reached") + }), + ) + + it.instance("guides the model to read truncated lines", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(file(test, "long.txt"), `${"x".repeat(2_100)}needle\n`)) + const grep = yield* init + const result = yield* grep.execute({ pattern: "needle", path: test.directory }, ctx) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain("Some matching or context lines were truncated. Use read for full lines.") + }), + ) +}) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 368225175b6..a31700b7c0e 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -114,16 +114,37 @@ exports[`tool parameters JSON Schema (wire shape) grep 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "context": { + "description": "Number of context lines to show before and after each match (default 0)", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "ignoreCase": { + "description": "Match without regard to letter case (default false)", + "type": "boolean", + }, "include": { "description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")", "type": "string", }, + "limit": { + "description": "Maximum matching lines to return (default 100)", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer", + }, + "literal": { + "description": "Treat pattern as plain text instead of a regex (default false)", + "type": "boolean", + }, "path": { "description": "The directory to search in. Defaults to the current working directory.", "type": "string", }, "pattern": { - "description": "The regex pattern to search for in file contents", + "description": "Pattern to search for in file contents (regex by default)", "type": "string", }, }, diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 0c0769d4dab..e125349cff5 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -145,7 +145,7 @@ describe("tool.grep", () => { const grep = yield* info.init() const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx) - expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)") + expect(result.output).toContain("100 matches limit reached. Use limit=200 for more, or refine pattern.") // kilocode_change expect(result.output).not.toMatch(/showing \d+ of \d+ matches/) }), ) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 67247b00b8e..1b43d44206b 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -163,6 +163,21 @@ describe("tool parameters", () => { expect(parsed.path).toBe("/tmp") expect(parsed.include).toBe("*.ts") }) + // kilocode_change start - configurable grep signal controls + test("accepts signal controls", () => { + expect(parse(Grep, { pattern: "TODO", context: 0, limit: 1, literal: true, ignoreCase: true })).toMatchObject({ + context: 0, + limit: 1, + literal: true, + ignoreCase: true, + }) + }) + test("rejects invalid signal controls", () => { + expect(accepts(Grep, { pattern: "TODO", context: -1 })).toBe(false) + expect(accepts(Grep, { pattern: "TODO", limit: 0 })).toBe(false) + expect(accepts(Grep, { pattern: "TODO", limit: 1.5 })).toBe(false) + }) + // kilocode_change end test("rejects missing pattern", () => { expect(accepts(Grep, {})).toBe(false) }) From 348db374757768f3e6eb534b323efd2b464906c5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 5 Aug 2026 09:48:01 +0200 Subject: [PATCH 032/157] test(cli): update grep harness for layer nodes --- .../test/kilocode/tool/grep-signal-controls.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts index cc077248a0a..8a634d793ab 100644 --- a/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts +++ b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Agent } from "../../../src/agent/agent" @@ -13,13 +14,8 @@ import { TestInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const it = testEffect( - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), ), ) From c0649f7cb27aabf2cf992aa88eaed132adee91f9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 5 Aug 2026 11:40:43 +0200 Subject: [PATCH 033/157] fix(vscode): avoid macOS git launcher overhead --- .changeset/quick-git-launches.md | 5 + .../src/agent-manager/AgentManagerProvider.ts | 4 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 152 ++++++++++++------ packages/kilo-vscode/src/extension.ts | 6 +- .../kilo-vscode/src/util/git-executable.ts | 70 ++++++++ .../tests/unit/git-executable.test.ts | 142 ++++++++++++++++ .../kilo-vscode/tests/unit/git-ops.test.ts | 47 ++++++ 7 files changed, 375 insertions(+), 51 deletions(-) create mode 100644 .changeset/quick-git-launches.md create mode 100644 packages/kilo-vscode/src/util/git-executable.ts create mode 100644 packages/kilo-vscode/tests/unit/git-executable.test.ts diff --git a/.changeset/quick-git-launches.md b/.changeset/quick-git-launches.md new file mode 100644 index 00000000000..6c29101ce1f --- /dev/null +++ b/.changeset/quick-git-launches.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce Agent Manager Git polling overhead by reusing the validated Git executable and bypassing the macOS developer-tool launcher when safe. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 069783c53fc..f2bf96cb615 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -27,6 +27,7 @@ import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type Work import { PRStatusBridge } from "./pr-status-bridge" import { createPollers, type ProjectPollers } from "./project/pollers" import { GitOps } from "./GitOps" +import type { GitExecutable } from "../util/git-executable" import { versionedName } from "./branch-name" import { BranchNamingController } from "./branch-naming" import { SetupScriptService } from "./SetupScriptService" @@ -122,6 +123,7 @@ export class AgentManagerProvider implements Disposable { constructor( private readonly host: Host, private readonly connectionService: KiloConnectionService, + binary: GitExecutable = () => Promise.resolve("git"), ) { this.outputChannel = host.createOutput("Kilo Agent Manager") this.terminalManager = new SessionTerminalManager( @@ -175,7 +177,7 @@ export class AgentManagerProvider implements Disposable { log: (...args) => this.log(...args), }) const semaphore = new Semaphore(3) - this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore }) + this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore, binary }) const wiring = createProjectWiring({ host: this.host, git: this.gitOps, diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index ea26906b893..d4be3b7ca6f 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -2,6 +2,7 @@ import * as nodePath from "path" import * as os from "os" import * as fs from "fs/promises" import { spawn } from "../util/process" +import type { GitExecutable } from "../util/git-executable" import simpleGit from "simple-git" import { parseWorktreeList, @@ -18,6 +19,8 @@ interface GitOpsOptions { runGit?: (args: string[], cwd: string) => Promise /** Shared concurrency gate for child process spawning. */ semaphore?: Semaphore + /** Validated Git executable shared by Agent Manager operations. */ + binary?: GitExecutable } export interface ApplyConflict { @@ -96,6 +99,9 @@ export class GitOps { private readonly runGit: (args: string[], cwd: string) => Promise private readonly controller = new AbortController() private readonly semaphore: Semaphore | undefined + private readonly binary: GitExecutable + private readonly injected: boolean + private executableCache: Promise | undefined private readonly resolutionCache = new Map() private static readonly CACHE_TTL_MS = 60000 private static readonly MAX_CACHE_SIZE = 100 @@ -107,12 +113,19 @@ export class GitOps { constructor(options: GitOpsOptions) { this.log = options.log this.semaphore = options.semaphore + this.binary = options.binary ?? (() => Promise.resolve("git")) + this.injected = options.runGit !== undefined this.runGit = options.runGit ?? - ((args, cwd) => - simpleGit(cwd, { abort: this.controller.signal }) + (async (args, cwd) => { + const binary = await this.executable() + return simpleGit(cwd, { + abort: this.controller.signal, + binary, + }) .raw(args) - .then((out) => out.trim())) + .then((out) => out.trim()) + }) } dispose(): void { @@ -148,22 +161,28 @@ export class GitOps { private raw(args: string[], cwd: string): Promise { const signal = this.controller.signal if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) - const invoke = () => - new Promise((resolve, reject) => { - const onAbort = () => reject(new Error("GitOps disposed")) - signal.addEventListener("abort", onAbort, { once: true }) - this.runGit(args, cwd).then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (err) => { - signal.removeEventListener("abort", onAbort) - reject(err) - }, - ) - }) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + return this.executable().then(() => { + if (signal.aborted) throw new Error("GitOps disposed") + const invoke = () => { + const pending = this.runGit(args, cwd) + if (!this.injected) return pending + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + pending.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) + } + return this.semaphore ? this.semaphore.run(invoke) : invoke() + }) } /** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */ @@ -551,43 +570,78 @@ export class GitOps { return { code: result.code, stdout: result.stdout.toString("utf8"), stderr: result.stderr } } - private execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise { + private async execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise { + if (this.controller.signal.aborted) { + return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } + } + const cmd = await this.executable().catch(() => undefined) + if (!cmd || this.controller.signal.aborted) { + return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } + } + const invoke = () => this.invoke(cmd, args, cwd, options) + return this.semaphore ? this.semaphore.run(invoke) : invoke() + } + + private executable(): Promise { + const signal = this.controller.signal + if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) + + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + this.executableCache ??= Promise.resolve().then(() => this.binary()) + this.executableCache.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) + } + + private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise { if (this.controller.signal.aborted) { return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }) } - const invoke = () => - new Promise((resolve) => { - const child = spawn("git", args, { - cwd, - env: options?.env, - signal: this.controller.signal, - stdio: ["pipe", "pipe", "pipe"], - }) - if (options?.stdin !== undefined) { - if (!child.stdin) { - resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "stdin not available for git process" }) - return - } - child.stdin.end(options.stdin) - } + return new Promise((resolve) => { + const child = spawn(cmd, args, { + cwd, + env: options?.env, + stdio: ["pipe", "pipe", "pipe"], + }) + const out: Buffer[] = [] + const err: Buffer[] = [] + let failure: string | undefined + const abort = () => child.kill("SIGINT") - const out: Buffer[] = [] - const err: Buffer[] = [] - child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) - child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) + this.controller.signal.addEventListener("abort", abort, { once: true }) + child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) + child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) - child.on("error", (error) => { - resolve({ code: 1, stdout: Buffer.alloc(0), stderr: error.message }) - }) - child.on("close", (code) => { - resolve({ - code: code ?? 1, - stdout: Buffer.concat(out), - stderr: Buffer.concat(err).toString("utf8"), - }) + child.on("error", (error) => { + failure = error.message + }) + child.on("close", (code) => { + this.controller.signal.removeEventListener("abort", abort) + resolve({ + code: code ?? 1, + stdout: Buffer.concat(out), + stderr: failure ?? Buffer.concat(err).toString("utf8"), }) }) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + + if (options?.stdin === undefined) return + if (child.stdin) { + child.stdin.end(options.stdin) + return + } + failure = "stdin not available for git process" + child.kill("SIGINT") + }) } } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b1549e3c479..20f842e324b 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -25,6 +25,7 @@ import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" import { createNotebookBridge } from "./services/notebook" +import { createGitExecutable } from "./util/git-executable" let agentManager: AgentManagerProvider | undefined let shuttingDown = false @@ -149,7 +150,10 @@ export function activate(context: vscode.ExtensionContext) { // Create Agent Manager provider for editor panel const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService) - const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService) + const git = createGitExecutable({ + log: (message) => console.warn(`[Kilo New] ${message}`), + }) + const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git) agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) agentManager = agentManagerProvider context.subscriptions.push(agentManagerProvider) diff --git a/packages/kilo-vscode/src/util/git-executable.ts b/packages/kilo-vscode/src/util/git-executable.ts new file mode 100644 index 00000000000..a4fa64f377b --- /dev/null +++ b/packages/kilo-vscode/src/util/git-executable.ts @@ -0,0 +1,70 @@ +import { constants } from "fs" +import * as fs from "fs/promises" +import * as path from "path" +import { exec } from "./process" + +export type GitExecutable = () => Promise + +interface GitExecutableOptions { + platform?: NodeJS.Platform + path?: string + run?: (cmd: string, args: string[]) => Promise<{ stdout: string }> + access?: (file: string, mode: number) => Promise + realpath?: (file: string) => Promise + log?: (message: string) => void +} + +/** + * Preserve normal PATH lookup on every platform. On macOS only, bypass Apple's + * /usr/bin/git launcher after confirming it is the command PATH would select and + * xcrun identifies a valid executable for the active developer directory. + */ +export function createGitExecutable(options: GitExecutableOptions = {}): GitExecutable { + const platform = options.platform ?? process.platform + const run = options.run ?? ((cmd, args) => exec(cmd, args, { timeout: 15_000 })) + const access = options.access ?? fs.access + const realpath = options.realpath ?? fs.realpath + const log = options.log ?? (() => undefined) + let cached: Promise | undefined + + return (): Promise => { + cached ??= (async () => { + if (platform !== "darwin") return "git" + + try { + const env = options.path ?? process.env.PATH ?? "/usr/bin:/bin" + const selected = await (async () => { + for (const dir of env.split(path.posix.delimiter)) { + // Relative and empty PATH entries depend on each command's cwd, so + // they cannot be resolved once without changing lookup semantics. + if (!dir || !path.posix.isAbsolute(dir)) return undefined + const file = path.posix.join(dir, "git") + const resolved = await access(file, constants.X_OK) + .then(() => realpath(file)) + .catch(() => undefined) + if (resolved) return resolved + } + return undefined + })() + if (selected !== "/usr/bin/git") return "git" + + const result = await run("/usr/bin/xcrun", ["--find", "git"]) + const candidate = result.stdout.trim() + if (!candidate || candidate === selected || !path.posix.isAbsolute(candidate)) return "git" + if (!/^[/a-zA-Z0-9._~-]+$/.test(candidate)) return "git" + + await access(candidate, constants.X_OK) + const version = await run(candidate, ["--version"]) + if (!version.stdout.trim().startsWith("git version ")) return "git" + + log(`Using ${candidate} directly instead of the macOS Git launcher`) + return candidate + } catch (err) { + log(`Unable to bypass the macOS Git launcher, using PATH: ${err}`) + return "git" + } + })() + + return cached + } +} diff --git a/packages/kilo-vscode/tests/unit/git-executable.test.ts b/packages/kilo-vscode/tests/unit/git-executable.test.ts new file mode 100644 index 00000000000..f6850a391db --- /dev/null +++ b/packages/kilo-vscode/tests/unit/git-executable.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test" +import { createGitExecutable } from "../../src/util/git-executable" + +describe("createGitExecutable", () => { + it("preserves PATH lookup on other platforms", async () => { + const git = createGitExecutable({ + platform: "linux", + run: async () => { + throw new Error("should not run") + }, + }) + + expect(await git()).toBe("git") + }) + + it("resolves and validates the real macOS Git executable", async () => { + const calls: string[] = [] + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin:/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd, args) => { + calls.push([cmd, ...args].join(" ")) + if (cmd === "/usr/bin/xcrun") return { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" } + return { stdout: "git version 2.50.1\n" } + }, + }) + + expect(await git()).toBe("/Library/Developer/CommandLineTools/usr/bin/git") + expect(calls).toEqual(["/usr/bin/xcrun --find git", "/Library/Developer/CommandLineTools/usr/bin/git --version"]) + }) + + it("falls back to the macOS launcher when resolution fails", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => { + throw new Error("xcrun failed") + }, + }) + + expect(await git()).toBe("git") + }) + + it("rejects a resolved command that is not Git", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd) => + cmd === "/usr/bin/xcrun" ? { stdout: "/tmp/not-git\n" } : { stdout: "unexpected command\n" }, + }) + + expect(await git()).toBe("git") + }) + + it("does not override a non-Apple Git selected by PATH", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "/opt/homebrew/bin:/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps per-command lookup for relative PATH entries", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "./bin:/usr/bin", + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps per-command lookup for empty PATH entries", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: ":/usr/bin", + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps PATH lookup when the developer directory contains unsafe path characters", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => ({ stdout: "/Applications/Xcode Beta.app/Contents/Developer/usr/bin/git\n" }), + }) + + expect(await git()).toBe("git") + }) + + it("shares one resolution across concurrent callers", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd) => { + calls++ + return cmd === "/usr/bin/xcrun" + ? { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" } + : { stdout: "git version 2.50.1\n" } + }, + }) + + expect(await Promise.all([git(), git(), git()])).toEqual([ + "/Library/Developer/CommandLineTools/usr/bin/git", + "/Library/Developer/CommandLineTools/usr/bin/git", + "/Library/Developer/CommandLineTools/usr/bin/git", + ]) + expect(calls).toBe(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index 563620705d5..b840e119e4f 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -41,6 +41,53 @@ async function withRepo(run: (cwd: string) => Promise): Promise { } describe("GitOps", () => { + it("uses the configured Git executable for raw commands", async () => { + await withRepo(async (cwd) => { + let calls = 0 + const git = new GitOps({ + log: () => undefined, + binary: async () => { + calls++ + return "git" + }, + }) + + expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd)) + expect(calls).toBe(1) + }) + }) + + it("does not hold a semaphore slot while resolving Git", async () => { + const semaphore = new Semaphore(1) + let resolve!: (value: string) => void + const binary = new Promise((done) => { + resolve = done + }) + const git = new GitOps({ log: () => undefined, semaphore, binary: () => binary }) + const pending = git.currentBranch("/repo") + let entered = false + + await semaphore.run(async () => { + entered = true + }) + resolve("git") + await pending + + expect(entered).toBe(true) + }) + + it("stops waiting for Git resolution when disposed", async () => { + const git = new GitOps({ + log: () => undefined, + binary: () => new Promise(() => undefined), + }) + const pending = git.currentBranch("/repo") + + git.dispose() + + expect(await pending).toBe("") + }) + describe("currentBranch", () => { it("returns the current branch name", async () => { const git = ops(async (args) => { From 7382d3a5eedd9ab589e5a7e6406adbb243119acd Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:44:28 +0000 Subject: [PATCH 034/157] docs: consolidate Auto Balanced into Auto Efficient Auto Balanced is being retired as a separate tier; its behavior and positioning are absorbed into Auto Efficient. Updates: - packages/kilo-docs/pages/code-with-ai/agents/auto-model.md: drop the Balanced tier row/bullet, fold its fallback-baseline behavior into Efficient's description - packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md: remove the Balanced tier row and rewrite the Balanced-vs-Efficient comparison section as an Efficient-only explainer - packages/kilo-docs/pages/gateway/models-and-providers.md: rename the kilo-auto/balanced routing section to kilo-auto/efficient, update the tier-routing summary and curl example - packages/kilo-docs/pages/deploy-secure/security-reviews.md: rename "Kilo Balanced" default models to "Kilo Efficient" - packages/kilo-docs/pages/code-with-ai/gastown/settings.md and troubleshooting.md: rename Auto Balanced references to Auto Efficient - packages/kilo-docs/pages/kiloclaw/overview.md and end-to-end.md: rename Auto Balanced / Balanced references to Auto Efficient --- .../pages/code-with-ai/agents/auto-model.md | 10 ++++------ .../pages/code-with-ai/gastown/settings.md | 2 +- .../pages/code-with-ai/gastown/troubleshooting.md | 2 +- .../pages/deploy-secure/security-reviews.md | 8 ++++---- .../pages/gateway/models-and-providers.md | 8 ++++---- .../getting-started/rate-limits-and-costs.md | 15 ++++----------- packages/kilo-docs/pages/kiloclaw/end-to-end.md | 2 +- packages/kilo-docs/pages/kiloclaw/overview.md | 2 +- 8 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index a287cf094c6..d09392976dc 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -10,7 +10,6 @@ Auto Model is a smart routing system that selects an underlying model for each r | Tier | Best For | Pricing | |---|---|---| | `kilo-auto/frontier` | Maximum capability with the best available models | Paid | -| `kilo-auto/balanced` | Strong performance at a lower cost | Paid | | `kilo-auto/efficient` | Lowest cost per task, with capability matched to difficulty | Paid | | `kilo-auto/free` | The best free models available | Free | @@ -31,8 +30,7 @@ The underlying models behind each Auto Model tier are updated server-side as bet ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. -- **Balanced** — Routes to a cost-effective model for all modes. The specific model is selected based on the API interface in use, but does not vary by mode. A good default for most developers who want strong AI assistance without paying frontier prices. -- **Efficient** — Session-aware routing that classifies the difficulty of each request in real time and routes it to the cheapest model proven accurate enough for that task, based on Kilo's continuously-run benchmarks. Routine work stays lean while harder tasks get a more capable model. Because it watches your session in context, it keeps using a model across related turns and only switches when a cheaper option is clearly worth it. If a routing decision can't be made, it falls back to the Balanced tier, so quality never drops below Balanced. +- **Efficient** — Session-aware routing that classifies the difficulty of each request in real time and routes it to the cheapest model proven accurate enough for that task, based on Kilo's continuously-run benchmarks. Routine work stays lean while harder tasks get a more capable model. Because it watches your session in context, it keeps using a model across related turns and only switches when a cheaper option is clearly worth it. If a routing decision can't be made, it falls back to a fixed, cost-effective baseline model, so quality never drops below that baseline. A good default for most developers who want strong AI assistance without paying frontier prices. - **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. ### How Auto Efficient routing works @@ -61,7 +59,7 @@ New entries are benchmarked on demand before they can serve traffic. Each entry | Failed | Benchmarking failed — retry the entry | | Unavailable | The model or variant is no longer in your catalog — remove the entry | -Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to the Balanced tier, so quality never drops below Balanced. +Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to a fixed, cost-effective baseline model, so quality never drops below that baseline. {% callout type="note" %} You can benchmark up to 10 new or retried pairs per owner per rolling 24 hours. Entries that are already ready or benchmarking don't count against this limit. @@ -85,7 +83,7 @@ No need to manually switch models when changing modes. Auto Model handles routin ### Flexible Cost Control -Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Balanced offers capable models at a fraction of the cost; Efficient minimizes cost per task by matching model capability to task difficulty; Free costs nothing. +Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Efficient minimizes cost per task by matching model capability to task difficulty, at a fraction of Frontier's cost; Free costs nothing. ## Requirements @@ -101,7 +99,7 @@ Select an Auto Model tier from the model dropdown in the Kilo Code chat interfac 1. Open Kilo Code in VS Code or JetBrains 2. Click the model selector dropdown -3. Choose an Auto Model such as `kilo-auto/frontier` or `kilo-auto/balanced` +3. Choose an Auto Model such as `kilo-auto/frontier` or `kilo-auto/efficient` 4. Start chatting - the right model is selected automatically based on your current mode ## When to Use Auto Model diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md index d95fa6bf147..7512e72e6d6 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md @@ -17,7 +17,7 @@ The primary model used by all agents (polecats, refinery, mayor). This affects q Popular choices: - **Kilo Auto Frontier** — highest quality models, best results (recommended) -- **Kilo Auto Balanced** — good balance of quality and cost (minimum for Gas Town) +- **Kilo Auto Efficient** — cheapest model proven accurate enough for each task, with capability matched to difficulty (minimum for Gas Town) ### Role-Specific Models diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md index 62067e340f1..63b3fcc7880 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md @@ -124,7 +124,7 @@ Beads automatically escalate after 3 failed review cycles. If a bead is genuinel **Fix:** 1. Review failed bead descriptions — make them more specific 2. Ensure the repo builds cleanly (agents struggle with pre-existing broken builds) -3. Consider upgrading the model (Auto Balanced → Auto Frontier for complex work) +3. Consider upgrading the model (Auto Efficient → Auto Frontier for complex work) 4. Add custom instructions to guide agents: test commands, build steps, conventions ## Getting Help diff --git a/packages/kilo-docs/pages/deploy-secure/security-reviews.md b/packages/kilo-docs/pages/deploy-secure/security-reviews.md index 68d6677aa08..22c25c8fdc7 100644 --- a/packages/kilo-docs/pages/deploy-secure/security-reviews.md +++ b/packages/kilo-docs/pages/deploy-secure/security-reviews.md @@ -243,9 +243,9 @@ General settings include: |---|---|---| | Security Agent enabled | Off until you turn it on | Turning it on queues an initial sync for the selected repository scope. | | Repository selection | Selected repositories during setup | Choose all accessible repositories or selected repositories. | -| Triage model | Kilo Balanced | Used for initial triage and exploitability recommendations. | -| Analysis model | Kilo Balanced | Used for sandbox analysis and result extraction. | -| Remediation model | Kilo Balanced | Used by Cloud Agent for remediation PR work. | +| Triage model | Kilo Efficient | Used for initial triage and exploitability recommendations. | +| Analysis model | Kilo Efficient | Used for sandbox analysis and result extraction. | +| Remediation model | Kilo Efficient | Used by Cloud Agent for remediation PR work. | | Analysis mode | Auto | Auto, Shallow, or Deep. | #### Turn Security Agent on or off @@ -271,7 +271,7 @@ Security Agent uses a separate model for each stage: - The Analysis model runs sandbox analysis and extracts the result. - The Remediation model is used by Cloud Agent to prepare remediation pull requests. -Kilo Balanced is the default for all three stages. You can change each model independently. The model recorded in finding details is the model used when that analysis or remediation attempt ran. AI triage, sandbox analysis, and remediation consume Kilo Code credits. +Kilo Efficient is the default for all three stages. You can change each model independently. The model recorded in finding details is the model used when that analysis or remediation attempt ran. AI triage, sandbox analysis, and remediation consume Kilo Code credits. #### Choose an analysis mode diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 922706b984b..b7a78df9ca0 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -73,7 +73,7 @@ For NVIDIA free endpoints (Super/Ultra/etc): Trial use only - do not submit pers ## Auto models -Auto virtual models select an underlying model using tier-specific routing. Frontier uses the `x-kilocode-mode` request header. Balanced uses the API interface, Free uses deterministic affinity across available candidates, and Small uses account balance. +Auto virtual models select an underlying model using tier-specific routing. Frontier uses the `x-kilocode-mode` request header. Efficient classifies task difficulty in session context and falls back to the API interface for its baseline model, Free uses deterministic affinity across available candidates, and Small uses account balance. {% callout type="info" title="Underlying models can change" %} The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable. @@ -89,9 +89,9 @@ Highest performance and capability for any task. Frontier requests are sent with | `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | | Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | -### `kilo-auto/balanced` +### `kilo-auto/efficient` -Great balance of price and capability. The resolved model depends on the API interface used by the client. +Session-aware routing that classifies each request by difficulty and routes to the cheapest model proven accurate enough for the task. When no confident routing decision can be made, requests fall back to a baseline model resolved by the API interface used by the client. | API interface | Resolved Model | Reasoning effort | |---|---|---| @@ -132,5 +132,5 @@ curl -X POST "https://api.kilo.ai/api/gateway/chat/completions" \ -H "Authorization: Bearer $KILO_API_KEY" \ -H "x-kilocode-mode: plan" \ -H "Content-Type: application/json" \ - -d '{"model": "kilo-auto/balanced", "messages": [{"role": "user", "content": "Design a database schema"}]}' + -d '{"model": "kilo-auto/efficient", "messages": [{"role": "user", "content": "Design a database schema"}]}' ``` diff --git a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md index 8b49b32ea69..9850e877655 100644 --- a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md +++ b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md @@ -14,7 +14,6 @@ Auto Model is Kilo's smart routing system. Instead of selecting a specific provi | Tier | Name | Best For | Cost | |---|---|---|---| | `kilo-auto/frontier` | Auto Frontier | Maximum capability — routes to top-tier models for planning/architect/debug and high-quality models for coding | Paid (highest) | -| `kilo-auto/balanced` | Auto Balanced | Strong performance at a predictably lower cost — routes every request to one fixed high-quality model | Paid | | `kilo-auto/efficient` | Auto Efficient | Lowest cost per task — classifies each request by difficulty and routes to the cheapest benchmark-proven model for that task | Paid (lowest) | | `kilo-auto/free` | Auto Free | No credits required — rotates through available free models | Free | @@ -22,20 +21,14 @@ Auto Model is Kilo's smart routing system. Instead of selecting a specific provi The underlying models behind each tier are updated server-side as better options become available or as providers change pricing. See [kilo.ai/models](https://kilo.ai/models) for current model assignments and live pricing. {% /callout %} -## Balanced vs Efficient — What's the Difference? +## What Makes Auto Efficient Efficient? -Both tiers are paid, but they optimize for different things. +**Auto Efficient** observes your coding session in context, classifies the difficulty of each request in real time, and routes it to the *cheapest model proven accurate enough* for that specific task, based on Kilo's continuously running benchmarks. Routine tasks (small edits, lookups, quick explanations) are handled by leaner models; harder tasks (architecture, debugging, complex refactors) automatically get a more capable model. -**Auto Balanced** routes every request to a single, fixed high-quality model. You get consistent, strong results with predictable cost — a reliable default for most developers. - -**Auto Efficient** goes further. It observes your coding session in context, classifies the difficulty of each request in real time, and routes it to the *cheapest model proven accurate enough* for that specific task, based on Kilo's continuously running benchmarks. Routine tasks (small edits, lookups, quick explanations) are handled by leaner models; harder tasks (architecture, debugging, complex refactors) automatically get a more capable model. - -Efficient is also session-aware: it stays with a model across related turns and only switches when a cheaper option is clearly worth it. If it cannot make a routing decision with confidence, it falls back to Balanced — so quality never drops below Balanced. - -Think of Efficient as Balanced with an intelligent cost optimizer layered on top. +Efficient is also session-aware: it stays with a model across related turns and only switches when a cheaper option is clearly worth it. If it cannot make a routing decision with confidence, it falls back to a fixed, high-quality baseline model — so quality never drops below that baseline. You get consistent, strong results with predictable cost, plus an intelligent cost optimizer layered on top. {% callout type="tip" %} -For everyday coding tasks, start with **Auto Efficient** or **Auto Balanced**. Switch to **Auto Frontier** for complex architecture sessions or deep debugging where maximum capability matters. +For everyday coding tasks, start with **Auto Efficient**. Switch to **Auto Frontier** for complex architecture sessions or deep debugging where maximum capability matters. {% /callout %} ## How to Switch Auto Models diff --git a/packages/kilo-docs/pages/kiloclaw/end-to-end.md b/packages/kilo-docs/pages/kiloclaw/end-to-end.md index 2ff8e011726..901f76dab82 100644 --- a/packages/kilo-docs/pages/kiloclaw/end-to-end.md +++ b/packages/kilo-docs/pages/kiloclaw/end-to-end.md @@ -158,6 +158,6 @@ Or ask your Claw to build a custom skill from scratch — it has a built-in skil ## Manage inference -**Model picker:** Balanced is a good starting point. Frontier is more capable but significantly more expensive. +**Model picker:** Efficient is a good starting point. Frontier is more capable but significantly more expensive. You can also use your [Kilo Pass](https://kilo.ai/pricing/kilo-pass) credits — find this under **Profile** in the dashboard. diff --git a/packages/kilo-docs/pages/kiloclaw/overview.md b/packages/kilo-docs/pages/kiloclaw/overview.md index a55e43395fa..8af8b3ef184 100644 --- a/packages/kilo-docs/pages/kiloclaw/overview.md +++ b/packages/kilo-docs/pages/kiloclaw/overview.md @@ -37,7 +37,7 @@ Depending on your setup, you can also use: {% image src="/docs/img/kiloclaw/profile-claw-nav.png" alt="Profile page showing Claw navigation" width="400" caption="Claw navigation in profile sidebar" /%} 3. Click **Create Instance** -4. Your instance will use **Kilo Auto Balanced** as the default model. You can optionally select a different model from the dropdown — see all available models at the [Kilo Leaderboard](https://kilo.ai/leaderboard#all-models). +4. Your instance will use **Kilo Auto Efficient** as the default model. You can optionally select a different model from the dropdown — see all available models at the [Kilo Leaderboard](https://kilo.ai/leaderboard#all-models). {% image src="/docs/img/kiloclaw/create-instance.png" alt="Create instance modal with model selection" width="600" caption="Model selection during instance creation" /%} From 450b8a2242887aef22c8eac557509b8eee77fec2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 09:20:19 -0400 Subject: [PATCH 035/157] chore: sync bun.lock kilo-jetbrains version with package.json Lockfile still listed 7.4.17; package.json is pinned to 7.4.20. --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 1a09d995841..0f1a05b52c8 100644 --- a/bun.lock +++ b/bun.lock @@ -345,7 +345,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.17", + "version": "7.4.20", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", From 573a660cc1026469338b2fabcff52b8012a4f78a Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 5 Aug 2026 15:26:55 +0200 Subject: [PATCH 036/157] fix(vscode): keep sidebar navigation visible in the secondary side bar --- .changeset/fix-secondary-sidebar-nav-bar.md | 5 + packages/kilo-vscode/package.json | 121 +----------------- packages/kilo-vscode/src/KiloProvider.ts | 1 + packages/kilo-vscode/src/extension.ts | 33 +---- .../src/kilo-provider/sidebar-worktree.ts | 6 + packages/kilo-vscode/webview-ui/src/App.tsx | 5 + .../src/components/chat/SidebarTopBar.tsx | 88 +++++++++++++ .../kilo-vscode/webview-ui/src/i18n/en.ts | 8 ++ .../webview-ui/src/styles/chat-layout.css | 29 +++++ .../src/types/messages/webview-messages.ts | 5 + 10 files changed, 149 insertions(+), 152 deletions(-) create mode 100644 .changeset/fix-secondary-sidebar-nav-bar.md create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/SidebarTopBar.tsx diff --git a/.changeset/fix-secondary-sidebar-nav-bar.md b/.changeset/fix-secondary-sidebar-nav-bar.md new file mode 100644 index 00000000000..070b89d5f80 --- /dev/null +++ b/.changeset/fix-secondary-sidebar-nav-bar.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the sidebar navigation bar (New Task, History, Agent Manager, KiloClaw, Marketplace, Profile, Settings) disappearing when the Kilo Code view is docked in the Secondary Side Bar. The navigation is now rendered inside the webview itself, so it stays visible regardless of where the view is docked. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 26d6af3ffad..bf4a1ed088c 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -141,41 +141,6 @@ "category": "Kilo Code", "icon": "$(settings-gear)" }, - { - "command": "kilo-code.new.sidebarTitle.plusButtonClicked", - "title": "New Task", - "icon": "$(add)" - }, - { - "command": "kilo-code.new.sidebarTitle.agentManagerOpen", - "title": "Agent Manager", - "icon": "$(organization)" - }, - { - "command": "kilo-code.new.sidebarTitle.kiloClawOpen", - "title": "KiloClaw", - "icon": "$(comment-discussion)" - }, - { - "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", - "title": "Marketplace", - "icon": "$(extensions)" - }, - { - "command": "kilo-code.new.sidebarTitle.historyButtonClicked", - "title": "History", - "icon": "$(history)" - }, - { - "command": "kilo-code.new.sidebarTitle.profileButtonClicked", - "title": "Profile", - "icon": "$(account)" - }, - { - "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", - "title": "Settings", - "icon": "$(settings-gear)" - }, { "command": "kilo-code.new.openInTab", "title": "Open in Tab", @@ -461,76 +426,12 @@ ], "menus": { "commandPalette": [ - { - "command": "kilo-code.new.sidebarTitle.plusButtonClicked", - "when": "false" - }, - { - "command": "kilo-code.new.sidebarTitle.historyButtonClicked", - "when": "false" - }, - { - "command": "kilo-code.new.sidebarTitle.agentManagerOpen", - "when": "false" - }, - { - "command": "kilo-code.new.sidebarTitle.kiloClawOpen", - "when": "false" - }, { "command": "kilo-code.new.agentManager.advancedWorktree", "when": "false" - }, - { - "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", - "when": "false" - }, - { - "command": "kilo-code.new.sidebarTitle.profileButtonClicked", - "when": "false" - }, - { - "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", - "when": "false" - } - ], - "view/title": [ - { - "command": "kilo-code.new.sidebarTitle.plusButtonClicked", - "group": "navigation@0", - "when": "view == kilo-code.SidebarProvider" - }, - { - "command": "kilo-code.new.sidebarTitle.historyButtonClicked", - "group": "navigation@1", - "when": "view == kilo-code.SidebarProvider" - }, - { - "command": "kilo-code.new.sidebarTitle.agentManagerOpen", - "group": "navigation@2", - "when": "view == kilo-code.SidebarProvider" - }, - { - "command": "kilo-code.new.sidebarTitle.kiloClawOpen", - "group": "navigation@3", - "when": "view == kilo-code.SidebarProvider" - }, - { - "command": "kilo-code.new.sidebarTitle.marketplaceButtonClicked", - "group": "navigation@4", - "when": "view == kilo-code.SidebarProvider" - }, - { - "command": "kilo-code.new.sidebarTitle.profileButtonClicked", - "group": "navigation@5", - "when": "view == kilo-code.SidebarProvider" - }, - { - "command": "kilo-code.new.sidebarTitle.settingsButtonClicked", - "group": "navigation@6", - "when": "view == kilo-code.SidebarProvider" } ], + "view/title": [], "scm/title": [ { "command": "kilo-code.new.generateCommitMessage", @@ -550,26 +451,6 @@ "command": "kilo-code.new.openInTab", "group": "navigation", "when": "true" - }, - { - "command": "kilo-code.new.plusButtonClicked", - "group": "navigation@0", - "when": "activeWebviewPanelId == kilo-code.new.TabPanel" - }, - { - "command": "kilo-code.new.historyButtonClicked", - "group": "navigation@1", - "when": "activeWebviewPanelId == kilo-code.new.TabPanel" - }, - { - "command": "kilo-code.new.profileButtonClicked", - "group": "navigation@2", - "when": "activeWebviewPanelId == kilo-code.new.TabPanel" - }, - { - "command": "kilo-code.new.settingsButtonClicked", - "group": "navigation@3", - "when": "activeWebviewPanelId == kilo-code.new.TabPanel" } ], "editor/context": [ diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index c04d818b4b8..15c4f706bd6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1046,6 +1046,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper openAdvancedWorktree: () => vscode.commands.executeCommand("kilo-code.new.agentManager.advancedWorktree"), openChanges: (sessionId?: string, turnId?: string) => vscode.commands.executeCommand("kilo-code.new.showChanges", { sessionId, turnId }), + openProfile: () => vscode.commands.executeCommand("kilo-code.new.profileButtonClicked"), currentSessionId: this.currentSession?.id, createWorktree: async (baseBranch, branchName) => { await this.createWorktreeHandler?.(baseBranch, branchName) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b1549e3c479..0cef572e1b1 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -17,7 +17,7 @@ import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-bac import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager" import { AttentionService } from "./services/attention" import { BrowserAutomationService } from "./services/browser-automation" -import { TelemetryEventName, TelemetryProxy } from "./services/telemetry" +import { TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions" import { registerToggleAutoApprove } from "./commands/toggle-auto-approve" @@ -329,39 +329,8 @@ export function activate(context: vscode.ExtensionContext) { }), ) - // Sidebar menus use wrapper commands so this event measures real title button presses, - // not programmatic opens, shortcuts, or editor title commands. - const track = (button: string, command: string) => { - TelemetryProxy.capture(TelemetryEventName.TITLE_BUTTON_CLICKED, { - button, - surface: "sidebar_title", - }) - void vscode.commands.executeCommand(command) - } - // Register toolbar button command handlers context.subscriptions.push( - vscode.commands.registerCommand("kilo-code.new.sidebarTitle.plusButtonClicked", () => { - track("new_task", "kilo-code.new.plusButtonClicked") - }), - vscode.commands.registerCommand("kilo-code.new.sidebarTitle.historyButtonClicked", () => { - track("history", "kilo-code.new.historyButtonClicked") - }), - vscode.commands.registerCommand("kilo-code.new.sidebarTitle.agentManagerOpen", () => { - track("agent_manager", "kilo-code.new.agentManagerOpen") - }), - vscode.commands.registerCommand("kilo-code.new.sidebarTitle.kiloClawOpen", () => { - track("kiloclaw", "kilo-code.new.kiloClawOpen") - }), - vscode.commands.registerCommand("kilo-code.new.sidebarTitle.marketplaceButtonClicked", () => { - track("marketplace", "kilo-code.new.marketplaceButtonClicked") - }), - vscode.commands.registerCommand("kilo-code.new.sidebarTitle.profileButtonClicked", () => { - track("profile", "kilo-code.new.profileButtonClicked") - }), - vscode.commands.registerCommand("kilo-code.new.sidebarTitle.settingsButtonClicked", () => { - track("settings", "kilo-code.new.settingsButtonClicked") - }), vscode.commands.registerCommand("kilo-code.new.plusButtonClicked", () => { const tab = activeTabProvider() if (tab) tab.postMessage({ type: "action", action: "plusButtonClicked" }) diff --git a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts index dc4d54fe5b0..10963338214 100644 --- a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts +++ b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts @@ -15,6 +15,7 @@ interface Ctx { openAgentManager: () => Thenable openAdvancedWorktree: () => Thenable openChanges: (sessionId?: string, turnId?: string) => Thenable + openProfile: () => Thenable currentSessionId?: string createWorktree?: (baseBranch?: string, branchName?: string) => Promise continueInWorktree?: ( @@ -59,6 +60,11 @@ export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) { return true } + if (message.type === "openProfilePanel") { + await ctx.openProfile() + return true + } + if (message.type !== "continueInWorktree") return false handleContinueInWorktree({ sessionId: message.sessionId, diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 7fa356e8482..d44a4092b96 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -11,6 +11,7 @@ import { LocalTabsProvider, useLocalTabs } from "./context/local-tabs" import { ProviderShell } from "./context/provider-shell" import { ChatView } from "./components/chat" import { SidebarEmptyState } from "./components/chat/SidebarEmptyState" +import { SidebarTopBar } from "./components/chat/SidebarTopBar" import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded" import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides" @@ -310,6 +311,10 @@ const AppContent: Component = () => { return (
+ handleViewAction("plusButtonClicked")} + onHistory={() => handleViewAction("historyButtonClicked")} + /> {/* legacy-migration start — state-driven overlay, independent of currentView */} void + onHistory: () => void +} + +interface Action { + key: string + icon: IconButtonProps["icon"] + button: string + run: () => void +} + +export const SidebarTopBar: Component = (props) => { + const vscode = useVSCode() + const language = useLanguage() + + // Mirrors the telemetry the native `view/title` toolbar buttons used to + // record before this bar replaced them (see kilo-code.new.sidebarTitle.* + // history in extension.ts), so button-usage analytics aren't silently lost. + const track = (button: string) => + vscode.postMessage({ + type: "telemetry", + event: TelemetryEventName.TITLE_BUTTON_CLICKED, + properties: { button, surface: "sidebar_title" }, + }) + + const open = ( + type: "openAgentManager" | "openKiloClaw" | "openMarketplacePanel" | "openProfilePanel" | "openSettingsPanel", + ) => vscode.postMessage({ type }) + + const actions: (Action | "spacer")[] = [ + { key: "newTask", icon: "plus", button: "new_task", run: props.onNewTask }, + { key: "history", icon: "history", button: "history", run: props.onHistory }, + { key: "agentManager", icon: "layers", button: "agent_manager", run: () => open("openAgentManager") }, + { key: "kiloClaw", icon: "comment", button: "kiloclaw", run: () => open("openKiloClaw") }, + { key: "marketplace", icon: "providers", button: "marketplace", run: () => open("openMarketplacePanel") }, + "spacer", + { key: "profile", icon: "status", button: "profile", run: () => open("openProfilePanel") }, + { key: "settings", icon: "settings-gear", button: "settings", run: () => open("openSettingsPanel") }, + ] + + return ( +