From 6b8c736dc1c97544467f6edf8026d271149e4164 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 21 Jul 2026 14:14:06 -0600 Subject: [PATCH 01/67] feat(cli): add privacy_mode for blurring PII in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `privacy_mode` config flag that masks personal/team information in the always-visible sidebar footer and requires explicit confirmation before `/profile` reveals the full account details. The CLI `kilo profile` command is unaffected. Always-visible sidebar: - Personal/team balance renders as `•••` when the flag is on - Team label collapses to "Team credits" instead of the org name - Kilo Pass period usage, bonus, and renew date are hidden `/profile` gate: - Show a DialogConfirm (default: Cancel, action: Reveal) that warns email, name, balance, and team will be exposed before fetching the profile `/privacy` command: - Toggles `privacy_mode` in the global config and refreshes sync Mechanism: - New top-level `privacy_mode` boolean in ConfigV1.Info (kilocode_change) - Registered in the overlay field paths so it's editable - SDK regenerated for the new SdkConfig field - DialogConfirm extended with optional `confirmLabel` and `defaultOption` props (kilocode_change) for the gate UX All edits are isolated to kilocode paths or wrapped in kilocode_change markers to keep the upstream diff minimal. --- .changeset/privacy-mode-tui.md | 5 +++ packages/core/src/v1/config/config.ts | 4 ++ .../opencode/src/kilocode/config/overlay.ts | 1 + .../opencode/src/kilocode/kilo-commands.tsx | 44 +++++++++++++++++++ packages/opencode/src/kilocode/pii.ts | 1 + .../src/kilocode/plugins/sidebar-footer.tsx | 13 ++++-- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 3 ++ 8 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 .changeset/privacy-mode-tui.md create mode 100644 packages/opencode/src/kilocode/pii.ts diff --git a/.changeset/privacy-mode-tui.md b/.changeset/privacy-mode-tui.md new file mode 100644 index 0000000000..9131915a7e --- /dev/null +++ b/.changeset/privacy-mode-tui.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add a privacy mode that blurs PII in the TUI (personal balance, Kilo Pass usage, etc.) and requires confirmation before `/profile` reveals email, name, balance, and team. Toggle with the new `/privacy` command or by setting `privacy_mode` in `kilo.json`. The `kilo profile` CLI command is unaffected. \ No newline at end of file diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 233f623837..a2781b892f 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -129,6 +129,10 @@ export const Info = Schema.Struct({ hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({ description: "Hide Kilo Gateway models that may train on your prompts from model listings", }), + privacy_mode: Schema.optional(Schema.Boolean).annotate({ + description: + "Blur personally identifiable information (account email, balance, team name, etc.) in the TUI and require confirmation before showing profile details", + }), sandbox: Schema.optional( Schema.Struct({ enabled: Schema.optional( diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 2d576c3bce..486b7b3abe 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -80,6 +80,7 @@ export namespace KilocodeConfigOverlay { ["model"], ["small_model"], ["hide_prompt_training_models"], + ["privacy_mode"], ["default_agent"], ["snapshot"], ["share"], diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx index 58bb3e8de2..b8ffd08147 100644 --- a/packages/opencode/src/kilocode/kilo-commands.tsx +++ b/packages/opencode/src/kilocode/kilo-commands.tsx @@ -11,6 +11,8 @@ import { useRoute } from "@tui/context/route" import { useDialog } from "@tui/ui/dialog" import { useToast } from "@tui/ui/toast" import { DialogAlert } from "@tui/ui/dialog-alert" +import { DialogConfirm } from "@tui/ui/dialog-confirm" +import { reconcile } from "solid-js/store" import type { Organization } from "@kilocode/kilo-gateway" import type { ClawStatus } from "./claw/types.js" import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js" @@ -137,6 +139,15 @@ export function registerKiloCommands(useSDK: () => UseSDK) { hidden: !isKiloConnected(), run: async () => { try { + if (sync.data.config.privacy_mode === true) { + const confirmed = await DialogConfirm.show( + dialog, + "Privacy Mode Enabled", + "Privacy mode is on. Revealing your profile will display your email, name, balance, and team on screen.", + ) + if (confirmed !== true) return + } + // Fetch profile and balance using server endpoint const response = await sdk.client.kilo.profile() @@ -176,6 +187,39 @@ export function registerKiloCommands(useSDK: () => UseSDK) { ] : []), + // /privacy command + { + name: "kilo.privacy", + get title() { + return sync.data.config.privacy_mode === true ? "Disable privacy mode" : "Enable privacy mode" + }, + desc: "Blur PII (balance, email, etc.) and confirm before showing profile", + category: "Kilo", + slashName: "privacy", + run: async () => { + const next = sync.data.config.privacy_mode !== true + const response = await sdk.client.config.overlayUpdate({ + scope: "global", + set: { privacy_mode: next }, + }) + if (response.error) { + const status = response.response?.status ?? "?" + toast.show({ message: `Failed to update privacy mode (${status})`, variant: "error" }) + return + } + const [cfg, global] = await Promise.all([ + sdk.client.config.get({}), + sdk.client.global.config.get({}), + ]) + if (cfg.data) sync.set("config", reconcile(cfg.data)) + if (global.data) sync.set("globalConfig", reconcile(global.data)) + toast.show({ + message: next ? "Privacy mode enabled" : "Privacy mode disabled", + variant: "success", + }) + }, + }, + // /teams command { name: "kilo.teams", diff --git a/packages/opencode/src/kilocode/pii.ts b/packages/opencode/src/kilocode/pii.ts new file mode 100644 index 0000000000..f3d9002156 --- /dev/null +++ b/packages/opencode/src/kilocode/pii.ts @@ -0,0 +1 @@ +export const REDACTED_BALANCE = "•••" diff --git a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx index cf605c78bf..b7113eb5b8 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx @@ -5,6 +5,7 @@ import * as Log from "@opencode-ai/core/util/log" import type { KiloPassState } from "@kilocode/kilo-gateway" import type { Message } from "@kilocode/sdk/v2" import { onBalanceRefresh } from "../balance-refresh" +import { REDACTED_BALANCE } from "../pii" const id = "internal:kilo-sidebar-footer" const TEAM_POLL_MS = 5 * 60_000 @@ -37,8 +38,9 @@ export function scope(org: string | null | undefined, list?: readonly { id: stri } } -export function creditLabel(value: ReturnType) { +export function creditLabel(value: ReturnType, masked = false) { if (value.kind === "Personal") return "Personal credits" + if (masked) return "Team credits" return value.name ? `${value.name} team` : "Team credits" } @@ -98,6 +100,8 @@ function View(props: { api: TuiPluginApi }) { name: list.at(-1) ?? "", } }) + const privacyMode = createMemo(() => props.api.state.config.privacy_mode === true) + const balanceText = createMemo(() => (privacyMode() ? REDACTED_BALANCE : null)) const refresh = () => { const id = ++seq // Cancel any prior request and time this one out — the client path has no fetch timeout, @@ -167,19 +171,20 @@ function View(props: { api: TuiPluginApi }) { {(() => { const balance = data().balance if (balance === undefined) return null + const masked = balanceText() return ( - {creditLabel(data().scope)} + {creditLabel(data().scope, privacyMode())} - {format(balance)} + {masked ?? format(balance)} ) })()} - + {(pass) => ( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fe259e24a0..39f3b7453a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1582,6 +1582,7 @@ export type Config = { terminal_command_display?: "expanded" | "collapsed" code_edit_display?: "expanded" | "collapsed" hide_prompt_training_models?: boolean + privacy_mode?: boolean /** * Sandbox configuration for agent tools */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ed1b1a4fd6..65ff805e56 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -28115,6 +28115,9 @@ "hide_prompt_training_models": { "type": "boolean" }, + "privacy_mode": { + "type": "boolean" + }, "sandbox": { "type": "object", "properties": { From 897f48a0ec7c67161f41f831952ac75ff36a0c04 Mon Sep 17 00:00:00 2001 From: Hardik Sharma Date: Fri, 31 Jul 2026 16:25:49 +0530 Subject: [PATCH 02/67] feat(jetbrains): show filenames first in file mentions --- .../session/ui/prompt/KiloPromptCompletionProvider.kt | 2 +- .../ui/prompt/KiloPromptCompletionProviderTest.kt | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index a2b6331bbb..d028a83028 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -252,7 +252,7 @@ class KiloPromptCompletionProvider( PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100) private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path) - .withPresentableText("@${file.path}") + .withPresentableText("@${file.name}") .withTailText(parent(file.path), true) .withIcon(icon(file)) .withLookupString(file.name) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index 76077daeab..16b65019b9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -211,6 +211,16 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { assertSame(AllIcons.Nodes.Folder, icon("src")) } + fun `test mention completion renders filename before parent path`() { + rpc.searchResult = FileSearchResultDto(files = listOf(file("src/foo/Bar.kt"))) + + complete("@bar") + + val view = LookupElementPresentation().also { item("src/foo/Bar.kt").renderElement(it) } + assertEquals("@Bar.kt", view.itemText) + assertEquals(" src/foo", view.tailText) + } + fun `test highlights known slash command at start`() { assertEquals( listOf(KiloPromptCompletionProvider.Highlight(0, 4, KiloPromptCompletionProvider.HighlightKind.COMMAND)), From 27fd873d9aed51225e6da3de2b7a5ee7ce7c47b8 Mon Sep 17 00:00:00 2001 From: Hardik Sharma Date: Fri, 31 Jul 2026 19:26:27 +0530 Subject: [PATCH 03/67] docs(changeset): note JetBrains file suggestion order --- .changeset/jetbrains-file-suggestions.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/jetbrains-file-suggestions.md diff --git a/.changeset/jetbrains-file-suggestions.md b/.changeset/jetbrains-file-suggestions.md new file mode 100644 index 0000000000..9ac86fcbf9 --- /dev/null +++ b/.changeset/jetbrains-file-suggestions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show file names before their containing folders in JetBrains `@file` suggestions. From b2f0bb0c2ba81d2753f370d1adb089d9e0493b5f Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 2 Aug 2026 13:15:45 -0400 Subject: [PATCH 04/67] 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 0000000000..817db02396 --- /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 7ee8dc0f84..f73b6c541c 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 fcbadd3827..b578567ed8 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 05/67] 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 0000000000..7f04efb0fb --- /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 304a93ea9c..72b3551622 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 894515662b..38e3f872ec 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 c47cfeceebcd6b2ae5c0d416bde00f7e57449df8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 14:53:48 -0400 Subject: [PATCH 06/67] 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 0000000000..7c77921274 --- /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 d18a8c0dee..25c44b5d90 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 93514a7a75..ab67406dbf 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 7eb0106b7c..5579e96a46 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 5f60ef388b..8e6edd1ed6 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 5d39e41feb..113a1c7b92 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 56e2bbb3ef..da8ff8a09a 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 15944a645d..5d1fc08ba8 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 df5882b500..d857e0f611 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 65d9d0909c..a75a3b5239 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 9b0ca26632..3d4cd77b63 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 0d19e12cfd..d9243cafa2 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 683bf55d43..ae0c82fff4 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 e9e1c52f3c..04da2eb846 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 c2e935a4ed..42e0dcad68 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 e43ba741bd..193c71cc7b 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 d391bcc32a..92b9a8f79e 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 ef4c5efe13..94a3cba275 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 2bf9c4c4cc..3d31ef7733 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 aef654244b..31af59b74c 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 8959598aa7..49dedd7bcb 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 b954188a5c..221755be45 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 77ea64c775..bbe9ae6b19 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 909073cae3..bb29932afc 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 bc74d81c93..dc251b70f4 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 ab7f800934..3e23202907 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 6018178065..9f38bca320 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 82818fccb4..26c54b7d08 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 07/67] 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 0000000000..4f9ce88e55 --- /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 1370c72aa1..22bea5de8e 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 0000000000..e2d831baa1 --- /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 6eba81785718bfb3af579877fb332336f3a6eae3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:44:25 -0400 Subject: [PATCH 08/67] 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 5579e96a46..06ca8f68f0 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 3e23202907..95ff55bf94 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 4cdbe9d32551dd97c9d3e6067abfac0e04e2a580 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 16:55:50 -0400 Subject: [PATCH 09/67] 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 94d5781126..c4b3d8e6f4 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 6e39e42c60..ced62e863b 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 6414e64b291619457c13d089102059e4e0f29b06 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 09:38:14 -0400 Subject: [PATCH 10/67] 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 0000000000..b4a871eebb --- /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 654ef86da0..ca2c92cf46 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 24ab5b67c5..0215144879 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 7bf4d3c12b..de63af8b03 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 11/67] 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 7f04efb0fb..0000000000 --- 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 b4a871eebb..0000000000 --- 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 7c77921274..fe8f75e3d3 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 4f9ce88e55..0000000000 --- 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 817db02396..0000000000 --- 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 06ca8f68f0..e1273bdfc1 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 12/67] 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 0000000000..0f32e3327a --- /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 157f9841d7..4f0ac69204 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 edbe590573..dcf5b5d9b7 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 c0b5e440ef..1ee04fd78d 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 9a2f11f550..1de6ab95c2 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 be4377e46f..4c61c085d5 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 39b844f989..5453637a15 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 92a2984b5a..ea26906b89 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 f8e8cee3a6..296cb70caf 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 4e1528f8a9..a98f679d89 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 c9825cb771..c4bd2f9f74 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 bd56efa827..563620705d 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 1965da5452..732a08a92c 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 3cb7d11ed6..e96a67d6b7 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 13/67] 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 dcf5b5d9b7..14ed0ee64c 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 1ee04fd78d..eaf604a9d0 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 1de6ab95c2..4b3c78e749 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 4c61c085d5..76736609bf 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 732a08a92c..b3af1f80eb 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 14/67] 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 4f0ac69204..157f9841d7 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 15/67] 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 82442564a8..c29e6e6c62 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 c5f1f98158..eb239a1f6f 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 d016709a68..fae9829a5e 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 5a36f3f190..dd39aaa22f 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 538a1686ac..c7a3c921e2 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 f8d61745f8..6973eb01b9 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 0000000000..01307f892b --- /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 ce540a25d4..d6f1a0ee65 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 0eaa3c5c01..dccff6811c 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 4d31c3a551..ecf0da6a41 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 02837849be..afbb61bcd0 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 eba9b76458..805df07f27 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 d540de8617..2dc3ea2106 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 a1d7480978..38a10054c3 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 16/67] 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 0000000000..cd7e51379b --- /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 c29e6e6c62..97dad25d05 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 0000000000..993ec8928e --- /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 eb239a1f6f..2c6413d750 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 fae9829a5e..d016709a68 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 dd39aaa22f..f2925161a4 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 6973eb01b9..0c4fde54f1 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 d6f1a0ee65..1087cf7531 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 dccff6811c..e9752ae0be 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 afbb61bcd0..02837849be 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 805df07f27..eba9b76458 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 2dc3ea2106..d540de8617 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 38a10054c3..a1d7480978 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 17/67] 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 c928c46ea7..11253134f2 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 18/67] 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 c86a249f51..9246facb48 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 19/67] 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 97dad25d05..28c8117b2a 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 2c6413d750..a883eebc5b 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 d016709a68..910df5eefa 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 f2925161a4..35df48eaa5 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 0c4fde54f1..43ed3b8949 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 1087cf7531..b3951bfab3 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 e9752ae0be..9c5773a896 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 82d2b6789c..532f6c713d 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 02837849be..a7f5464a68 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 eba9b76458..885a3f98bd 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 d540de8617..1b058c7b77 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 a1d7480978..173861141e 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 3f67cee923..90838910b0 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 91a0d0b650..4bce7c4fca 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 20/67] 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 c7a3c921e2..6b8b8d0f0f 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 ca2c92cf46..b29812ca12 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 01307f892b..2d68628ea9 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 0215144879..b55ef75ae7 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 21/67] 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 532f6c713d..6beb5f97c8 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 6a8302881daf12bb2de5c9296e085bc8e2aafdaf Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:28:55 -0400 Subject: [PATCH 22/67] feat(jetbrains): add jetbrains-cli-pin skill Add a skill that pins the JetBrains plugin to the latest released CLI, unpins to the local repo CLI, or fresh-regenerates the local CLI. Every command first cleans all leftover CLI binaries and build artifacts in the current worktree so each run starts from a fresh, artifact-free state. Reuses the release-jetbrains set-pin/pin-common helpers for validated version bumps and cross-links the skill from the JetBrains AGENTS.md. --- .kilo/skills/jetbrains-cli-pin/SKILL.md | 79 +++++++++++++++ .../skills/jetbrains-cli-pin/script/clean.ts | 23 +++++ .../jetbrains-cli-pin/script/cli-pin.ts | 95 +++++++++++++++++++ packages/kilo-jetbrains/AGENTS.md | 2 + 4 files changed, 199 insertions(+) create mode 100644 .kilo/skills/jetbrains-cli-pin/SKILL.md create mode 100644 .kilo/skills/jetbrains-cli-pin/script/clean.ts create mode 100644 .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts diff --git a/.kilo/skills/jetbrains-cli-pin/SKILL.md b/.kilo/skills/jetbrains-cli-pin/SKILL.md new file mode 100644 index 0000000000..ecd59d67da --- /dev/null +++ b/.kilo/skills/jetbrains-cli-pin/SKILL.md @@ -0,0 +1,79 @@ +--- +name: jetbrains-cli-pin +description: Use when pinning or unpinning the CLI version the Kilo JetBrains plugin uses, or fresh-regenerating the local repo CLI. Cleans all leftover CLI binaries and build artifacts in the current worktree so every operation starts from a fresh, artifact-free state. +--- + +# JetBrains CLI Pin + +Pin the Kilo JetBrains plugin to the latest released CLI, unpin it to use the local +repo CLI, or fresh-regenerate the local CLI while unpinned. Every command first cleans +all CLI/pin build artifacts and binaries in the current worktree so the result never +carries state from a previous run. + +Run all commands from the repository root of the worktree you want to affect. Paths are +relative, so they resolve to the current worktree, not the main checkout. + +## Two Controls + +The plugin's CLI behavior is governed by two independent values: + +| Control | Location | Meaning | +|---|---|---| +| Pin mode | `packages/kilo-jetbrains/gradle.properties` -> `kilo.cli.pinned` | `true` = download the released CLI at build/connect time. `false` = build and bundle the local repo CLI. | +| Pinned version | `packages/kilo-jetbrains/package.json` -> `version` | Which GitHub CLI release is downloaded and generated from when `pinned=true`. | + +"Pin to latest" means `kilo.cli.pinned=true` **and** `package.json` set to the latest +stable CLI release. "Unpin" means `kilo.cli.pinned=false` with a freshly built local CLI +bundled. + +## Commands + +```bash +bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts [--no-verify] +``` + +| Command | Steps | +|---|---| +| `pin` | Clean -> set `kilo.cli.pinned=true` -> bump `package.json` to latest release (via `set-pin.ts --latest`, which validates release assets) -> verify with a cold `gradlew clean typecheck`. | +| `unpin` | Clean -> set `kilo.cli.pinned=false` -> `:backend:buildRepoCli` (fresh CLI) -> `:backend:stageRepoCli` -> assert staged `kilo-cli.zip` -> verify with `gradlew typecheck`. | +| `regen` | Fast dev loop while unpinned: `rm -rf dist` -> `buildRepoCli` -> `stageRepoCli`. Refuses to run unless `kilo.cli.pinned=false`. | +| `clean` | Run the shared artifact clean only. | + +`--no-verify` skips the gradle verification build (rewrites + clean only). Use it when +offline or without Java 21. + +## Cleaned Artifacts + +`clean()` runs `./gradlew clean` plus targeted deletes. All paths are gitignored, so +tracked files are never touched. The clean removes the stale artifacts that otherwise +leak across a pin/unpin flip: + +| Artifact | Path | +|---|---| +| Repo CLI binaries | `packages/opencode/dist/` | +| Staged CLI archive | `packages/kilo-jetbrains/backend/build/generated/kilo-cli-res/kilo-cli.zip` | +| Generated props / checksums / OpenAPI client | `packages/kilo-jetbrains/backend/build/generated/` | +| Compiled resources (bundled zip on classpath) | `packages/kilo-jetbrains/backend/build/resources/` | +| CLI download cache | `packages/kilo-jetbrains/backend/build/cli-cache/` | + +The staged `kilo-cli.zip` is the nastiest leak: once it lands in `backend/build/resources/main/` +from an unpinned build, runtime prefers the bundled zip over downloading. A full clean is +the only reliable reset. + +## Notes + +- Verification builds pass `--no-configuration-cache` so the changed `kilo.cli.pinned` + value is re-read instead of served from the on-disk Gradle configuration cache. +- The `pin` verification is a cold build: it downloads the pinned CLI release via + `generateOpenApiSpec` and needs network access plus Java 21. Use `--no-verify` offline. +- `kilo.cli.pinned=false` is dev-only and not releasable. Production Gradle builds, + `script/build-version.sh`, and the release scripts hard-fail on `false` -- run `pin` + before releasing. + +## Related + +- Version-bump and release-gating logic lives in the `release-jetbrains` skill + (`.kilo/skills/release-jetbrains/SKILL.md`); this skill reuses its `set-pin.ts` and + `pin-common.ts` helpers. +- Background on the build wiring: the "CLI Pinning, Unpinning, and Bumping" and "CLI + Integration" sections of `packages/kilo-jetbrains/AGENTS.md`. diff --git a/.kilo/skills/jetbrains-cli-pin/script/clean.ts b/.kilo/skills/jetbrains-cli-pin/script/clean.ts new file mode 100644 index 0000000000..a58ddcc532 --- /dev/null +++ b/.kilo/skills/jetbrains-cli-pin/script/clean.ts @@ -0,0 +1,23 @@ +import { $ } from "bun" + +// Single source of truth for every CLI/pin artifact that can leak across a mode +// flip in the current worktree. Everything here is gitignored (dist, backend/build, +// .gradle), so cleaning never touches tracked files. +// +// The build's conditional sourceSets/dependsOn wiring in backend/build.gradle.kts only +// produces a correct package from a clean build/ directory. Incremental builds are what +// let a stale kilo-cli.zip survive a pin<->unpin flip, and runtime prefers a bundled +// zip over downloading -- so a full gradle clean is the reliable reset. +export async function clean(jb = "packages/kilo-jetbrains") { + // gradle clean wipes each project's build directory (including backend/build). + await $`./gradlew clean --quiet`.cwd(jb).nothrow() + + // Stale per-platform CLI binaries. build.ts only rm -rf dist for the platforms it + // builds, so old platform dirs can survive; wipe the whole tree. + await $`rm -rf packages/opencode/dist` + + // Belt-and-suspenders in case gradle clean was skipped or ran offline. + await $`rm -rf ${jb}/backend/build/generated`.nothrow() + await $`rm -rf ${jb}/backend/build/resources`.nothrow() + await $`rm -rf ${jb}/backend/build/cli-cache`.nothrow() +} diff --git a/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts new file mode 100644 index 0000000000..ae293b6b1f --- /dev/null +++ b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { parseArgs } from "util" +import { clean } from "./clean" + +const jb = "packages/kilo-jetbrains" +const props = `${jb}/gradle.properties` +const pkg = `${jb}/package.json` +const zip = `${jb}/backend/build/generated/kilo-cli-res/kilo-cli.zip` + +const arg = Bun.argv[2] +const cmd = arg && !arg.startsWith("-") ? arg : undefined +const { values } = parseArgs({ + args: cmd ? Bun.argv.slice(3) : Bun.argv.slice(2), + options: { + "no-verify": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, +}) + +if (values.help || !cmd) { + console.log(` +Usage: bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts [--no-verify] + +Commands: + pin Pin the JetBrains plugin to the latest released CLI. Cleans artifacts, + sets kilo.cli.pinned=true, bumps package.json to the latest release, + then verifies with a cold gradle build (needs network + Java 21). + unpin Use the local repo CLI. Cleans artifacts, sets kilo.cli.pinned=false, + fresh-builds and stages the repo CLI, then verifies with typecheck. + regen Fast dev loop: rebuild + restage the local repo CLI (requires unpinned). + clean Remove all CLI/pin build artifacts and binaries in the current worktree. + +Options: + --no-verify Skip the gradle verification build (rewrites + clean only). + +Run from the repository root of the worktree you want to affect. +`) + process.exit(values.help ? 0 : 1) +} + +async function pinned() { + const text = await Bun.file(props).text() + const line = text.split(/\r?\n/).find((l) => l.startsWith("kilo.cli.pinned=")) + return (line?.split("=", 2)[1]?.trim().toLowerCase() ?? "true") === "true" +} + +async function setPinned(value: boolean) { + const text = await Bun.file(props).text() + if (!/^kilo\.cli\.pinned=.*$/m.test(text)) throw new Error(`kilo.cli.pinned not found in ${props}`) + await Bun.write(props, text.replace(/^kilo\.cli\.pinned=.*$/m, `kilo.cli.pinned=${value}`)) +} + +async function report() { + const version = (await Bun.file(pkg).json()).version + console.log(`\nState: kilo.cli.pinned=${await pinned()}, package.json version=${version}`) +} + +if (cmd === "pin") { + await clean() + await setPinned(true) + // set-pin.ts bumps package.json to the latest release and refuses versions with + // missing runtime assets, so we do not reimplement release/asset validation. + await $`bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest` + if (!values["no-verify"]) { + // Cold pinned build downloads the pinned CLI release via generateOpenApiSpec. + await $`./gradlew clean typecheck --no-configuration-cache`.cwd(jb) + } + await report() +} else if (cmd === "unpin") { + await clean() + await setPinned(false) + // build.ts does rm -rf dist internally, producing a fresh single-platform binary. + await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb) + // stageRepoCli has upToDateWhen{false}; force it so the staged zip matches this build. + await $`./gradlew :backend:stageRepoCli --no-configuration-cache`.cwd(jb) + if (!(await Bun.file(zip).exists())) throw new Error(`Expected staged CLI at ${zip} after unpin`) + if (!values["no-verify"]) { + await $`./gradlew typecheck --no-configuration-cache`.cwd(jb) + } + await report() +} else if (cmd === "regen") { + if (await pinned()) throw new Error("regen requires the unpinned state; run 'unpin' first") + await $`rm -rf packages/opencode/dist` + await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb) + await $`./gradlew :backend:stageRepoCli --no-configuration-cache`.cwd(jb) + if (!(await Bun.file(zip).exists())) throw new Error(`Expected staged CLI at ${zip} after regen`) + await report() +} else if (cmd === "clean") { + await clean() + await report() +} else { + throw new Error(`Unknown command '${cmd}'. Run with --help for usage.`) +} diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 2901241038..254d66698f 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -170,6 +170,8 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi The JetBrains plugin has two independent CLI controls. Use the commands below directly when asked to change either one; do not hand-edit versions by guesswork. +For a one-shot pin/unpin/regen that also cleans every leftover CLI binary and build artifact in the current worktree, use the `jetbrains-cli-pin` skill (`.kilo/skills/jetbrains-cli-pin/SKILL.md`): `bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts `. + **Pin mode** (`kilo.cli.pinned` in `packages/kilo-jetbrains/gradle.properties`) controls release CLI vs local repo CLI. | Ask | Do | From 9a4410f42511024e6ddc98badd5d309c3c9e0120 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:44:28 -0400 Subject: [PATCH 23/67] 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 28c8117b2a..3861abeb3d 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 43ed3b8949..38e5b51d77 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 9b635e1bf4..a7b30a796c 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 22bea5de8e..a0f0013b98 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 24/67] 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 f48dbf9038..004bc837b5 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 5482dfbd49..115fb1000a 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 25/67] 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 004bc837b5..d6555546ee 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 26/67] 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 0000000000..9f7cacabf7 --- /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 5888693f01..cb5f41d289 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 0000000000..cac163c7f5 --- /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 0000000000..3bd4c0f88a --- /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 5361c083a4..71ee912e24 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 0000000000..955aa424a0 --- /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 0000000000..49eb582c64 --- /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 30a1779208..f427783168 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 0000000000..cc077248a0 --- /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 368225175b..a31700b7c0 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 0c0769d4da..e125349cff 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 67247b00b8..1b43d44206 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 27/67] 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 cc077248a0..8a634d793a 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 28/67] 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 0000000000..6c29101ce1 --- /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 069783c53f..f2bf96cb61 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 ea26906b89..d4be3b7ca6 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 b1549e3c47..20f842e324 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 0000000000..a4fa64f377 --- /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 0000000000..f6850a391d --- /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 563620705d..b840e119e4 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 29/67] 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 a287cf094c..d09392976d 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 d95fa6bf14..7512e72e6d 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 62067e340f..63b3fcc788 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 68d6677aa0..22c25c8fdc 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 922706b984..b7a78df9ca 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 8b49b32ea6..9850e87765 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 2ff8e01172..901f76dab8 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 a55e43395f..8af8b3ef18 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 30/67] 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 1a09d99584..0f1a05b52c 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 76f8967dc19a5f4f48726ad401fbd3142fd17750 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 5 Aug 2026 07:31:50 -0600 Subject: [PATCH 31/67] fix(cli): read privacy_mode only from global config and use balance-independent color The /privacy command wrote only to global config but read the effective config, so a project-level privacy_mode could shadow the global toggle and the UI would not change even though the command reported success. Since privacy mode is a personal preference, persist and read it only from the global config. The sidebar footer used tone() for both the bullet and masked balance, so the bullet color still revealed whether the balance was low. Use theme().textMuted for both while masked, retaining tone() only when privacy mode is off. --- packages/opencode/src/kilocode/kilo-commands.tsx | 6 +++--- packages/opencode/src/kilocode/plugins/sidebar-footer.tsx | 7 ++++--- packages/opencode/test/fixture/tui-plugin.ts | 6 ++++++ packages/plugin/src/tui.ts | 1 + packages/tui/src/plugin/adapters.tsx | 5 +++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx index b8ffd08147..5ad85cf974 100644 --- a/packages/opencode/src/kilocode/kilo-commands.tsx +++ b/packages/opencode/src/kilocode/kilo-commands.tsx @@ -139,7 +139,7 @@ export function registerKiloCommands(useSDK: () => UseSDK) { hidden: !isKiloConnected(), run: async () => { try { - if (sync.data.config.privacy_mode === true) { + if (sync.data.globalConfig.privacy_mode === true) { const confirmed = await DialogConfirm.show( dialog, "Privacy Mode Enabled", @@ -191,13 +191,13 @@ export function registerKiloCommands(useSDK: () => UseSDK) { { name: "kilo.privacy", get title() { - return sync.data.config.privacy_mode === true ? "Disable privacy mode" : "Enable privacy mode" + return sync.data.globalConfig.privacy_mode === true ? "Disable privacy mode" : "Enable privacy mode" }, desc: "Blur PII (balance, email, etc.) and confirm before showing profile", category: "Kilo", slashName: "privacy", run: async () => { - const next = sync.data.config.privacy_mode !== true + const next = sync.data.globalConfig.privacy_mode !== true const response = await sdk.client.config.overlayUpdate({ scope: "global", set: { privacy_mode: next }, diff --git a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx index b7113eb5b8..994a1b47a1 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx @@ -100,8 +100,9 @@ function View(props: { api: TuiPluginApi }) { name: list.at(-1) ?? "", } }) - const privacyMode = createMemo(() => props.api.state.config.privacy_mode === true) + const privacyMode = createMemo(() => props.api.state.globalConfig.privacy_mode === true) const balanceText = createMemo(() => (privacyMode() ? REDACTED_BALANCE : null)) + const mutedColor = createMemo(() => (privacyMode() ? theme().textMuted : tone())) const refresh = () => { const id = ++seq // Cancel any prior request and time this one out — the client path has no fetch timeout, @@ -175,12 +176,12 @@ function View(props: { api: TuiPluginApi }) { return ( - + {creditLabel(data().scope, privacyMode())} - {masked ?? format(balance)} + {masked ?? format(balance)} ) })()} diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 06706bc96b..13860c0d0b 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -97,6 +97,7 @@ type Opts = { state?: { ready?: HostPluginApi["state"]["ready"] config?: HostPluginApi["state"]["config"] + globalConfig?: HostPluginApi["state"]["globalConfig"] // kilocode_change provider?: HostPluginApi["state"]["provider"] path?: HostPluginApi["state"]["path"] vcs?: HostPluginApi["state"]["vcs"] @@ -303,6 +304,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { get config() { return opts.state?.config ?? {} }, + // kilocode_change start + get globalConfig() { + return opts.state?.globalConfig ?? {} + }, + // kilocode_change end get provider() { return opts.state?.provider ?? [] }, diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 5af4d6ee60..d7b0a709f0 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -376,6 +376,7 @@ export type TuiKV = { export type TuiState = { readonly ready: boolean readonly config: SdkConfig + readonly globalConfig: SdkConfig readonly provider: ReadonlyArray readonly path: { state: string diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 7f0e66c5e7..54cea5055e 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -107,6 +107,11 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { get config() { return sync.data.config }, + // kilocode_change start + get globalConfig() { + return sync.data.globalConfig + }, + // kilocode_change end get provider() { return sync.data.provider }, From a340d61716b6fdec89943bff438c151b513fd1f3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 09:37:33 -0400 Subject: [PATCH 32/67] feat(jetbrains): log CLI download/bundled mode and mark bundled Core version Backend now logs a clear one-line mode statement when resolving Core (BUNDLED vs DOWNLOAD), and logs distinctly when a cached/extracted binary is reused so no download or extraction happens. Adds a cliBundled() RPC so the frontend Core-info popup can prefix the version with "Bundled" when Core wasn't downloaded. --- .changeset/jetbrains-cli-mode-visibility.md | 5 +++ .../backend/cli/KiloBackendCliManager.kt | 12 ++++--- .../kilocode/backend/cli/KiloCliDownloader.kt | 2 +- .../ai/kilocode/backend/cli/KiloRepoCli.kt | 2 ++ .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 3 ++ .../backend/cli/KiloCliDownloaderTest.kt | 2 +- .../kilocode/client/actions/CoreInfoAction.kt | 4 ++- .../ai/kilocode/client/app/KiloAppService.kt | 33 +++++++++++++++++++ .../resources/messages/KiloBundle.properties | 1 + .../client/actions/KiloRecoveryActionsTest.kt | 15 +++++++++ .../kilocode/client/testing/FakeAppRpcApi.kt | 6 ++++ .../kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt | 3 ++ 12 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 .changeset/jetbrains-cli-mode-visibility.md diff --git a/.changeset/jetbrains-cli-mode-visibility.md b/.changeset/jetbrains-cli-mode-visibility.md new file mode 100644 index 0000000000..bc9cf774d1 --- /dev/null +++ b/.changeset/jetbrains-cli-mode-visibility.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Log whether the JetBrains plugin downloads Core or uses the bundled/cached version, and mark the Core version shown in the popup as "Bundled" when it wasn't downloaded. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index 9425f0779f..580d226c33 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -126,14 +126,18 @@ class KiloBackendCliManager( private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File { val force = forceExtract forceExtract = false + val version = KiloProps.cliVersion() + val platform = KiloCliPlatform.current() if (KiloRepoCli.available()) { - if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}") + if (force) log.info("Force re-extracting bundled CLI $version") + log.info("Kilo CLI mode: BUNDLED — using CLI $version ($platform) shipped in the plugin; no download needed") val cli = KiloRepoCli.extract(force) - onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current())) + onProgress(CliDownload(100, version, platform)) return cli } - if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}") - return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress) + if (force) log.info("Force re-downloading CLI $version") + log.info("Kilo CLI mode: DOWNLOAD — resolving CLI $version ($platform) from the GitHub release") + return KiloCliDownloader(log = log).resolve(version, force, onProgress) } // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index bee74c12bb..11cb6754ff 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -112,7 +112,7 @@ class KiloCliDownloader( "completeExists=${done.isFile} digestValid=$valid exe=${exe.absolutePath} complete=${done.absolutePath}" ) if (!exe.isFile || !valid) return null - log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") + log.info("Kilo CLI $version ($platform) already cached at ${exe.absolutePath}; skipping download and extraction") if (!SystemInfo.isWindows) exe.setExecutable(true) prune(version) return exe diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt index 8138ae4dae..617e805ba7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt @@ -36,10 +36,12 @@ object KiloRepoCli { val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}") val done = File(root, ".complete") if (!force && done.isFile && exe.isFile) { + log.info("Bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) already extracted at ${exe.absolutePath}; skipping extraction") if (!SystemInfo.isWindows) exe.setExecutable(true) if (cleanup) prune(root) return@withContext exe } + log.info("Extracting bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) into ${root.absolutePath}") if (root.exists() && !root.deleteRecursively()) { throw IllegalStateException("Failed to delete local repo CLI under ${root.absolutePath}") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 58e929dcd7..2fe3edca36 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -11,6 +11,7 @@ import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult import ai.kilocode.backend.cli.KiloCliPlatform import ai.kilocode.backend.cli.KiloProps +import ai.kilocode.backend.cli.KiloRepoCli import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.KiloAppRpcApi @@ -57,6 +58,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun cliPlatform(): String = KiloCliPlatform.current() + override suspend fun cliBundled(): Boolean = KiloRepoCli.available() + override suspend fun retry() = app.retry() override suspend fun restart() = app.restart() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index 8c489813a6..477f001849 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -70,7 +70,7 @@ class KiloCliDownloaderTest { assertEquals(cli.absolutePath, cached.absolutePath) assertEquals(1, server.requestCount) assertTrue(cachedProgress.isEmpty()) - assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}") + assertContains(log.messages, "INFO: Kilo CLI 1.2.3 (${KiloCliPlatform.current()}) already cached at ${cli.absolutePath}; skipping download and extraction") File(cli.parentFile.parentFile, ".complete").writeText("ok\n") server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt index 1bad925ec0..6ed21bcb69 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt @@ -15,8 +15,10 @@ class CoreInfoAction : AnAction(), DumbAware { val app = service() val info = app.core if (info == null) app.fetchCoreInfoAsync() + app.fetchBundledAsync() + val key = if (app.bundled == true) "action.Kilo.CoreInfo.bundled" else "action.Kilo.CoreInfo.text" e.presentation.text = info?.let { - KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform) + KiloBundle.message(key, it.version, it.platform) } ?: KiloBundle.message("action.Kilo.CoreInfo.loading") e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description") e.presentation.isEnabled = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 23443265e1..1751383899 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -58,6 +58,18 @@ class KiloAppService internal constructor( val version: String? get() = info?.version + /** + * Whether the running Core is bundled in the plugin (true) or downloaded (false). + * Null until fetched. This is a static property of the plugin build, so it is + * fetched once via RPC independently of the download-progress state. + */ + @Volatile + private var bundledFlag: Boolean? = null + private val bundledLock = Any() + private var bundledJob: Job? = null + + val bundled: Boolean? get() = bundledFlag + /** * App-lifetime scope for fire-and-forget work that must outlive transient UIs such as the * settings dialog (whose own scope is cancelled the moment it closes on OK). @@ -152,6 +164,7 @@ class KiloAppService internal constructor( platform = call { cliPlatform() }, ) info = next + bundledFlag = call { cliBundled() } next } catch (e: Exception) { LOG.warn("core info failed", e) @@ -200,6 +213,26 @@ class KiloAppService internal constructor( fetchCoreInfoAsync { done(it?.version) } } + /** Fetch whether the running Core is bundled and cache it. Deduped and fetched once. */ + fun fetchBundledAsync() { + if (bundledFlag != null) return + synchronized(bundledLock) { + if (bundledFlag != null || bundledJob != null) return + bundledJob = cs.launch { + val value = try { + call { cliBundled() } + } catch (e: Exception) { + LOG.warn("core bundled check failed", e) + null + } + synchronized(bundledLock) { + if (value != null) bundledFlag = value + bundledJob = null + } + } + } + } + fun refreshModelFavoritesAsync() { cs.launch { try { 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 faf0e7b782..84f9423db3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -731,6 +731,7 @@ action.Kilo.Reinstall.text=Reinstall action.Kilo.Reinstall.cli.text=Reinstall Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.CoreInfo.text=Core v{0} • Architecture: {1} +action.Kilo.CoreInfo.bundled=Bundled Core v{0} • Architecture: {1} action.Kilo.CoreInfo.loading=Core details loading... action.Kilo.CoreInfo.description=Kilo Core version and architecture action.Kilo.Session.Open.text=Open diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 477a78333c..9d4689d514 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -126,6 +126,21 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals("Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) } + fun `test core info action marks bundled core`() { + appRpc.cliVersion = "1.2.3" + appRpc.cliPlatform = "darwin-arm64" + appRpc.cliBundled = true + ApplicationManager.getApplication().executeOnPooledThread { + runBlocking { app().coreInfo() } + }.get() + val action = CoreInfoAction() + val event = event(action) + + update(action, event) + + assertEquals("Bundled Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) + } + fun `test local config action says open when target exists`() { rpc.localConfigPath = "/test/.kilo/kilo.jsonc" rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 7c7d4a9aff..ba9ce1d3bc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -37,6 +37,7 @@ class FakeAppRpcApi : KiloAppRpcApi { var health = HealthDto(healthy = true, version = "1.0.0") var cliVersion = "1.0.0" var cliPlatform = "darwin-arm64" + var cliBundled = false var cliInfoGate: CompletableDeferred? = null var cliInfoError: Exception? = null var cliVersionCalls = 0 @@ -94,6 +95,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return cliPlatform } + override suspend fun cliBundled(): Boolean { + assertNotEdt("cliBundled") + return cliBundled + } + override suspend fun retry() { assertNotEdt("retry") retries += 1 diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index e05b4c3bdf..2322ce6164 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -45,6 +45,9 @@ interface KiloAppRpcApi : RemoteApi { /** Core platform downloaded by the backend process. */ suspend fun cliPlatform(): String + /** Whether the running Core is bundled in the plugin (true) or downloaded (false). */ + suspend fun cliBundled(): Boolean + /** Retry app connection or loading after a failure. */ suspend fun retry() From 7ee1909f73485197175c05411ac23c70d035d2ec Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 09:37:44 -0400 Subject: [PATCH 33/67] fix(jetbrains): resolve Bun path for repo CLI generation under IDE Gradle runs IDE-launched Gradle runs (e.g. Run IDE Split Mode) can have a stripped PATH where 'bun' isn't resolvable, so :backend:generateOpenApiSpec and :backend:buildRepoCli failed with 'A problem occurred starting process command bun' in repo-CLI (unpinned) mode. The jetbrains-cli-pin skill's unpin/regen commands now write an ignored, worktree-local Bun path hint (.gradle/kilo-cli-pin.properties) that backend/build.gradle.kts resolves and passes as an absolute path to the affected tasks. pin removes the hint since pinned mode doesn't depend on local Bun. --- .kilo/skills/jetbrains-cli-pin/SKILL.md | 19 ++++++++++++++--- .../jetbrains-cli-pin/script/cli-pin.ts | 21 +++++++++++++++++++ .../kilo-jetbrains/backend/build.gradle.kts | 10 ++++++++- .../main/kotlin/GenerateOpenApiSpecTask.kt | 5 ++++- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/.kilo/skills/jetbrains-cli-pin/SKILL.md b/.kilo/skills/jetbrains-cli-pin/SKILL.md index ecd59d67da..2fc5b06ef5 100644 --- a/.kilo/skills/jetbrains-cli-pin/SKILL.md +++ b/.kilo/skills/jetbrains-cli-pin/SKILL.md @@ -34,9 +34,9 @@ bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts [--no-verify] | Command | Steps | |---|---| -| `pin` | Clean -> set `kilo.cli.pinned=true` -> bump `package.json` to latest release (via `set-pin.ts --latest`, which validates release assets) -> verify with a cold `gradlew clean typecheck`. | -| `unpin` | Clean -> set `kilo.cli.pinned=false` -> `:backend:buildRepoCli` (fresh CLI) -> `:backend:stageRepoCli` -> assert staged `kilo-cli.zip` -> verify with `gradlew typecheck`. | -| `regen` | Fast dev loop while unpinned: `rm -rf dist` -> `buildRepoCli` -> `stageRepoCli`. Refuses to run unless `kilo.cli.pinned=false`. | +| `pin` | Clean -> set `kilo.cli.pinned=true` -> remove the repo-CLI Bun path hint -> bump `package.json` to latest release (via `set-pin.ts --latest`, which validates release assets) -> verify with a cold `gradlew clean typecheck`. | +| `unpin` | Clean -> set `kilo.cli.pinned=false` -> write the repo-CLI Bun path hint -> `:backend:buildRepoCli` (fresh CLI) -> `:backend:stageRepoCli` -> assert staged `kilo-cli.zip` -> verify with `gradlew typecheck`. | +| `regen` | Fast dev loop while unpinned: refresh the repo-CLI Bun path hint -> `rm -rf dist` -> `buildRepoCli` -> `stageRepoCli`. Refuses to run unless `kilo.cli.pinned=false`. | | `clean` | Run the shared artifact clean only. | `--no-verify` skips the gradle verification build (rewrites + clean only). Use it when @@ -60,6 +60,19 @@ The staged `kilo-cli.zip` is the nastiest leak: once it lands in `backend/build/ from an unpinned build, runtime prefers the bundled zip over downloading. A full clean is the only reliable reset. +## Bun Path Hint + +In repo CLI mode, Gradle's `generateOpenApiSpec` task runs the local CLI source through +`bun run --conditions=browser ./src/index.ts generate`. IDE-launched Gradle runs can have +worktree-local hint: + +```text +packages/kilo-jetbrains/.gradle/kilo-cli-pin.properties +``` + +The file contains `bun.path=` and is consumed by `backend/build.gradle.kts` +for repo CLI tasks. `pin` removes it because pinned mode should not depend on local Bun. + ## Notes - Verification builds pass `--no-configuration-cache` so the changed `kilo.cli.pinned` diff --git a/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts index ae293b6b1f..a7223d3cba 100644 --- a/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts +++ b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts @@ -8,6 +8,7 @@ const jb = "packages/kilo-jetbrains" const props = `${jb}/gradle.properties` const pkg = `${jb}/package.json` const zip = `${jb}/backend/build/generated/kilo-cli-res/kilo-cli.zip` +const hint = `${jb}/.gradle/kilo-cli-pin.properties` const arg = Bun.argv[2] const cmd = arg && !arg.startsWith("-") ? arg : undefined @@ -52,6 +53,23 @@ async function setPinned(value: boolean) { await Bun.write(props, text.replace(/^kilo\.cli\.pinned=.*$/m, `kilo.cli.pinned=${value}`)) } +async function bunPath() { + const result = await $`command -v bun`.quiet().nothrow() + const bin = result.exitCode === 0 ? result.stdout.toString().trim() : "" + return bin || process.execPath +} + +async function writeBunHint() { + const path = await bunPath() + await $`mkdir -p ${jb}/.gradle` + await Bun.write(hint, `# Generated by jetbrains-cli-pin so IDE-launched Gradle can find Bun in repo CLI mode.\nbun.path=${path}\n`) + console.log(`Wrote Bun path hint for repo CLI mode: ${path}`) +} + +async function removeBunHint() { + await $`rm -f ${hint}`.nothrow() +} + async function report() { const version = (await Bun.file(pkg).json()).version console.log(`\nState: kilo.cli.pinned=${await pinned()}, package.json version=${version}`) @@ -60,6 +78,7 @@ async function report() { if (cmd === "pin") { await clean() await setPinned(true) + await removeBunHint() // set-pin.ts bumps package.json to the latest release and refuses versions with // missing runtime assets, so we do not reimplement release/asset validation. await $`bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest` @@ -71,6 +90,7 @@ if (cmd === "pin") { } else if (cmd === "unpin") { await clean() await setPinned(false) + await writeBunHint() // build.ts does rm -rf dist internally, producing a fresh single-platform binary. await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb) // stageRepoCli has upToDateWhen{false}; force it so the staged zip matches this build. @@ -82,6 +102,7 @@ if (cmd === "pin") { await report() } else if (cmd === "regen") { if (await pinned()) throw new Error("regen requires the unpinned state; run 'unpin' first") + await writeBunHint() await $`rm -rf packages/opencode/dist` await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb) await $`./gradlew :backend:stageRepoCli --no-configuration-cache`.cwd(jb) diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index e09355e19f..bcba0effb4 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -26,6 +26,13 @@ val repoCli = pinned.map { !it } val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false) val downloadsCli = repoCli.zip(bundled) { repo, bundle -> !repo && !bundle } val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode") +val local = rootProject.layout.projectDirectory.file(".gradle/kilo-cli-pin.properties") +val bunPathProvider = providers.fileContents(local).asText.map { text -> + text.lineSequence().firstNotNullOfOrNull { line -> + val pair = line.split("=", limit = 2) + if (pair.getOrNull(0)?.trim() == "bun.path") pair.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() } else null + } ?: "bun" +}.orElse("bun") val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text -> Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1) @@ -64,12 +71,13 @@ val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) { ) cacheDir.set(layout.buildDirectory.dir("cli-cache")) spec.set(rawSpec) + bunPath.set(bunPathProvider) } val buildRepoCli by tasks.registering(Exec::class) { description = "Build the local repo CLI for the current platform" workingDir = repoRootDir.asFile - commandLine("bun", "run", "script/build.ts", "--single", "--skip-install") + commandLine(bunPathProvider.get(), "run", "script/build.ts", "--single", "--skip-install") } fun platform(): String { 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 5482dfbd49..1c1181c34d 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -49,6 +49,9 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { @get:Internal abstract val cacheDir: DirectoryProperty + @get:Internal + abstract val bunPath: Property + @get:OutputFile abstract val spec: RegularFileProperty @@ -76,7 +79,7 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { val err = ByteArrayOutputStream() val result = exec.exec { workingDir = root - commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate") + commandLine(bunPath.get(), "run", "--conditions=browser", "./src/index.ts", "generate") standardOutput = out errorOutput = err isIgnoreExitValue = true From a3160d784225628d461353c18d49b643e33b79b6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 09:52:21 -0400 Subject: [PATCH 34/67] fix(jetbrains): harden diff fallback handling --- .../ai/kilocode/backend/app/KiloBackendSessionManager.kt | 5 +++-- .../kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt | 6 ++++++ .../kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt | 5 +++++ .../main/kotlin/ai/kilocode/client/session/model/Message.kt | 2 ++ .../kotlin/ai/kilocode/client/session/model/SessionModel.kt | 1 + .../ai/kilocode/client/session/views/tool/EditToolView.kt | 4 +++- .../ai/kilocode/client/session/model/SessionModelTest.kt | 1 + packages/opencode/src/snapshot/index.ts | 4 ++-- packages/opencode/test/kilocode/summary-file-diff.test.ts | 4 ++-- 9 files changed, 25 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 d6555546ee..e5d5a002c1 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 @@ -350,9 +350,10 @@ class KiloBackendSessionManager( 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 + val message = str("getMessageID") + ?: return@runCatching null.also { log.info("revertDto reflective getMessageID missing on ${cls.name}") } revertDto(message, str("getPartID"), str("getSnapshot"), str("getDiff")) - }.getOrNull() + }.onFailure { log.info("revertDto reflective decode failed for ${s.javaClass.name}: ${it.message}") }.getOrNull() } private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) = 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 index cd7e51379b..0b1f063f75 100644 --- 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 @@ -10,6 +10,12 @@ package ai.kilocode.backend.diff * frontend reconstructs those full sides directly. Binary patches and any drift between the patch's * after side and the real file (a stale/historical turn) also return null so the caller can fall back * to the hunk-only view instead of rendering a wrong diff. + * + * Known limitation: `\ No newline at end of file` markers are dropped rather than tracked per side, so + * the reconstructed `before` inherits the after side's trailing-newline state. When exactly one side + * lacks a trailing newline, the whole-file fallback view will not surface that EOF-newline change. This + * is cosmetic and rare (the scoped hunk view still shows the marker); tracking it per side would require + * remembering which side the marker followed. */ internal object DiffFullReconstruct { private val HUNK = Regex("^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@") 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 38e5b51d77..e257e62d97 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 @@ -173,6 +173,11 @@ internal class KiloDiffEditorService( files: List, session: KiloSessionService, ): List { + // Revert diffs already carry range-scoped patches from the CLI's `revert.diff`. Whole-file + // enrichment has no per-message scope for a revert here, so the authoritative endpoint would + // return the whole-session before/after and splice in changes from kept turns. Render the + // scoped hunk patches directly instead. + if (params["token"].takeIfPresent()?.startsWith("revert:") == true) return files 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()}") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index 7eeaa95150..beee188bd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -77,6 +77,8 @@ class FileAttachment(id: String) : Content(id) { /** Tool invocation with lifecycle state. */ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { + /** Owning message id. The CLI scopes authoritative snapshot diffs by message, not part, id. */ + var messageID: String? = null var state: ToolExecState = ToolExecState.PENDING var callId: String? = null var title: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 14f7b8c027..1d918c70bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -554,6 +554,7 @@ class SessionModel { source = dto.source } "tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply { + messageID = dto.messageID state = parseToolState(dto.state) callId = dto.callID title = dto.title diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 77fb712763..831bfb6ea1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -254,7 +254,9 @@ class EditToolView( private fun openDiffViewer() { val files = toDiffFiles(item) if (files.isEmpty()) return - opener(files, diffTitle(item), "tool:${sessionId ?: "pending"}:${item.id}") + // The CLI scopes the authoritative snapshot diff by message id, so carry the owning message id + // (not the tool part id) in the token; otherwise the per-message lookup never matches. + opener(files, diffTitle(item), "tool:${sessionId ?: "pending"}:${item.messageID ?: item.id}") } private fun syncFilesTag(count: Int): Boolean { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index a210429af0..2479977f63 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -375,6 +375,7 @@ class SessionModelTest : BasePlatformTestCase() { ) val p = model.message("m1")!!.parts["p1"] as Tool + assertEquals("m1", p.messageID) assertEquals("git log", p.input["command"]) assertEquals("Show history", p.input["description"]) assertEquals("state", p.metadata["source"]) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 20043447cb..39779464ca 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,8 +34,8 @@ export type Patch = typeof Patch.Type export const FileDiff = Info.pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileDiff = typeof FileDiff.Type -// kilocode_change start - lightweight FileDiff without patch for session summaries -export const SummaryFileDiff = FileDiff.mapFields(Struct.omit(["patch"])) +// kilocode_change start - lightweight FileDiff without heavy content (patch/before/after) for session summaries +export const SummaryFileDiff = FileDiff.mapFields(Struct.omit(["patch", "before", "after"])) .annotate({ identifier: "SnapshotSummaryFileDiff" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SummaryFileDiff = typeof SummaryFileDiff.Type diff --git a/packages/opencode/test/kilocode/summary-file-diff.test.ts b/packages/opencode/test/kilocode/summary-file-diff.test.ts index c481716553..84e055664b 100644 --- a/packages/opencode/test/kilocode/summary-file-diff.test.ts +++ b/packages/opencode/test/kilocode/summary-file-diff.test.ts @@ -20,9 +20,9 @@ test("SummaryFileDiff parse strips `patch` when present on input", () => { expect(parsed).toEqual({ file: "a.txt", additions: 1, deletions: 1, status: "modified" }) }) -test("SummaryFileDiff differs from FileDiff by exactly `patch`", () => { +test("SummaryFileDiff drops the heavy content fields from FileDiff", () => { const full = new Set(Object.keys(Snapshot.FileDiff.fields)) const summary = new Set(Object.keys(Snapshot.SummaryFileDiff.fields)) - expect([...full].filter((k) => !summary.has(k))).toEqual(["patch"]) + expect([...full].filter((k) => !summary.has(k)).sort()).toEqual(["after", "before", "patch"]) expect([...summary].filter((k) => !full.has(k))).toEqual([]) }) From e83b25e8d93ee9c236514e4562f828b2e5f858e4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 09:52:59 -0400 Subject: [PATCH 35/67] fix(cli): stop eager file watchers on JetBrains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JetBrains backend eagerly warmed the v2 location stack for every instance, which starts a native @parcel/watcher subscription that lives for the whole session. On macOS FSEvents watches the entire subtree recursively (the ignore list is only a userspace filter), so this always-on watcher burns CPU and leaks native memory while the IDE is idle — the cause of the 150%+ CPU and multi-GB RSS growth reported in `kilo serve` on macOS. The watcher's only consumer is the CLI/TUI sidebar branch label via the vcs.branch.updated event. JetBrains, like VS Code, has its own git integration and does not consume that event, so eager watchers are pure overhead for it. Extend the existing VS Code gate to also exclude jetbrains; the standalone CLI/TUI stays eager, and editor clients still build the stack lazily if a real file/pty route needs it. Fixes #12721 --- .changeset/quiet-jetbrains-watchers.md | 5 +++++ packages/opencode/src/kilocode/watcher.ts | 11 ++++++++++- .../test/kilocode/instance-vcs-watcher.test.ts | 4 ++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-jetbrains-watchers.md diff --git a/.changeset/quiet-jetbrains-watchers.md b/.changeset/quiet-jetbrains-watchers.md new file mode 100644 index 0000000000..3e07e6c698 --- /dev/null +++ b/.changeset/quiet-jetbrains-watchers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix high CPU and runaway memory growth in the JetBrains background `kilo serve` process on macOS by no longer eagerly starting native file watchers, matching the VS Code backend. diff --git a/packages/opencode/src/kilocode/watcher.ts b/packages/opencode/src/kilocode/watcher.ts index 6553d3e105..fd88764e24 100644 --- a/packages/opencode/src/kilocode/watcher.ts +++ b/packages/opencode/src/kilocode/watcher.ts @@ -15,8 +15,17 @@ export namespace KilocodeWatcher { export class Service extends Context.Service()("@kilocode/Watcher") {} + // Embedded editor clients (VS Code, JetBrains) have their own file watching + // and git integration and do not consume the CLI's vcs.branch.updated event, + // so they must not eagerly warm the location stack — that starts a native + // @parcel/watcher subscription per instance that lives for the whole session. + // On macOS FSEvents watches the entire subtree recursively (the ignore list + // is only a userspace filter), so an always-on, consumer-less watcher on a + // churny workspace burns CPU and leaks native memory while idle. The + // standalone CLI/TUI stays eager because its sidebar branch label is the only + // consumer and no request-driven route would otherwise build the stack. export function eager(client = Flag.KILO_CLIENT) { - return client !== "vscode" + return client !== "vscode" && client !== "jetbrains" } export const layer = Layer.effect( diff --git a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts index e3861069e9..cf8f7b5386 100644 --- a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts +++ b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts @@ -34,6 +34,10 @@ describe("KilocodeWatcher.eager", () => { expect(KilocodeWatcher.eager("vscode")).toBe(false) }) + test("skips eager location watchers for JetBrains", () => { + expect(KilocodeWatcher.eager("jetbrains")).toBe(false) + }) + test("keeps eager location watchers for the standalone CLI", () => { expect(KilocodeWatcher.eager("cli")).toBe(true) expect(KilocodeWatcher.eager(undefined)).toBe(true) From c5cc20ee16681c7241b5304ab568c434b8894bf2 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Wed, 5 Aug 2026 15:57:05 +0200 Subject: [PATCH 36/67] docs(kilo-docs): document upstream v1.17.13 behavior --- .changeset/opencode-v1-17-9-to-v1-17-13.md | 10 +++------- .../kilo-docs/pages/automate/mcp/using-in-cli.md | 2 ++ .../pages/automate/mcp/using-in-kilo-code.md | 6 ++++++ .../kilo-docs/pages/code-with-ai/platforms/cli.md | 15 +++++++++++++++ packages/kilo-docs/pages/customize/skills.md | 10 ++++++++-- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.changeset/opencode-v1-17-9-to-v1-17-13.md b/.changeset/opencode-v1-17-9-to-v1-17-13.md index df1deffa15..529fce727e 100644 --- a/.changeset/opencode-v1-17-9-to-v1-17-13.md +++ b/.changeset/opencode-v1-17-9-to-v1-17-13.md @@ -5,11 +5,7 @@ Changes from opencode v1.17.9 to v1.17.13 upstream: -- Core Improvements: Sessions gain a snapshot and revert system for staging, clearing and committing file reverts. -- Core Improvements: Durable session history is served in finite pages and exposed through the SDK. - Core Improvements: MCP servers can append their instructions to the model context, and MCP resources are available as tools with template listing. -- Core Improvements: MCP tools use the `mcp__server__tool` naming convention, with legacy names still accepted. -- Core Improvements: Plugins can use the v2 effect host and a namespaced hook API. - Core Improvements: Model variants are generated from models.dev data, including modes exposed as models. - Core Improvements: Tool definitions pass `strict` through for Codex parity, and Gemini requests support video and audio media. - Core Bugfixes: Interrupted assistant steps settle instead of leaving sessions stuck busy. @@ -18,8 +14,8 @@ Changes from opencode v1.17.9 to v1.17.13 upstream: - Core Bugfixes: Stale GitHub Copilot Responses item IDs are no longer replayed, and OpenAI reasoning variants are forced where required. - Core Bugfixes: Adaptive thinking is enabled for Claude Sonnet 5, and expired promos were removed from the zen catalog. - Core Bugfixes: Preserve released prompt history during database replay and keep native event streams connected for all supported Kilo events. -- Core Bugfixes: Remote skills refresh atomically with version pinning, and skill base directories are emitted as filesystem paths. -- CLI Improvements: `kilo run --mini` provides a compact interactive mode, and ports increment from the default when busy. +- Core Bugfixes: Remote skill manifests support optional per-skill versions; changing a version refreshes the cached skill atomically, and skill base directories are emitted as filesystem paths. +- CLI Improvements: Ports increment from the default when busy. - CLI Improvements: Use `--auto` to start the TUI in a run-scoped auto-approve mode, and leave the mode mid-session from the command palette. -- TUI Improvements: Redesigned crash screen, model picker sorted by release date, a diff viewer keybind, main-branch diff source, bindable move-session command, and inline skill load errors. +- TUI Improvements: Redesigned crash screen, model picker sorted by release date, bindable diff viewer and Move Session commands, main-branch diff source, and inline skill load errors. - TUI Bugfixes: File autocomplete is scoped to the session, multi-day durations format correctly, and root sessions load in the session switcher. diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index 865cfa1ac2..a07800555a 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -165,6 +165,8 @@ MCP tools use the same permission system as built-in tools (`allow`, `ask`, `den For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#auto-approve-tools). +Connected servers can also add usage instructions to the model context and expose resources, including parameterized resource templates. See [Server instructions and resources](/docs/automate/mcp/using-in-kilo-code#server-instructions-and-resources). + ## Environment Variables Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 304457a8dd..ca711b30aa 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -513,6 +513,12 @@ After configuring an MCP server, Kilo Code will automatically detect available t Example: "Analyze the performance of my API" might use an MCP tool that tests API endpoints. +### Server instructions and resources + +When a connected MCP server provides instructions, Kilo adds them to the model context so the agent can follow the server's usage guidance. Kilo omits those instructions when every tool from that server is denied. + +Resource-capable servers also make the `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` tools available to the agent. Resource templates describe parameterized URIs; the agent fills in a template, then reads the resulting resource URI. Resource listing and reads use Kilo's normal read approval flow. + ## Troubleshooting MCP Servers {% tabs %} diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index a8b1d5e9b6..9f099096e9 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -97,6 +97,8 @@ The `kilo console` command and its browser interface are deprecated and will be | `/copy` | - | Copy latest agent response | | `/copy-session` | - | Copy session transcript | | `/export` | - | Export session transcript | +| `/move` | - | Move the current session to another project directory | +| `/diff` | - | Open the diff viewer | | `/timestamps` | `/toggle-timestamps` | Show/hide timestamps | | `/thinking` | `/toggle-thinking` | Show/hide thinking blocks | @@ -219,6 +221,19 @@ There is no notification slash command or command-palette toggle. Use `tui.json` The CLI's interactive mode supports slash commands for common operations. The main commands are documented above in the [Interactive Slash Commands](#interactive-slash-commands) section. +Use `/diff` to review working-tree changes. From the diff viewer, switch the source to the current branch compared with the main branch or to changes from the last assistant turn. Use `/move` to move the current session to another project directory. + +The `diff_open` and `session_move` TUI keybindings run the same actions and are unbound by default. Set them under `keybinds` in `tui.jsonc`: + +```jsonc +{ + "keybinds": { + "diff_open": "d", + "session_move": "o", + }, +} +``` + ## Permissions Kilo Code uses the permission config to decide whether a given action should run automatically, prompt you, or be blocked. diff --git a/packages/kilo-docs/pages/customize/skills.md b/packages/kilo-docs/pages/customize/skills.md index 9f6520b370..415e152215 100644 --- a/packages/kilo-docs/pages/customize/skills.md +++ b/packages/kilo-docs/pages/customize/skills.md @@ -105,17 +105,20 @@ The remote server must serve an `index.json` file at the URL path with the follo ```json { "skills": [ - { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + { "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] } ] } ``` Each skill object contains: - `name`: The skill name (must match the directory name) +- `version`: Optional version string for refreshing cached skill files - `files`: Array of files to fetch for this skill (must include `SKILL.md`) Files are downloaded from `{url}/{skill-name}/{file}` paths. +When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version. + {% /tab %} {% tab label="CLI" %} @@ -174,17 +177,20 @@ The remote server must serve an `index.json` file at the URL path with the follo ```json { "skills": [ - { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + { "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] } ] } ``` Each skill object contains: - `name`: The skill name (must match the directory name) +- `version`: Optional version string for refreshing cached skill files - `files`: Array of files to fetch for this skill (must include `SKILL.md`) Files are downloaded from `{url}/{skill-name}/{file}` paths. +When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version. + {% /tab %} {% /tabs %} From d3448bfdc1af637f2f17ec0ef828c35f1d1e7fae Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Wed, 5 Aug 2026 10:44:08 -0400 Subject: [PATCH 37/67] Revert "feat(jetbrains): show filenames first in @file mentions" --- .changeset/jetbrains-file-suggestions.md | 5 ----- .../session/ui/prompt/KiloPromptCompletionProvider.kt | 2 +- .../ui/prompt/KiloPromptCompletionProviderTest.kt | 10 ---------- 3 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 .changeset/jetbrains-file-suggestions.md diff --git a/.changeset/jetbrains-file-suggestions.md b/.changeset/jetbrains-file-suggestions.md deleted file mode 100644 index 9ac86fcbf9..0000000000 --- a/.changeset/jetbrains-file-suggestions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show file names before their containing folders in JetBrains `@file` suggestions. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index d028a83028..a2b6331bbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -252,7 +252,7 @@ class KiloPromptCompletionProvider( PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100) private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path) - .withPresentableText("@${file.name}") + .withPresentableText("@${file.path}") .withTailText(parent(file.path), true) .withIcon(icon(file)) .withLookupString(file.name) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index 16b65019b9..76077daeab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -211,16 +211,6 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { assertSame(AllIcons.Nodes.Folder, icon("src")) } - fun `test mention completion renders filename before parent path`() { - rpc.searchResult = FileSearchResultDto(files = listOf(file("src/foo/Bar.kt"))) - - complete("@bar") - - val view = LookupElementPresentation().also { item("src/foo/Bar.kt").renderElement(it) } - assertEquals("@Bar.kt", view.itemText) - assertEquals(" src/foo", view.tailText) - } - fun `test highlights known slash command at start`() { assertEquals( listOf(KiloPromptCompletionProvider.Highlight(0, 4, KiloPromptCompletionProvider.HighlightKind.COMMAND)), From ca421f8e19138bd7152b9390909208e5a01ad61d Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 10:58:13 -0400 Subject: [PATCH 38/67] chore(jetbrains): translate bundled Core version label --- .../src/main/resources/messages/KiloBundle_ar.properties | 1 + .../src/main/resources/messages/KiloBundle_bs.properties | 1 + .../src/main/resources/messages/KiloBundle_da.properties | 1 + .../src/main/resources/messages/KiloBundle_de.properties | 1 + .../src/main/resources/messages/KiloBundle_es.properties | 1 + .../src/main/resources/messages/KiloBundle_fr.properties | 1 + .../src/main/resources/messages/KiloBundle_ja.properties | 1 + .../src/main/resources/messages/KiloBundle_ko.properties | 1 + .../src/main/resources/messages/KiloBundle_nl.properties | 1 + .../src/main/resources/messages/KiloBundle_no.properties | 1 + .../src/main/resources/messages/KiloBundle_pl.properties | 1 + .../src/main/resources/messages/KiloBundle_pt_BR.properties | 1 + .../src/main/resources/messages/KiloBundle_ru.properties | 1 + .../src/main/resources/messages/KiloBundle_th.properties | 1 + .../src/main/resources/messages/KiloBundle_tr.properties | 1 + .../src/main/resources/messages/KiloBundle_uk.properties | 1 + .../src/main/resources/messages/KiloBundle_zh_CN.properties | 1 + .../src/main/resources/messages/KiloBundle_zh_TW.properties | 1 + 18 files changed, 18 insertions(+) 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 15944a645d..a3ad22de39 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=إنهاء عملية Core وإعادة تشغي action.Kilo.Reinstall.text=إعادة تثبيت Kilo action.Kilo.Reinstall.cli.text=إعادة تثبيت Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=‏Core المُضمّن v{0} • البنية: {1} action.Kilo.Session.Open.text=فتح action.Kilo.Session.Open.description=فتح الجلسة المحددة action.Kilo.Session.Rename.text=إعادة تسمية 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 df5882b500..f865d87759 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Ugasite i ponovo pokrenite Core proces action.Kilo.Reinstall.text=Ponovo instalirajte Kilo action.Kilo.Reinstall.cli.text=Ponovo instalirajte Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Ugrađeni Core v{0} • Arhitektura: {1} action.Kilo.Session.Open.text=Otvori action.Kilo.Session.Open.description=Otvorite odabranu sesiju action.Kilo.Session.Rename.text=Preimenuj 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 65d9d0909c..aa558608cf 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Afslut og genstart Core-processen action.Kilo.Reinstall.text=Geninstaller Kilo action.Kilo.Reinstall.cli.text=Geninstaller Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Medfølgende Core v{0} • Arkitektur: {1} action.Kilo.Session.Open.text=Åbn action.Kilo.Session.Open.description=Åbn den valgte session action.Kilo.Session.Rename.text=Omdøb 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 9b0ca26632..0aaaa9b106 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core-Prozess beenden und neu starten action.Kilo.Reinstall.text=Kilo neu installieren action.Kilo.Reinstall.cli.text=Core neu installieren action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Gebündelter Core v{0} • Architektur: {1} action.Kilo.Session.Open.text=Öffnen action.Kilo.Session.Open.description=Ausgewählte Sitzung öffnen action.Kilo.Session.Rename.text=Umbenennen 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 0d19e12cfd..9ad869fc59 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Terminar y reiniciar el proceso Core action.Kilo.Reinstall.text=Reinstalar Kilo action.Kilo.Reinstall.cli.text=Reinstalar Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core incluido v{0} • Arquitectura: {1} action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir la sesión seleccionada action.Kilo.Session.Rename.text=Renombrar 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 683bf55d43..dfe1497882 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Tuer et redémarrer le processus Core action.Kilo.Reinstall.text=Réinstaller Kilo action.Kilo.Reinstall.cli.text=Réinstaller Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core intégré v{0} • Architecture : {1} action.Kilo.Session.Open.text=Ouvrir action.Kilo.Session.Open.description=Ouvrir la session sélectionnée action.Kilo.Session.Rename.text=Renommer 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 e9e1c52f3c..1c8e84104d 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Coreプロセスを終了して再起動 action.Kilo.Reinstall.text=Kiloを再インストール action.Kilo.Reinstall.cli.text=Coreを再インストール action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=バンドルされた Core v{0} • アーキテクチャ: {1} action.Kilo.Session.Open.text=開く action.Kilo.Session.Open.description=選択したセッションを開く action.Kilo.Session.Rename.text=変名 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 c2e935a4ed..5594fdf319 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core 프로세스 종료 후 재시작 action.Kilo.Reinstall.text=Kilo 재설치 action.Kilo.Reinstall.cli.text=Core 재설치 action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=번들 Core v{0} • 아키텍처: {1} action.Kilo.Session.Open.text=열기 action.Kilo.Session.Open.description=선택한 세션 열기 action.Kilo.Session.Rename.text=이름 바꾸기 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 e43ba741bd..d96e724108 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core-proces beëindigen en herstarten action.Kilo.Reinstall.text=Kilo herinstalleren action.Kilo.Reinstall.cli.text=Core herinstalleren action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Gebundelde Core v{0} • Architectuur: {1} action.Kilo.Session.Open.text=Openen action.Kilo.Session.Open.description=Geselecteerde sessie openen action.Kilo.Session.Rename.text=Hernoemen 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 d391bcc32a..57502a00be 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 @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Avslutt og start Core-prosessen på nytt action.Kilo.Reinstall.text=Installer Kilo på nytt action.Kilo.Reinstall.cli.text=Installer Core på nytt action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Innebygd Core v{0} • Arkitektur: {1} action.Kilo.Session.Open.text=Åpne action.Kilo.Session.Open.description=Åpne valgt økt action.Kilo.Session.Rename.text=Gi nytt navn 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 ef4c5efe13..4b9bf11f46 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 @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Zakończ i uruchom ponownie proces Core action.Kilo.Reinstall.text=Zainstaluj ponownie Kilo action.Kilo.Reinstall.cli.text=Zainstaluj ponownie Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Wbudowany Core v{0} • Architektura: {1} action.Kilo.Session.Open.text=Otwórz action.Kilo.Session.Open.description=Otwórz wybraną sesję action.Kilo.Session.Rename.text=Zmień nazwę 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 2bf9c4c4cc..08de02c275 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 @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Encerrar e reiniciar o processo Core action.Kilo.Reinstall.text=Reinstalar Kilo action.Kilo.Reinstall.cli.text=Reinstalar Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core incluído v{0} • Arquitetura: {1} action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir a sessão selecionada action.Kilo.Session.Rename.text=Renomear 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 aef654244b..90a13ed0ed 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 @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Завершить и перезапустить action.Kilo.Reinstall.text=Переустановить Kilo action.Kilo.Reinstall.cli.text=Переустановить Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Встроенный Core v{0} • Архитектура: {1} action.Kilo.Session.Open.text=Открыть action.Kilo.Session.Open.description=Открыть выбранную сессию action.Kilo.Session.Rename.text=Переименовать 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 8959598aa7..ee10c59c9a 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 @@ -160,6 +160,7 @@ action.Kilo.Restart.description=สิ้นสุดและรีสตาร action.Kilo.Reinstall.text=ติดตั้ง Kilo ใหม่ action.Kilo.Reinstall.cli.text=ติดตั้ง Core ใหม่ action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core แบบรวมมาในตัว v{0} • สถาปัตยกรรม: {1} action.Kilo.Session.Open.text=เปิด action.Kilo.Session.Open.description=เปิดเซสชันที่เลือก action.Kilo.Session.Rename.text=เปลี่ยนชื่อ 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 b954188a5c..15c992fac3 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 @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Core sürecini sonlandır ve yeniden başlat action.Kilo.Reinstall.text=Kilo’yu yeniden yükle action.Kilo.Reinstall.cli.text=Core’yi yeniden yükle action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Paketlenmiş Core v{0} • Mimari: {1} action.Kilo.Session.Open.text=Aç action.Kilo.Session.Open.description=Seçilen oturumu aç action.Kilo.Session.Rename.text=Yeniden adlandır 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 77ea64c775..b69ddc8861 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Завершити і перезапустити action.Kilo.Reinstall.text=Перевстановити Kilo action.Kilo.Reinstall.cli.text=Перевстановити Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Вбудований Core v{0} • Архітектура: {1} action.Kilo.Session.Open.text=Відкрити action.Kilo.Session.Open.description=Відкрити вибрану сесію action.Kilo.Session.Rename.text=Перейменувати 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 909073cae3..f16bb2266b 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=终止并重启 Core 进程 action.Kilo.Reinstall.text=重新安装 Kilo action.Kilo.Reinstall.cli.text=重新安装 Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=捆绑的 Core v{0} • 架构:{1} action.Kilo.Session.Open.text=打开 action.Kilo.Session.Open.description=打开所选会话 action.Kilo.Session.Rename.text=重命名 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 bc74d81c93..6eec893d1a 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 @@ -155,6 +155,7 @@ action.Kilo.Restart.description=終止並重啟 Core 程序 action.Kilo.Reinstall.text=重新安裝 Kilo action.Kilo.Reinstall.cli.text=重新安裝 Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=捆綁的 Core v{0} • 架構:{1} action.Kilo.Session.Open.text=開啟 action.Kilo.Session.Open.description=開啟所選工作階段 action.Kilo.Session.Rename.text=重命名 From f035c871ffc6c844a4b1400829dd98248fada4df Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 11:29:44 -0400 Subject: [PATCH 39/67] docs(jetbrains): fix Bun Path Hint wording and use Bun.which for bun lookup Addresses PR review: repairs the broken sentence in the SKILL.md Bun Path Hint section and replaces the dead 'command -v bun' lookup (not a Bun $ shell builtin) with native Bun.which. --- .kilo/skills/jetbrains-cli-pin/SKILL.md | 2 +- .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.kilo/skills/jetbrains-cli-pin/SKILL.md b/.kilo/skills/jetbrains-cli-pin/SKILL.md index 2fc5b06ef5..f04217edec 100644 --- a/.kilo/skills/jetbrains-cli-pin/SKILL.md +++ b/.kilo/skills/jetbrains-cli-pin/SKILL.md @@ -64,7 +64,7 @@ the only reliable reset. In repo CLI mode, Gradle's `generateOpenApiSpec` task runs the local CLI source through `bun run --conditions=browser ./src/index.ts generate`. IDE-launched Gradle runs can have -worktree-local hint: +a stripped `PATH` where `bun` isn't resolvable. The `unpin`/`regen` commands write an ignored, worktree-local hint: ```text packages/kilo-jetbrains/.gradle/kilo-cli-pin.properties diff --git a/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts index a7223d3cba..a57676806d 100644 --- a/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts +++ b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts @@ -53,14 +53,12 @@ async function setPinned(value: boolean) { await Bun.write(props, text.replace(/^kilo\.cli\.pinned=.*$/m, `kilo.cli.pinned=${value}`)) } -async function bunPath() { - const result = await $`command -v bun`.quiet().nothrow() - const bin = result.exitCode === 0 ? result.stdout.toString().trim() : "" - return bin || process.execPath +function bunPath() { + return Bun.which("bun") ?? process.execPath } async function writeBunHint() { - const path = await bunPath() + const path = bunPath() await $`mkdir -p ${jb}/.gradle` await Bun.write(hint, `# Generated by jetbrains-cli-pin so IDE-launched Gradle can find Bun in repo CLI mode.\nbun.path=${path}\n`) console.log(`Wrote Bun path hint for repo CLI mode: ${path}`) From 5e60473e768325ce4109ef1c07106e392b49427f Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 13:42:32 -0400 Subject: [PATCH 40/67] fix(jetbrains): improve slash command matching --- .changeset/jetbrains-slash-completion.md | 5 ++ .../ui/prompt/KiloPromptCompletionProvider.kt | 58 ++++++++++++-- .../KiloPromptCompletionProviderTest.kt | 76 +++++++++++++++++++ 3 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 .changeset/jetbrains-slash-completion.md diff --git a/.changeset/jetbrains-slash-completion.md b/.changeset/jetbrains-slash-completion.md new file mode 100644 index 0000000000..e2ec3c4996 --- /dev/null +++ b/.changeset/jetbrains-slash-completion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve slash command completion to match separators, camel-case humps, and contained command names. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index a2b6331bbb..f7f5f3617d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -20,7 +20,10 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.progress.runBlockingCancellable +import com.intellij.psi.codeStyle.MinusculeMatcher +import com.intellij.psi.codeStyle.NameUtil import com.intellij.util.textCompletion.TextCompletionProvider +import com.intellij.util.text.matching.MatchingMode import java.util.Collections import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -163,12 +166,18 @@ class KiloPromptCompletionProvider( private fun slash(prefix: String, result: CompletionResultSet) { result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) + val rank = Ranker(prefix) val names = clientTokens() - val clients = actions.filter { action -> matches(prefix, action.name, action.hints) } - clients.forEach { action -> out.addElement(client(action)) } + val clients = actions.mapNotNull { action -> + rank.score(action.name, action.hints)?.let { Hit(client(action), it) } + } val commands = workspace.state.value.commands - .filter { it.name !in names && matches(prefix, it.name, it.hints) } - commands.forEach { command -> out.addElement(server(command)) } + .mapNotNull { command -> + if (command.name in names) return@mapNotNull null + rank.score(command.name, command.hints)?.let { Hit(server(command), it) } + } + val hits = (clients + commands).sortedByDescending { it.score } + hits.forEach { hit -> out.addElement(weight(hit.item, hit.score)) } if (clients.isNotEmpty() || commands.isNotEmpty()) return result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) .addElement(info(prefix, KiloBundle.message("prompt.completion.noMatches"))) @@ -178,7 +187,8 @@ class KiloPromptCompletionProvider( result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) val search = search(prefix) - val known = mentions.filter { action -> matches(prefix, action.name, action.hints) && action.available(search) } + val rank = Ranker(prefix) + val known = mentions.filter { action -> rank.matches(action.name, action.hints) && action.available(search) } known.forEach { action -> out.addElement(prioritize(resource(action))) } if (search.indexing) { val msg = KiloBundle.message("prompt.mention.indexing") @@ -214,8 +224,37 @@ class KiloPromptCompletionProvider( } .withAutoCompletionPolicy(AutoCompletionPolicy.NEVER_AUTOCOMPLETE) - private fun matches(prefix: String, name: String, hints: List): Boolean = - (listOf(name) + hints).any { it.startsWith(prefix, ignoreCase = true) } + private class Ranker(prefix: String) { + private val start = matcher(prefix) + private val middle = if (prefix.any { separator(it) }) null else matcher("*$prefix") + + fun matches(name: String, hints: List): Boolean = score(name, hints) != null + + fun score(name: String, hints: List): Int? = (listOf(name) + hints).maxOfOrNull { value -> + score(value) ?: Int.MIN_VALUE + }?.takeIf { it != Int.MIN_VALUE } + + private fun score(value: String): Int? { + val exact = start.match(value) + if (exact != null) return START + start.matchingDegree(value, true, exact) + val fallback = middle ?: return null + val fuzzy = fallback.match(value) ?: return null + return fallback.matchingDegree(value, false, fuzzy) + } + + private companion object { + const val START = 10_000 + + fun matcher(prefix: String): MinusculeMatcher = NameUtil.buildMatcher(prefix) + .withMatchingMode(MatchingMode.IGNORE_CASE) + .build() + + fun separator(c: Char): Boolean = when (c) { + '_', '-', ':', '+', '.' -> true + else -> c.isWhitespace() + } + } + } private fun commandName(text: String): String? { val raw = text.trimStart() @@ -251,6 +290,11 @@ class KiloPromptCompletionProvider( private fun prioritize(element: LookupElement): LookupElement = PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100) + private fun weight(element: LookupElement, score: Int): LookupElement = + PrioritizedLookupElement.withPriority(element, score.toDouble()) + + private data class Hit(val item: LookupElement, val score: Int) + private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path) .withPresentableText("@${file.path}") .withTailText(parent(file.path), true) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index 76077daeab..b61398ca28 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -174,6 +174,69 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) } + fun `test slash completion ranks prefix matches before contains matches`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf( + CommandDto("three-jetbrains"), + CommandDto("jetbrains-one"), + CommandDto("jetbrains-two"), + ), + ) + waitFor { provider.serverCommand("/jetbrains-one") != null } + + complete("/jet") + + assertOrder("jetbrains-one", "jetbrains-two", "three-jetbrains") + } + + fun `test slash completion matches prefix across separators`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf( + CommandDto("three-jetbrains"), + CommandDto("jetbrains-one"), + CommandDto("jetbrains-two"), + CommandDto("jetbrains_two"), + CommandDto("jetbrains.three"), + CommandDto("jetbrains:four"), + CommandDto("jetbrains+five"), + ), + ) + waitFor { provider.serverCommand("/jetbrains-one") != null } + + complete("/j-") + assertEquals( + listOf("jetbrains-one", "jetbrains-two"), + matches("jetbrains-one", "jetbrains-two", "jetbrains_two", "three-jetbrains"), + ) + + complete("/j_") + assertEquals(listOf("jetbrains_two"), matches("jetbrains-one", "jetbrains_two", "three-jetbrains")) + + complete("/j.") + assertEquals(listOf("jetbrains.three"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + + complete("/j:") + assertEquals(listOf("jetbrains:four"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + + complete("/j+") + assertEquals(listOf("jetbrains+five"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + } + + fun `test slash completion matches camel humps with capitals`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf(CommandDto("jetBrainsSkill"), CommandDto("jetbrains-skill")), + ) + waitFor { provider.serverCommand("/jetBrainsSkill") != null } + + complete("/JBS") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "jetBrainsSkill") + assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) + } + fun `test blank mention completion includes special and root entries`() { rpc.searchResult = FileSearchResultDto( files = listOf(file("src", directory = true), file("README.md")), @@ -380,6 +443,19 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { private fun item(value: String) = myFixture.lookupElements.orEmpty().first { it.lookupString == value } + private fun assertOrder(vararg values: String) { + val items = myFixture.lookupElementStrings.orEmpty() + assertContainsElements(items, *values) + values.toList().zipWithNext().forEach { (left, right) -> + assertTrue("Expected $left before $right in $items", items.indexOf(left) < items.indexOf(right)) + } + } + + private fun matches(vararg values: String): List { + val items = myFixture.lookupElementStrings.orEmpty() + return values.filter { it in items } + } + private fun file(path: String, directory: Boolean = false) = WorkspaceFileDto( path = path, name = path.substringAfterLast('/'), From cbbbd7217f940b59b1b29964264536c567065327 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 6 Aug 2026 00:12:23 +0200 Subject: [PATCH 41/67] fix: address upstream merge review findings --- .changeset/opencode-v1-18-0.md | 6 + .github/workflows/test.yml | 4 + package.json | 1 - packages/opencode/src/provider/transform.ts | 17 + packages/opencode/src/session/prompt/meta.txt | 8 +- .../provider/kimi-adaptive-effort.test.ts | 40 ++ .../test/kilocode/session/meta-prompt.test.ts | 11 + packages/ui/src/i18n/ar.ts | 2 +- packages/ui/src/i18n/br.ts | 2 +- packages/ui/src/i18n/bs.ts | 2 +- packages/ui/src/i18n/da.ts | 2 +- packages/ui/src/i18n/de.ts | 2 +- packages/ui/src/i18n/en.ts | 2 +- packages/ui/src/i18n/es.ts | 2 +- packages/ui/src/i18n/fr.ts | 2 +- packages/ui/src/i18n/it.ts | 2 +- packages/ui/src/i18n/ja.ts | 2 +- packages/ui/src/i18n/ko.ts | 2 +- packages/ui/src/i18n/nl.ts | 2 +- packages/ui/src/i18n/no.ts | 2 +- packages/ui/src/i18n/pl.ts | 2 +- packages/ui/src/i18n/ru.ts | 2 +- packages/ui/src/i18n/th.ts | 2 +- packages/ui/src/i18n/tr.ts | 2 +- packages/ui/src/i18n/zh.ts | 2 +- packages/ui/src/i18n/zht.ts | 2 +- script/check-test-ci.ts | 35 ++ script/translate-app.md | 21 - script/translate-app.test.ts | 168 ------ script/translate-app.ts | 523 ------------------ script/upstream/transforms/skip-files.test.ts | 11 + .../transforms/transform-i18n.test.ts | 12 + script/upstream/transforms/transform-i18n.ts | 3 +- .../transforms/transform-package-json.test.ts | 4 +- .../transforms/transform-package-json.ts | 2 +- script/upstream/utils/config.ts | 6 + 36 files changed, 171 insertions(+), 739 deletions(-) create mode 100644 .changeset/opencode-v1-18-0.md create mode 100644 packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts create mode 100644 packages/opencode/test/kilocode/session/meta-prompt.test.ts create mode 100644 script/check-test-ci.ts delete mode 100644 script/translate-app.md delete mode 100644 script/translate-app.test.ts delete mode 100644 script/translate-app.ts create mode 100644 script/upstream/transforms/transform-i18n.test.ts diff --git a/.changeset/opencode-v1-18-0.md b/.changeset/opencode-v1-18-0.md new file mode 100644 index 0000000000..14fb63e858 --- /dev/null +++ b/.changeset/opencode-v1-18-0.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Adopt OpenCode v1.18.0 improvements, including code mode, expanded model reasoning controls, MCP reliability updates, and TUI enhancements. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9df9c697de..78143b9f8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -142,6 +142,10 @@ jobs: # kilocode_change end # kilocode_change start - test non-CLI packages separately from sharded CLI tests + - name: Verify package test scheduling + if: matrix.settings.run && matrix.settings.packages && matrix.settings.os == 'linux' + run: bun run script/check-test-ci.ts + - name: Run non-CLI unit tests if: matrix.settings.run && matrix.settings.packages run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' diff --git a/package.json b/package.json index 3a99e16d17..1bba0e600b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "prepare": "husky", "random": "echo 'Random script'", "sso": "aws sso login --sso-session=opencode --no-browser", - "translate:app": "bun run script/translate-app.ts", "test": "echo 'do not run tests from root' && exit 1", "extension": "bun --cwd packages/kilo-vscode script/launch.ts", "extension:isolated": "bun --cwd packages/kilo-vscode script/launch.ts --isolated", diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 957047a058..2829d23f39 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1297,6 +1297,9 @@ function reasoningEffort(model: Provider.Model, effort: string) { function anthropicEffort(model: Provider.Model, effort: string) { if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort } + // kilocode_change start - Kimi Anthropic endpoints require adaptive thinking summaries for published effort tiers + if (isKimiFamily(model)) return { thinking: { type: "adaptive", display: "summarized" }, effort } + // kilocode_change end if (!anthropicAdaptiveEfforts(model.api.id)) return return { thinking: { @@ -1307,6 +1310,20 @@ function anthropicEffort(model: Provider.Model, effort: string) { } } +// kilocode_change start +function isKimiFamily(model: Provider.Model) { + if ( + [model.providerID, model.api.id].some((id) => { + const value = id.toLowerCase() + return value.includes("kimi") || value.includes("moonshot") + }) + ) + return true + const url = model.api.url.toLowerCase() + return ["api.kimi.com", "api.moonshot.ai", "api.moonshot.cn", "api.moonshotai.cn"].some((host) => url.includes(host)) +} +// kilocode_change end + function reasoningBudget(model: Provider.Model, budget: number) { switch (model.api.npm) { case "@openrouter/ai-sdk-provider": diff --git a/packages/opencode/src/session/prompt/meta.txt b/packages/opencode/src/session/prompt/meta.txt index 4d6e30f27c..bd0ea22f5c 100644 --- a/packages/opencode/src/session/prompt/meta.txt +++ b/packages/opencode/src/session/prompt/meta.txt @@ -1,20 +1,20 @@ -You are OpenCode, the best coding agent on the planet. +You are Kilo, the best coding agent on the planet. You are based on a large language model trained by Meta MSL named Muse Spark. -When asked who you are, identify yourself as OpenCode powered by Meta Muse Spark by name. +When asked who you are, identify yourself as Kilo powered by Meta Muse Spark by name. You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. If the user asks for help or wants to give feedback inform them of the following: - ctrl+p to list available actions - To give feedback, users should report the issue at https://github.com/Kilo-Org/kilocode -When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs +When the user directly asks about Kilo (eg. "can Kilo do...", "does Kilo have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific Kilo feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from Kilo docs. The list of available docs is available at https://kilo.ai/docs # Tone and style - Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. - Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. - Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. - NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. # Professional objectivity -Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. +Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Kilo honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. # Task Management You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. diff --git a/packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts b/packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts new file mode 100644 index 0000000000..3cdc32ab3f --- /dev/null +++ b/packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import type { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" + +const model = { + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], +} as unknown as ModelsDev.Model + +function target(input: { providerID: string; id: string; url: string }) { + return { + id: input.id, + providerID: input.providerID, + api: { id: input.id, npm: "@ai-sdk/anthropic", url: input.url }, + capabilities: { reasoning: true }, + limit: { output: 64_000 }, + } as unknown as Provider.Model +} + +describe("Kimi adaptive effort", () => { + test("uses adaptive summarized thinking for Kimi model IDs", () => { + const variants = ProviderTransform.reasoningVariants( + model, + target({ providerID: "moonshotai", id: "kimi-k3", url: "https://example.test/v1" }), + ) + expect(variants).toEqual({ + low: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + max: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" }, + }) + }) + + test("recognizes custom Kimi providers by Moonshot API host", () => { + const variants = ProviderTransform.reasoningVariants( + model, + target({ providerID: "custom", id: "custom-model", url: "https://api.moonshot.ai/anthropic" }), + ) + expect(variants?.high).toEqual({ thinking: { type: "adaptive", display: "summarized" }, effort: "high" }) + }) +}) diff --git a/packages/opencode/test/kilocode/session/meta-prompt.test.ts b/packages/opencode/test/kilocode/session/meta-prompt.test.ts new file mode 100644 index 0000000000..cf43fb5b69 --- /dev/null +++ b/packages/opencode/test/kilocode/session/meta-prompt.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test" +import type { Provider } from "@/provider/provider" +import { SystemPrompt } from "@/session/system" + +test("Muse Spark identifies as Kilo and uses Kilo documentation", () => { + const prompt = SystemPrompt.provider({ api: { id: "meta/muse-spark-preview" } } as Provider.Model)[0] + expect(prompt).toContain("Kilo powered by Meta Muse Spark") + expect(prompt).toContain("https://kilo.ai/docs") + expect(prompt).not.toContain("identify yourself as OpenCode") + expect(prompt).not.toContain("https://opencode.ai/docs") +}) diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 15f71db764..9ed46a0342 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -72,7 +72,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "تم الوصول إلى الحد المجاني", "dialog.usageExceeded.freeTier.description": - "اشترك في Kilo Go للحصول على وصول موثوق إلى أفضل النماذج مفتوحة المصدر، ابتداءً من $5/شهر.", + "اشترك في Kilo Go للحصول على وصول موثوق إلى أفضل النماذج مفتوحة المصدر، ابتداءً من $5/شهر.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "اشترك", "dialog.usageExceeded.accountRateLimit.title": "تم الوصول إلى حد Go", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 8409d76253..8fcf3b7a86 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -70,7 +70,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Limite gratuito atingido", "dialog.usageExceeded.freeTier.description": - "Assine o Kilo Go para ter acesso confiável aos melhores modelos open-source, a partir de $5/mês.", + "Assine o Kilo Go para ter acesso confiável aos melhores modelos open-source, a partir de $5/mês.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Assinar", "dialog.usageExceeded.accountRateLimit.title": "Limite do Go atingido", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 3adb99fcd0..1d2df6d1d5 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -74,7 +74,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Dostignut besplatan limit", "dialog.usageExceeded.freeTier.description": - "Pretplatite se na Kilo Go za pouzdan pristup najboljim open-source modelima, počevši od $5/mjesec.", + "Pretplatite se na Kilo Go za pouzdan pristup najboljim open-source modelima, počevši od $5/mjesec.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Pretplati se", "dialog.usageExceeded.accountRateLimit.title": "Dostignut Go limit", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index c7517cb21c..1e3906e193 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -70,7 +70,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Gratis grænse nået", "dialog.usageExceeded.freeTier.description": - "Abonnér på Kilo Go for pålidelig adgang til de bedste open source-modeller, fra $5/måned.", + "Abonnér på Kilo Go for pålidelig adgang til de bedste open source-modeller, fra $5/måned.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Abonnér", "dialog.usageExceeded.accountRateLimit.title": "Go-grænse nået", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 7e5f6f368b..05fc3607bf 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -78,7 +78,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Kostenloses Limit erreicht", "dialog.usageExceeded.freeTier.description": - "Abonniere Kilo Go für zuverlässigen Zugriff auf die besten Open-Source-Modelle, ab $5/Monat.", + "Abonniere Kilo Go für zuverlässigen Zugriff auf die besten Open-Source-Modelle, ab $5/Monat.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Abonnieren", "dialog.usageExceeded.accountRateLimit.title": "Go-Limit erreicht", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 29e3b09086..f3fa4474b4 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -74,7 +74,7 @@ export const dict: Record = { "dialog.usageExceeded.freeTier.title": "Free limit reached", "dialog.usageExceeded.freeTier.description": - "Subscribe to Kilo Go for reliable access to the best open-source models, starting at $5/month.", + "Subscribe to Kilo Go for reliable access to the best open-source models, starting at $5/month.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Subscribe", "dialog.usageExceeded.accountRateLimit.title": "Go limit reached", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 334f4e0068..73681a0d0e 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -70,7 +70,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Límite gratuito alcanzado", "dialog.usageExceeded.freeTier.description": - "Suscríbete a Kilo Go para acceso fiable a los mejores modelos de código abierto, desde $5/mes.", + "Suscríbete a Kilo Go para acceso fiable a los mejores modelos de código abierto, desde $5/mes.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Suscribirse", "dialog.usageExceeded.accountRateLimit.title": "Límite de Go alcanzado", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index bc42bbbdf7..0ac82bc336 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -70,7 +70,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Limite gratuite atteinte", "dialog.usageExceeded.freeTier.description": - "Abonnez-vous à Kilo Go pour un accès fiable aux meilleurs modèles open source, à partir de $5/mois.", + "Abonnez-vous à Kilo Go pour un accès fiable aux meilleurs modèles open source, à partir de $5/mois.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "S'abonner", "dialog.usageExceeded.accountRateLimit.title": "Limite Go atteinte", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/it.ts b/packages/ui/src/i18n/it.ts index c4fa753f1b..fc0947b972 100644 --- a/packages/ui/src/i18n/it.ts +++ b/packages/ui/src/i18n/it.ts @@ -75,7 +75,7 @@ export const dict: Record = { "dialog.usageExceeded.freeTier.title": "Limite gratuito raggiunto", "dialog.usageExceeded.freeTier.description": - "Abbonati a Kilo Go per un accesso affidabile ai migliori modelli open source, a partire da $5 al mese.", + "Abbonati a Kilo Go per un accesso affidabile ai migliori modelli open source, a partire da $5 al mese.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Abbonati", "dialog.usageExceeded.accountRateLimit.title": "Limite Go raggiunto", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 5834da32ea..335d5346b0 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -70,7 +70,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "無料制限に達しました", "dialog.usageExceeded.freeTier.description": - "Kilo Go にサブスクライブして、最高のオープンソースモデルに安定してアクセスできます。月額 $5 から。", + "Kilo Go にサブスクライブして、最高のオープンソースモデルに安定してアクセスできます。月額 $5 から。", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "サブスクライブ", "dialog.usageExceeded.accountRateLimit.title": "Go の制限に達しました", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index f89d66bc89..e47019e108 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -52,7 +52,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "무료 한도에 도달했습니다", "dialog.usageExceeded.freeTier.description": - "Kilo Go를 구독하여 최고의 오픈 소스 모델에 안정적으로 액세스하세요. 월 $5부터 시작합니다.", + "Kilo Go를 구독하여 최고의 오픈 소스 모델에 안정적으로 액세스하세요. 월 $5부터 시작합니다.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "구독", "dialog.usageExceeded.accountRateLimit.title": "Go 한도에 도달했습니다", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index 83fc0a7ea1..df421c92fb 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -76,7 +76,7 @@ export const dict: Record = { // kilocode_change start - complete upstream usage-exceeded translations "dialog.usageExceeded.freeTier.title": "Gratis limiet bereikt", "dialog.usageExceeded.freeTier.description": - "Abonneer je op Kilo Go voor betrouwbare toegang tot de beste open-sourcemodellen, vanaf $5 per maand.", + "Abonneer je op Kilo Go voor betrouwbare toegang tot de beste open-sourcemodellen, vanaf $5 per maand.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Abonneren", "dialog.usageExceeded.accountRateLimit.title": "Go-limiet bereikt", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 49d1e92010..1557560324 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -57,7 +57,7 @@ export const dict: Record = { "dialog.usageExceeded.freeTier.title": "Gratis grense nådd", "dialog.usageExceeded.freeTier.description": - "Abonner på Kilo Go for pålitelig tilgang til de beste åpen kildekode-modellene, fra $5/måned.", + "Abonner på Kilo Go for pålitelig tilgang til de beste åpen kildekode-modellene, fra $5/måned.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Abonner", "dialog.usageExceeded.accountRateLimit.title": "Go-grense nådd", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 15d9dd3bee..7fc2843cdb 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -71,7 +71,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Osiągnięto limit darmowy", "dialog.usageExceeded.freeTier.description": - "Subskrybuj Kilo Go, aby uzyskać niezawodny dostęp do najlepszych modeli open source, od $5/miesiąc.", + "Subskrybuj Kilo Go, aby uzyskać niezawodny dostęp do najlepszych modeli open source, od $5/miesiąc.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Subskrybuj", "dialog.usageExceeded.accountRateLimit.title": "Osiągnięto limit Go", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index febb1f42e2..a94b371a18 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -71,7 +71,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Достигнут бесплатный лимит", "dialog.usageExceeded.freeTier.description": - "Подпишитесь на Kilo Go для надёжного доступа к лучшим моделям с открытым исходным кодом, от $5/месяц.", + "Подпишитесь на Kilo Go для надёжного доступа к лучшим моделям с открытым исходным кодом, от $5/месяц.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Подписаться", "dialog.usageExceeded.accountRateLimit.title": "Достигнут лимит Go", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 3efe0417f6..50e36502d4 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -71,7 +71,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "ถึงขีดจำกัดฟรีแล้ว", "dialog.usageExceeded.freeTier.description": - "สมัครสมาชิก Kilo Go เพื่อการเข้าถึงโมเดลโอเพนซอร์สที่ดีที่สุดอย่างเชื่อถือได้ เริ่มต้นที่ $5/เดือน", + "สมัครสมาชิก Kilo Go เพื่อการเข้าถึงโมเดลโอเพนซอร์สที่ดีที่สุดอย่างเชื่อถือได้ เริ่มต้นที่ $5/เดือน", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "สมัครสมาชิก", "dialog.usageExceeded.accountRateLimit.title": "ถึงขีดจำกัดของ Go แล้ว", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 010e0f5adf..42a36309e5 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -79,7 +79,7 @@ export const dict = { "dialog.usageExceeded.freeTier.title": "Ücretsiz sınıra ulaşıldı", "dialog.usageExceeded.freeTier.description": - "En iyi açık kaynak modellere güvenilir erişim için Kilo Go'ya abone olun. Aylık $5'tan başlar.", + "En iyi açık kaynak modellere güvenilir erişim için Kilo Go'ya abone olun. Aylık $5'tan başlar.", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "Abone ol", "dialog.usageExceeded.accountRateLimit.title": "Go sınırına ulaşıldı", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index ac9a315b7d..5492c5d520 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -75,7 +75,7 @@ export const dict = { "ui.sessionTurn.error.addCredits": "添加积分", "dialog.usageExceeded.freeTier.title": "免费额度已用完", - "dialog.usageExceeded.freeTier.description": "订阅 Kilo Go,可靠地使用最佳开源模型,每月 $5 起。", + "dialog.usageExceeded.freeTier.description": "订阅 Kilo Go,可靠地使用最佳开源模型,每月 $5 起。", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "订阅", "dialog.usageExceeded.accountRateLimit.title": "Go 额度已用完", "dialog.usageExceeded.accountRateLimit.description": diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 98167e648f..366da4998b 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -75,7 +75,7 @@ export const dict = { "ui.sessionTurn.error.addCredits": "新增點數", "dialog.usageExceeded.freeTier.title": "已達免費額度上限", - "dialog.usageExceeded.freeTier.description": "訂閱 Kilo Go,可靠地使用最佳開源模型,每月 $5 起。", + "dialog.usageExceeded.freeTier.description": "訂閱 Kilo Go,可靠地使用最佳開源模型,每月 $5 起。", // kilocode_change "dialog.usageExceeded.freeTier.actionLabel": "訂閱", "dialog.usageExceeded.accountRateLimit.title": "已達 Go 額度上限", "dialog.usageExceeded.accountRateLimit.description": diff --git a/script/check-test-ci.ts b/script/check-test-ci.ts new file mode 100644 index 0000000000..13e34663f5 --- /dev/null +++ b/script/check-test-ci.ts @@ -0,0 +1,35 @@ +// kilocode_change - new file +import path from "path" + +const root = path.resolve(import.meta.dir, "..") +const proc = Bun.spawnSync(["git", "ls-files", "packages"], { + cwd: root, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", +}) +if (proc.exitCode !== 0) throw new Error(proc.stderr.toString() || "Unable to list tracked package tests") + +const exempt = new Set(["packages/kilo-vscode"]) +const dirs = new Set( + proc.stdout + .toString() + .split("\n") + .filter((file) => /\.test\.tsx?$/.test(file)) + .map((file) => file.split("/").slice(0, 2).join("/")), +) +const missing: string[] = [] + +for (const dir of [...dirs].sort()) { + if (exempt.has(dir)) continue + const file = path.join(root, dir, "package.json") + const source = Bun.file(file) + if (!(await source.exists())) continue + const pkg = (await source.json()) as { scripts?: Record } + const scripts = pkg.scripts + if (!scripts?.test && !scripts?.["test:ci"]) continue + if (!scripts["test:ci"]) missing.push(`${dir}/package.json`) +} + +if (missing.length > 0) throw new Error(`Test-bearing packages missing test:ci:\n${missing.join("\n")}`) +console.log(`check-test-ci: ok (${dirs.size - exempt.size} test-bearing package(s))`) diff --git a/script/translate-app.md b/script/translate-app.md deleted file mode 100644 index 96615f132b..0000000000 --- a/script/translate-app.md +++ /dev/null @@ -1,21 +0,0 @@ -Translate the product app locale `$1` from the English source dictionaries. English is the read-only source of truth. Its copy is intentional and must never be modified, rewritten, or "improved." - -The translation request below contains the locale glossary, exact source and target files, plus missing, extra, and placeholder-mismatched keys. - -```json -$ARGUMENTS -``` - -Requirements: - -- Edit only the target files listed in the request. Never edit English, another locale, tests, registries, docs, or other packages. -- Treat every English key and value as intentional. Translate from it without changing the English source files in any way. -- Add every missing key with a natural, concise translation suitable for application UI. -- Remove keys listed as extra and repair values listed under `placeholders` so their `{{tokens}}` exactly match English. -- Preserve existing translations unless they have a listed placeholder mismatch. -- Preserve meaning, intent, tone, capitalization, punctuation, whitespace, and formatting. -- Preserve technical terms and artifacts exactly: OpenCode, API names, identifiers, code, commands, flags, paths, URLs, versions, error messages, config keys, and placeholder tokens. -- Apply the locale glossary included in the request. -- `ui.sessionTurn.diffs.changed.one` and `ui.sessionTurn.diffs.changed.other` are complete count phrases. Preserve `{{count}}` and translate the whole phrase naturally rather than composing translated fragments. -- Use only read, glob, grep, and edit tools. Do not run commands or delegate work. -- Finish only when every requested key is synchronized and no other file has changed. diff --git a/script/translate-app.test.ts b/script/translate-app.test.ts deleted file mode 100644 index 4eafbc580a..0000000000 --- a/script/translate-app.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - findDrift, - glossaryFile, - modelVariants, - parseTranslationArgs, - runPool, - sessionIDFromEvents, - sessionModels, - targetFiles, - textFromEvents, - translationConfig, - unexpectedChanges, -} from "./translate-app" - -describe("translate app", () => { - test("parses one locale with the public model defaults", () => { - expect(parseTranslationArgs(["fr"])).toEqual({ - target: "fr", - concurrency: 1, - model: "opencode/gpt-5.5", - variant: "xhigh", - dryRun: false, - check: false, - help: false, - }) - }) - - test("parses all locales with bounded concurrency overrides", () => { - expect( - parseTranslationArgs([ - "all", - "--concurrency", - "7", - "--model", - "opencode/gpt-5.4", - "--variant", - "high", - "--dry-run", - ]), - ).toEqual({ - target: "all", - concurrency: 7, - model: "opencode/gpt-5.4", - variant: "high", - dryRun: true, - check: false, - help: false, - }) - }) - - test("rejects unsupported targets and invalid concurrency", () => { - expect(() => parseTranslationArgs(["en"])).toThrow("Unknown locale") - expect(() => parseTranslationArgs(["fr", "de"])).toThrow("one locale") - expect(() => parseTranslationArgs(["all", "--concurrency", "0"])).toThrow("positive integer") - }) - - test("parses fresh-process parity checks without requesting translation", () => { - expect(parseTranslationArgs(["fr", "--check"]).check).toBe(true) - }) - - test("limits each locale to its app surfaces", () => { - expect(targetFiles("fr")).toEqual([ - "packages/app/src/i18n/fr.ts", - "packages/ui/src/i18n/fr.ts", - "packages/desktop/src/renderer/i18n/fr.ts", - ]) - expect(targetFiles("tr")).toEqual(["packages/app/src/i18n/tr.ts", "packages/ui/src/i18n/tr.ts"]) - }) - - test("maps product locale codes to their glossaries", () => { - expect(glossaryFile("fr")).toBe(".opencode/glossary/fr.md") - expect(glossaryFile("zh")).toBe(".opencode/glossary/zh-cn.md") - expect(glossaryFile("zht")).toBe(".opencode/glossary/zh-tw.md") - }) - - test("finds key and placeholder drift", () => { - expect( - findDrift( - { keep: "Hello {{name}}", missing: "Missing", changed: "{{one}} {{two}}" }, - { keep: "Bonjour {{name}}", extra: "Extra", changed: "{{one}}" }, - ), - ).toEqual({ missing: ["missing"], extra: ["extra"], placeholders: ["changed"] }) - }) - - test("runs work with the requested maximum concurrency", async () => { - const active = new Set() - const peaks: number[] = [] - const result = await runPool([1, 2, 3, 4, 5], 2, async (item) => { - active.add(item) - peaks.push(active.size) - await Bun.sleep(5) - active.delete(item) - return item * 2 - }) - - expect(result).toEqual([2, 4, 6, 8, 10]) - expect(Math.max(...peaks)).toBe(2) - }) - - test("reads the actual model and variant from the completed session", () => { - expect(sessionIDFromEvents('shared: https://example.test\n{"type":"step_start","sessionID":"ses_test"}\n')).toBe( - "ses_test", - ) - expect( - sessionModels({ - messages: [ - { info: { role: "user" } }, - { - info: { - role: "assistant", - providerID: "opencode", - modelID: "gpt-5.5", - variant: "xhigh", - }, - }, - ], - }), - ).toEqual([{ model: "opencode/gpt-5.5", variant: "xhigh" }]) - expect( - textFromEvents( - 'shared: https://example.test\n{"type":"text","sessionID":"ses_test","part":{"text":"finished"}}\n', - ), - ).toBe("finished") - }) - - test("resolves variants from verbose model output", () => { - const output = `opencode/other -{"variants":{}} -opencode/gpt-5.5 -{"variants":{"high":{"reasoningEffort":"high"},"xhigh":{"reasoningEffort":"xhigh"}}} -opencode/next -{"variants":{}} -` - expect(modelVariants(output, "opencode/gpt-5.5")).toEqual({ - high: { reasoningEffort: "high" }, - xhigh: { reasoningEffort: "xhigh" }, - }) - }) - - test("disables side effects and scopes edits for the translation agent", () => { - const config = translationConfig("translate-app-fr", "opencode/gpt-5.5", ["packages/app/src/i18n/fr.ts"]) - expect(config.share).toBe("disabled") - expect(config.formatter).toBe(false) - expect(config.lsp).toBe(false) - expect(config.agent["translate-app-fr"].permission.edit).toEqual({ - "*": "deny", - "packages/app/src/i18n/fr.ts": "allow", - }) - }) - - test("detects edits outside the locale targets", () => { - expect( - unexpectedChanges( - { "script/translate-app.ts": "before" }, - { - "script/translate-app.ts": "before", - "packages/app/src/i18n/fr.ts": "translated", - "packages/app/src/app.tsx": "unexpected", - }, - ["packages/app/src/i18n/fr.ts"], - ), - ).toEqual(["packages/app/src/app.tsx"]) - expect(unexpectedChanges({ "already-dirty.ts": "before" }, { "already-dirty.ts": "after" }, [])).toEqual([ - "already-dirty.ts", - ]) - }) -}) diff --git a/script/translate-app.ts b/script/translate-app.ts deleted file mode 100644 index 23f3f32139..0000000000 --- a/script/translate-app.ts +++ /dev/null @@ -1,523 +0,0 @@ -#!/usr/bin/env bun - -import path from "path" -import { parseArgs } from "util" -import { pathToFileURL } from "url" - -const locales = [ - "ar", - "br", - "bs", - "da", - "de", - "es", - "fr", - "ja", - "ko", - "no", - "pl", - "ru", - "uk", - "th", - "tr", - "zh", - "zht", -] as const -type Locale = (typeof locales)[number] - -const languages = { - ar: "Arabic", - br: "Brazilian Portuguese", - bs: "Bosnian", - da: "Danish", - de: "German", - es: "Spanish", - fr: "French", - ja: "Japanese", - ko: "Korean", - no: "Norwegian Bokmal", - pl: "Polish", - ru: "Russian", - uk: "Ukrainian", - th: "Thai", - tr: "Turkish", - zh: "Simplified Chinese", - zht: "Traditional Chinese", -} as const satisfies Record - -type Dictionary = Record -type Drift = ReturnType -type Domain = { name: string; source: string; target: string; drift: Drift } - -const desktopLocales = new Set(locales.filter((locale) => locale !== "th" && locale !== "tr")) -const root = path.resolve(import.meta.dir, "..") - -export function parseTranslationArgs(args: string[]) { - const parsed = parseArgs({ - args, - options: { - concurrency: { type: "string", short: "c", default: "4" }, - model: { type: "string", default: "opencode/gpt-5.5" }, - variant: { type: "string", default: "xhigh" }, - "dry-run": { type: "boolean", default: false }, - check: { type: "boolean", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - allowPositionals: true, - }) - const target = parsed.positionals[0] ?? "all" - const concurrency = Number(parsed.values.concurrency) - - if (!parsed.values.help && parsed.positionals.length !== 1) throw new Error("Pass one locale or 'all'.") - if (target !== "all" && !isLocale(target)) throw new Error(`Unknown locale '${target}'.`) - if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("Concurrency must be a positive integer.") - - return { - target, - concurrency: target === "all" ? concurrency : 1, - model: parsed.values.model, - variant: parsed.values.variant, - dryRun: parsed.values["dry-run"], - check: parsed.values.check, - help: parsed.values.help, - } -} - -export function targetFiles(locale: Locale) { - return [ - `packages/app/src/i18n/${locale}.ts`, - `packages/ui/src/i18n/${locale}.ts`, - ...(desktopLocales.has(locale) ? [`packages/desktop/src/renderer/i18n/${locale}.ts`] : []), - ] -} - -export function glossaryFile(locale: Locale) { - if (locale === "zh") return ".opencode/glossary/zh-cn.md" - if (locale === "zht") return ".opencode/glossary/zh-tw.md" - return `.opencode/glossary/${locale}.md` -} - -export function findDrift(source: Dictionary, target: Dictionary) { - return { - missing: Object.keys(source).filter((key) => !Object.hasOwn(target, key)), - extra: Object.keys(target).filter((key) => !Object.hasOwn(source, key)), - placeholders: Object.keys(source).filter( - (key) => Object.hasOwn(target, key) && tokens(source[key]).join() !== tokens(target[key]).join(), - ), - } -} - -export function sessionIDFromEvents(output: string) { - const match = output.match(/"sessionID"\s*:\s*"([^"]+)"/) - if (!match?.[1]) throw new Error("OpenCode did not report a session ID.") - return match[1] -} - -export function sessionModels(value: unknown) { - if (!isRecord(value) || !Array.isArray(value.messages)) - throw new Error("OpenCode returned an invalid session export.") - return value.messages.flatMap((message) => { - if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant") return [] - if (typeof message.info.providerID !== "string" || typeof message.info.modelID !== "string") { - throw new Error("OpenCode session export omitted the assistant model.") - } - return [ - { - model: `${message.info.providerID}/${message.info.modelID}`, - variant: typeof message.info.variant === "string" ? message.info.variant : undefined, - }, - ] - }) -} - -export function modelVariants(output: string, model: string) { - const normalized = output.replaceAll("\r\n", "\n") - const marker = `${model}\n` - const start = normalized.indexOf(marker) - if (start < 0) throw new Error(`Model not found: ${model}`) - const provider = model.split("/")[0] - const rest = normalized.slice(start + marker.length) - const next = rest.search(new RegExp(`^${escapeRegExp(provider)}/`, "m")) - const metadata: unknown = JSON.parse((next < 0 ? rest : rest.slice(0, next)).trim()) - if (!isRecord(metadata) || !isRecord(metadata.variants)) throw new Error(`Model variants not found: ${model}`) - return metadata.variants -} - -export function translationConfig(agent: string, model: string, targets: string[]) { - return { - $schema: "https://opencode.ai/config.json", - model, - default_agent: agent, - share: "disabled" as const, - formatter: false, - lsp: false, - snapshot: false, - agent: { - [agent]: { - mode: "primary" as const, - model, - permission: { - "*": "deny" as const, - read: "allow" as const, - glob: "allow" as const, - grep: "allow" as const, - edit: Object.fromEntries([["*", "deny"], ...targets.map((target) => [target, "allow"])]), - }, - }, - }, - } -} - -export function unexpectedChanges(before: Record, after: Record, allowed: string[]) { - const targets = new Set(allowed) - return [...new Set([...Object.keys(before), ...Object.keys(after)])] - .filter((file) => !targets.has(file) && before[file] !== after[file]) - .sort() -} - -export async function runPool(items: readonly T[], concurrency: number, task: (item: T) => Promise) { - const results = new Map() - const entries = items.entries() - const worker = async (): Promise => { - const next = entries.next() - if (next.done) return - results.set(next.value[0], await task(next.value[1])) - await worker() - } - - await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)) - return Array.from(results.entries()) - .sort((a, b) => a[0] - b[0]) - .map((entry) => entry[1]) -} - -async function main() { - const options = parseTranslationArgs(Bun.argv.slice(2)) - if (options.help) { - console.log(` -Usage: bun run translate:app -- [options] - -Synchronizes product app translations with the English app, UI, and desktop dictionaries. - -Options: - -c, --concurrency Maximum parallel OpenCode runs for 'all' (default: 4) - --model OpenCode model (default: opencode/gpt-5.5) - --variant Model variant (default: xhigh) - --dry-run Report drift without running OpenCode - --check Exit nonzero when translation drift exists - -h, --help Show this help message - -Examples: - bun run translate:app -- fr - bun run translate:app -- all --concurrency 4 -`) - return - } - - const selected = options.target === "all" ? locales : [options.target] - const plans = await Promise.all(selected.map((locale) => inspect(locale))) - plans.forEach(report) - const pending = plans.filter((plan) => plan.domains.some((domain) => changed(domain.drift))) - if (options.check) { - if (pending.length) process.exitCode = 1 - return - } - if (options.dryRun || pending.length === 0) return - - const targets = pending.flatMap((plan) => plan.domains.map((domain) => domain.target)) - const baseline = await worktreeSnapshot() - const variant = await resolveModelVariant(options.model, options.variant) - console.log(`Resolved ${options.model} (${options.variant}): ${JSON.stringify(variant)}`) - const template = await commandTemplate() - const results = await runPool(pending, options.concurrency, (plan) => - translate(plan, template, options.model, options.variant).catch((error) => ({ - locale: plan.locale, - code: 1, - stdout: "", - stderr: error instanceof Error ? error.message : String(error), - })), - ) - - results.forEach((result) => { - if (result.stdout) process.stdout.write(`\n[${result.locale}]\n${result.stdout}`) - if (result.stderr) process.stderr.write(`\n[${result.locale}]\n${result.stderr}`) - }) - - const failed = results.filter((result) => result.code !== 0) - const checks = await runPool(pending, options.concurrency, (plan) => check(plan.locale)) - const incomplete = checks.filter((result) => result.code !== 0) - const escaped = unexpectedChanges(baseline, await worktreeSnapshot(), targets) - incomplete.forEach((result) => { - if (result.stdout) process.stderr.write(`\n[${result.locale} verification]\n${result.stdout}`) - if (result.stderr) process.stderr.write(`\n[${result.locale} verification]\n${result.stderr}`) - }) - - if (failed.length === 0 && incomplete.length === 0 && escaped.length === 0) { - console.log(`\nTranslated ${pending.map((plan) => plan.locale).join(", ")}.`) - return - } - - if (failed.length) console.error(`\nOpenCode failed for: ${failed.map((result) => result.locale).join(", ")}`) - if (incomplete.length) - console.error(`Translation remains incomplete for: ${incomplete.map((plan) => plan.locale).join(", ")}`) - if (escaped.length) console.error(`Translation changed files outside its locale targets: ${escaped.join(", ")}`) - process.exitCode = 1 -} - -async function worktreeSnapshot() { - const groups = await Promise.all([ - gitPaths(["diff", "--name-only", "-z", "HEAD"]), - gitPaths(["ls-files", "--others", "--exclude-standard", "-z"]), - ]) - const files = [...new Set(groups.flat())] - return Object.fromEntries( - await Promise.all( - files.map(async (file) => { - const target = Bun.file(path.join(root, file)) - if (!(await target.exists())) return [file, ""] as const - const hash = new Bun.CryptoHasher("sha256") - hash.update(await target.arrayBuffer()) - return [file, hash.digest("hex")] as const - }), - ), - ) -} - -async function gitPaths(args: string[]) { - const proc = Bun.spawn(["git", ...args], { - cwd: root, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }) - const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]) - if (result[2] !== 0) throw new Error(result[1] || `git ${args.join(" ")} failed`) - return result[0].split("\0").filter(Boolean) -} - -async function check(locale: Locale) { - const proc = Bun.spawn([process.execPath, import.meta.path, locale, "--check"], { - cwd: root, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }) - const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]) - return { locale, stdout: result[0], stderr: result[1], code: result[2] } -} - -async function inspect(locale: Locale) { - const domains = await Promise.all( - targetFiles(locale).map(async (target) => { - const source = target.replace(`/${locale}.ts`, "/en.ts") - const dictionaries = await Promise.all([dictionary(source), dictionary(target)]) - return { - name: target.includes("packages/app/") ? "app" : target.includes("packages/ui/") ? "ui" : "desktop", - source, - target, - drift: findDrift(dictionaries[0], dictionaries[1]), - } - }), - ) - return { locale, language: languages[locale], domains } -} - -async function dictionary(file: string) { - const module: unknown = await import(pathToFileURL(path.join(root, file)).href) - if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) { - throw new Error(`Invalid translation dictionary: ${file}`) - } - return module.dict -} - -async function commandTemplate() { - return (await Bun.file(path.join(root, "script/translate-app.md")).text()).trim() -} - -async function translate( - plan: { locale: Locale; language: string; domains: Domain[] }, - template: string, - model: string, - variant: string, -) { - const glossary = glossaryFile(plan.locale) - const glossaryContent = (await Bun.file(path.join(root, glossary)).exists()) - ? await Bun.file(path.join(root, glossary)).text() - : undefined - const prompt = template.replaceAll("$1", plan.locale).replaceAll( - "$ARGUMENTS", - JSON.stringify( - { - locale: plan.locale, - language: plan.language, - glossary: glossaryContent ? { file: glossary, content: glossaryContent } : undefined, - domains: plan.domains.map((domain) => ({ - source: domain.source, - target: domain.target, - ...domain.drift, - })), - }, - null, - 2, - ), - ) - const agent = `translate-app-${plan.locale}-${process.pid}` - const env = isolatedEnvironment() - env.KILO_DISABLE_PROJECT_CONFIG = "1" - env.KILO_CONFIG_CONTENT = JSON.stringify( - translationConfig( - agent, - model, - plan.domains.map((domain) => domain.target), - ), - ) - - const proc = Bun.spawn( - [ - "opencode", - "--pure", - "run", - "--dir", - root, - "--agent", - agent, - "--model", - model, - "--variant", - variant, - "--title", - `Translate app ${plan.locale}`, - "--format", - "json", - ], - { - cwd: root, - env, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }, - ) - const stdout = new Response(proc.stdout).text() - const stderr = new Response(proc.stderr).text() - await proc.stdin.write(prompt) - await proc.stdin.end() - const result = await Promise.all([stdout, stderr, proc.exited]) - if (result[2] !== 0) return { locale: plan.locale, stdout: result[0], stderr: result[1], code: result[2] } - - const sessionID = sessionIDFromEvents(result[0]) - const exported = Bun.spawn(["opencode", "--pure", "export", sessionID, "--sanitize"], { - cwd: root, - env, - stdout: "pipe", - stderr: "pipe", - }) - const exportResult = await Promise.all([ - new Response(exported.stdout).text(), - new Response(exported.stderr).text(), - exported.exited, - ]) - if (exportResult[2] !== 0) { - return { locale: plan.locale, stdout: textFromEvents(result[0]), stderr: exportResult[1], code: exportResult[2] } - } - - const session: unknown = JSON.parse(exportResult[0]) - const observed = sessionModels(session) - const mismatch = observed.length === 0 || observed.some((item) => item.model !== model || item.variant !== variant) - const actual = Array.from(new Set(observed.map((item) => `${item.model} (${item.variant ?? "default"})`))).join(", ") - return { - locale: plan.locale, - stdout: `${textFromEvents(result[0])}\nVerified session model: ${actual}\n`, - stderr: mismatch - ? `Requested ${model} (${variant}), but session used ${actual || "no assistant model"}.\n` - : result[1], - code: mismatch ? 1 : 0, - } -} - -function report(plan: { locale: Locale; domains: Domain[] }) { - const details = plan.domains - .map( - (domain) => - `${domain.name}: ${domain.drift.missing.length} missing, ${domain.drift.extra.length} extra, ${domain.drift.placeholders.length} placeholder mismatches`, - ) - .join("; ") - console.log(`[${plan.locale}] ${details}`) -} - -function changed(drift: Drift) { - return drift.missing.length > 0 || drift.extra.length > 0 || drift.placeholders.length > 0 -} - -function isLocale(value: string): value is Locale { - return Object.hasOwn(languages, value) -} - -function isDictionary(value: unknown): value is Dictionary { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false - return Object.values(value).every((item) => typeof item === "string") -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -async function resolveModelVariant(model: string, variant: string) { - const provider = model.split("/")[0] - if (!provider || !model.includes("/")) throw new Error(`Model must use provider/model syntax: ${model}`) - const env = isolatedEnvironment() - env.KILO_DISABLE_PROJECT_CONFIG = "1" - const proc = Bun.spawn(["opencode", "--pure", "models", provider, "--verbose"], { - cwd: root, - env, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }) - const result = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]) - if (result[2] !== 0) throw new Error(result[1] || `Unable to resolve model: ${model}`) - const variants = modelVariants(result[0], model) - if (!Object.hasOwn(variants, variant)) throw new Error(`Variant '${variant}' is not configured for ${model}.`) - return variants[variant] -} - -function isolatedEnvironment() { - const env = { ...process.env } - delete env.KILO_CONFIG - delete env.KILO_CONFIG_DIR - delete env.KILO_CONFIG_CONTENT - delete env.KILO_PERMISSION - delete env.KILO_AUTO_SHARE - return env -} - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -export function textFromEvents(output: string) { - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.startsWith("{") && line.endsWith("}")) - .flatMap((line) => { - const event: unknown = JSON.parse(line) - if (!isRecord(event) || event.type !== "text" || !isRecord(event.part) || typeof event.part.text !== "string") { - return [] - } - return [event.part.text.trim()] - }) - .filter(Boolean) - .join("\n") -} - -function tokens(value: string) { - return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1] ?? "").sort() -} - -if (import.meta.main) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 - }) -} diff --git a/script/upstream/transforms/skip-files.test.ts b/script/upstream/transforms/skip-files.test.ts index b7a67258ae..4b4b3450e6 100644 --- a/script/upstream/transforms/skip-files.test.ts +++ b/script/upstream/transforms/skip-files.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { shouldSkip } from "./skip-files" +import { defaultConfig } from "../utils/config" test("matches hosted package glob paths", () => { expect(shouldSkip("packages/web/package.json", ["packages/web/**"])).toBe(true) @@ -21,6 +22,16 @@ test("matches upstream stats package glob paths", () => { expect(shouldSkip("packages/stats/core/src/index.ts", ["packages/stats/**"])).toBe(true) }) +test("matches upstream-only translation automation", () => { + expect(shouldSkip("script/translate-app.ts", defaultConfig.skipFiles)).toBe(true) + expect(shouldSkip("script/translate-app.test.ts", defaultConfig.skipFiles)).toBe(true) + expect(shouldSkip("script/translate-app.md", defaultConfig.skipFiles)).toBe(true) +}) + +test("transforms the Muse Spark prompt for Kilo branding", () => { + expect(defaultConfig.takeTheirsAndTransform).toContain("packages/opencode/src/session/prompt/meta.txt") +}) + test("matches removed vscode sdk glob paths", () => { expect(shouldSkip("sdks/vscode/package.json", ["sdks/vscode/**"])).toBe(true) expect(shouldSkip("sdks/vscode/src/extension.ts", ["sdks/vscode/**"])).toBe(true) diff --git a/script/upstream/transforms/transform-i18n.test.ts b/script/upstream/transforms/transform-i18n.test.ts new file mode 100644 index 0000000000..399f51c935 --- /dev/null +++ b/script/upstream/transforms/transform-i18n.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import { transformI18nContent } from "./transform-i18n" + +test("marks transformed Kilo branding and preserves legacy config names", () => { + const result = transformI18nContent( + ' "product": "OpenCode",\n "docs": "https://opencode.ai/docs",\n "legacy": ".opencode/opencode.json",', + ) + expect(result.result).toContain('"product": "Kilo", // kilocode_change') + expect(result.result).toContain('"docs": "https://kilo.ai/docs", // kilocode_change') + expect(result.result).toContain('"legacy": ".opencode/opencode.json",') + expect(result.replacements).toBe(2) +}) diff --git a/script/upstream/transforms/transform-i18n.ts b/script/upstream/transforms/transform-i18n.ts index a5769c7990..4e2e63a183 100644 --- a/script/upstream/transforms/transform-i18n.ts +++ b/script/upstream/transforms/transform-i18n.ts @@ -200,7 +200,8 @@ export function transformI18nContent( } } - transformedLines.push(transformedLine) + // Kilo branding produced by this transform remains a Kilo-owned delta in shared locale files. + transformedLines.push(lineReplacements > 0 ? `${transformedLine} // kilocode_change` : transformedLine) totalReplacements += lineReplacements } diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index 85b95f8c4b..64488c66c4 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -69,6 +69,7 @@ test("fixScripts removes upstream-only dead scripts from root", () => { "dev:desktop": "bun --cwd packages/desktop-electron dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", + "translate:app": "bun run script/translate-app.ts", }, } const changes: string[] = [] @@ -78,7 +79,8 @@ test("fixScripts removes upstream-only dead scripts from root", () => { expect(scripts["dev:desktop"]).toBeUndefined() expect(scripts["dev:web"]).toBeUndefined() expect(scripts["dev:console"]).toBeUndefined() - expect(changes.length).toBe(3) + expect(scripts["translate:app"]).toBeUndefined() + expect(changes.length).toBe(4) }) test("fixScripts preserves opencode test scripts", () => { diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 0e958fdb52..a05a408a50 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -320,7 +320,7 @@ const DELETE_UPSTREAM_TRUSTED_DEPS: Record = { // Kilo doesn't ship (desktop-electron, console/app, app) and would otherwise // reappear on every merge. const DELETE_UPSTREAM_SCRIPTS: Record = { - "package.json": ["dev:desktop", "dev:web", "dev:console"], + "package.json": ["dev:desktop", "dev:web", "dev:console", "translate:app"], } // Upstream-only catalog entries to delete per package.json. These are pulled diff --git a/script/upstream/utils/config.ts b/script/upstream/utils/config.ts index 184eb606a3..b35920c249 100644 --- a/script/upstream/utils/config.ts +++ b/script/upstream/utils/config.ts @@ -137,6 +137,10 @@ export const defaultConfig: MergeConfig = { "packages/opencode/bin/opencode", // Removed prompt file "packages/opencode/src/session/prompt/build-switch.txt", + // Upstream app translation automation targets products and binaries Kilo does not ship + "script/translate-app.ts", + "script/translate-app.test.ts", + "script/translate-app.md", // Vouch files (Kilo doesn't use Vouch). // Upstream currently ships VOUCHED.td (typo extension). The glob covers both // the current .td file and any future .md rename without another merge breaking. @@ -174,6 +178,8 @@ export const defaultConfig: MergeConfig = { // Files that should take upstream version and apply Kilo branding transforms // These are files with only branding differences, no logic changes takeTheirsAndTransform: [ + // Model-facing prompts that need Kilo product identity and documentation links + "packages/opencode/src/session/prompt/meta.txt", // UI components "packages/ui/src/components/**/*.tsx", "packages/ui/src/context/**/*.tsx", From dfcbec1f1c77e004681a2ff2201402a2f54dc59b Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Wed, 5 Aug 2026 23:05:56 +0000 Subject: [PATCH 42/67] release(jetbrains): v7.0.13-rc.1 --- packages/kilo-jetbrains/CHANGELOG.md | 84 +++++++++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d000b8245a..9f6199b7fd 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -128,6 +128,90 @@ ## [Unreleased] +## [7.0.13-rc.1] - 2026-08-05 + +### Added +- feat(ui): show line summaries for multi-file patches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12725 +- feat(tui): explain tool auto approval by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12728 +- feat(attachments): add remote CLI file delivery by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12747 +- feat(cli): adopt upstream reasoningVariants from v1.18.11 by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12800 +- feat(agent-manager): allow sessions to move their worktree between sections or ungroup by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12815 +- feat: add signal-to-noise controls to grep tool by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12811 +- feat(vscode): bind speech-to-text to Cmd/Ctrl+K with hold-to-send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12798 +- feat(agent-manager): assign models to workflows by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12729 +- feat(vscode): restore multi-project section and drag-and-drop support by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12803 +- feat(charts/vscode): Added charting capabilities to kilo-ui storybook and VS Code extension by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12525 +- feat(tui): expand a collapsed paste on a second identical paste by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12816 +- feat(docs-sync): learn from maintainer corrections by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12823 +- feat(vscode): discover speech-to-text models by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12841 +- feat(agent-manager): align and persist collapsible headers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12844 +- feat(cli): resume Claude and Codex sessions by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12824 +- feat(agent-manager): support worktree slash commands by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12859 +- feat(cli): add privacy_mode for blurring PII in the TUI by @IamCoder18 in https://github.com/Kilo-Org/kilocode/pull/12442 +- feat(jetbrains): show filenames first in @file mentions by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12732 +- feat(jetbrains): visible CLI download/bundled mode + skill to pin/unpin/update by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12895 +- feat(vscode): Add telemetry for charting tool by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12878 + +### Fixed +- fix(cli): preserve configured subagent routing by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12652 +- fix(vscode): restore Markdown comment gutter anchors after renderer wrapper change by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12794 +- fix(cli): defer threshold compaction during tool loops by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12792 +- fix(cli): speed up local recall searches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12726 +- fix(agent-manager): route mode shortcuts through modal by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12796 +- fix(agent-manager): make worktree hover cards instant by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12799 +- fix(cli): stop inline skill-shell doc examples from triggering permission prompts by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12802 +- fix(vscode): prevent prompt toolbar height growth when training indicator is visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12805 +- fix(cli): omit persona from generated names by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12790 +- fix(vscode): keep skill remove buttons visible at narrow widths by @rakshith1928 in https://github.com/Kilo-Org/kilocode/pull/12733 +- fix(vscode): recover Agent Manager terminals after exit by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12812 +- fix(vscode): persist Agent Manager focus per session by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12801 +- fix(vscode): optimize model selector search and auto-jump to active match by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12810 +- fix(agent-manager): restore focus to question options and prompt on session switch by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12795 +- fix(vscode): accelerate macOS speech capture by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12814 +- fix(docs-sync): address review findings on the learnings step by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12834 +- fix(vscode): remove duplicate sidebar border by @Drixled in https://github.com/Kilo-Org/kilocode/pull/12836 +- fix(cli): skip startup work for informational commands by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12659 +- fix(vscode): keep final patch file visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12839 +- fix: make tool invalid-arguments errors clearly actionable to the model by @mvanhorn in https://github.com/Kilo-Org/kilocode/pull/11961 +- fix(vscode): add project-local navigation hints by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12845 +- fix(cli): allow explicit external markdown sources by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12846 +- fix(vscode): fix multi-project navigation shortcuts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12843 +- fix(vscode): preserve worktree rename focus by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12852 +- fix(vscode): remove selected project indicator by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12851 +- fix(pty): stabilize non-ASCII output round-trip test by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12848 +- fix(kilo-docs): close agents callout correctly by @thomasboom in https://github.com/Kilo-Org/kilocode/pull/12829 +- fix(agent-manager): restore multi-project progress indicators by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12850 +- fix(vscode): remove duplicate Agent Manager empty state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12857 +- fix(vscode): sync Agent Manager inspector width by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12858 +- fix(vscode): isolate project worktree row state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12860 +- fix(jetbrains): avoid CLI checksum API rate limits by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12861 +- fix(agent-manager): scope multi-project sessions by project by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12867 +- fix(vscode): defer unused worktree watchers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12865 +- fix(jetbrains): add dropped files as references by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12869 +- fix(vscode): move prompt rail away from session content by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12883 +- fix(cli): handle SQLite lock errors by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12884 +- fix(vscode): prevent Agent Manager overview timeouts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12885 +- fix(vscode): start Agent Manager terminals instantly by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12866 +- fix(cli): stop eager file watchers on JetBrains by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12897 +- fix(jetbrains): improve session diff rendering by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12862 + +### Changed +- release(jetbrains): v7.0.12 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12766 +- perf(vscode): parallelize build validation and cache SDK generation by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12807 +- chore: remove accidental PR screenshot by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12820 +- docs: auto-sync with merged PRs (through 2026-08-01) by @github-actions[bot] in https://github.com/Kilo-Org/kilocode/pull/12716 +- revert(cli): stop promoting stable releases to rc by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12827 +- perf(vscode): defer Agent Manager terminal addons by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12842 +- revert(cli): restore stable grep behavior by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12847 +- test(cli): isolate PTY route tests from indexing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12818 +- chore(jetbrains): bump CLI pin to v7.4.20 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12853 +- docs(vscode): add icon authoring skill by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12872 +- Opencode Merge v1.17.10-v1.17.13 by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12695 +- docs(kilo-docs): document upstream v1.17.13 behavior by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12900 +- Revert "feat(jetbrains): show filenames first in @file mentions" by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12903 +- docs: consolidate Auto Balanced into Auto Efficient by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12892 + + ## [7.0.12] - 2026-08-01 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 22bcfa1724..2209c5aed7 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.12 +kilo.jetbrains.version=7.0.13-rc.1 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. From af6d1ded6d0c42f31b2cea2b84e478f6ac10445a Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 6 Aug 2026 01:06:52 +0200 Subject: [PATCH 43/67] fix: address second-round merge review findings --- .github/workflows/test.yml | 12 ++++++++++-- package.json | 1 + packages/opencode/src/provider/transform.ts | 4 ++-- .../provider/kimi-adaptive-effort.test.ts | 7 ++++++- packages/sdk-next/package.json | 2 +- script/check-test-ci.ts | 15 +++++++++++---- .../transforms/transform-package-json.test.ts | 2 ++ .../upstream/transforms/transform-package-json.ts | 1 + 8 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78143b9f8c..d57ca436e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -146,6 +146,10 @@ jobs: if: matrix.settings.run && matrix.settings.packages && matrix.settings.os == 'linux' run: bun run script/check-test-ci.ts + - name: Run root tooling unit tests + if: matrix.settings.run && matrix.settings.packages && matrix.settings.os == 'linux' + run: bun run test:script:ci + - name: Run non-CLI unit tests if: matrix.settings.run && matrix.settings.packages run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' @@ -175,7 +179,9 @@ jobs: if: always() && matrix.settings.run uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: - report_paths: packages/*/.artifacts/unit/junit.xml + report_paths: | + .artifacts/unit/junit.xml + packages/*/.artifacts/unit/junit.xml annotate_only: true detailed_summary: true include_time_in_summary: true @@ -189,7 +195,9 @@ jobs: include-hidden-files: true if-no-files-found: ignore retention-days: 7 - path: packages/*/.artifacts/unit/junit.xml + path: | + .artifacts/unit/junit.xml + packages/*/.artifacts/unit/junit.xml # kilocode_change end # kilocode_change start diff --git a/package.json b/package.json index 1bba0e600b..536136f32e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "random": "echo 'Random script'", "sso": "aws sso login --sso-session=opencode --no-browser", "test": "echo 'do not run tests from root' && exit 1", + "test:script:ci": "mkdir -p .artifacts/unit && bun test ./script --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "extension": "bun --cwd packages/kilo-vscode script/launch.ts", "extension:isolated": "bun --cwd packages/kilo-vscode script/launch.ts --isolated", "extension:isolated:clean": "bun --cwd packages/kilo-vscode script/launch.ts --isolated --clean", diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 2829d23f39..e12c05de27 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1314,12 +1314,12 @@ function anthropicEffort(model: Provider.Model, effort: string) { function isKimiFamily(model: Provider.Model) { if ( [model.providerID, model.api.id].some((id) => { - const value = id.toLowerCase() + const value = id?.toLowerCase() ?? "" return value.includes("kimi") || value.includes("moonshot") }) ) return true - const url = model.api.url.toLowerCase() + const url = model.api.url?.toLowerCase() ?? "" return ["api.kimi.com", "api.moonshot.ai", "api.moonshot.cn", "api.moonshotai.cn"].some((host) => url.includes(host)) } // kilocode_change end diff --git a/packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts b/packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts index 3cdc32ab3f..8e6fdef69c 100644 --- a/packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts +++ b/packages/opencode/test/kilocode/provider/kimi-adaptive-effort.test.ts @@ -7,7 +7,7 @@ const model = { reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], } as unknown as ModelsDev.Model -function target(input: { providerID: string; id: string; url: string }) { +function target(input: { providerID?: string; id: string; url?: string }) { return { id: input.id, providerID: input.providerID, @@ -37,4 +37,9 @@ describe("Kimi adaptive effort", () => { ) expect(variants?.high).toEqual({ thinking: { type: "adaptive", display: "summarized" }, effort: "high" }) }) + + test("handles partial metadata from generic Anthropic providers", () => { + const variants = ProviderTransform.reasoningVariants(model, target({ id: "claude-sonnet-4-6" })) + expect(variants?.high).toEqual({ thinking: { type: "adaptive" }, effort: "high" }) + }) }) diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index e81813908f..b4ec9ce5a9 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -9,7 +9,7 @@ }, "scripts": { "test": "bun test --timeout 5000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 10000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/script/check-test-ci.ts b/script/check-test-ci.ts index 13e34663f5..ca8201f062 100644 --- a/script/check-test-ci.ts +++ b/script/check-test-ci.ts @@ -2,7 +2,7 @@ import path from "path" const root = path.resolve(import.meta.dir, "..") -const proc = Bun.spawnSync(["git", "ls-files", "packages"], { +const proc = Bun.spawnSync(["git", "ls-files", "packages", "script"], { cwd: root, stdin: "ignore", stdout: "pipe", @@ -15,10 +15,17 @@ const dirs = new Set( proc.stdout .toString() .split("\n") - .filter((file) => /\.test\.tsx?$/.test(file)) + .filter((file) => /^packages\/.*\.test\.tsx?$/.test(file)) .map((file) => file.split("/").slice(0, 2).join("/")), ) const missing: string[] = [] +const files = proc.stdout.toString().split("\n") + +const scripts = files.filter((file) => /^script\/.*\.test\.tsx?$/.test(file)) +if (scripts.length > 0) { + const pkg = (await Bun.file(path.join(root, "package.json")).json()) as { scripts?: Record } + if (!pkg.scripts?.["test:script:ci"]?.includes("bun test ./script")) missing.push("package.json#test:script:ci") +} for (const dir of [...dirs].sort()) { if (exempt.has(dir)) continue @@ -31,5 +38,5 @@ for (const dir of [...dirs].sort()) { if (!scripts["test:ci"]) missing.push(`${dir}/package.json`) } -if (missing.length > 0) throw new Error(`Test-bearing packages missing test:ci:\n${missing.join("\n")}`) -console.log(`check-test-ci: ok (${dirs.size - exempt.size} test-bearing package(s))`) +if (missing.length > 0) throw new Error(`Test suites missing CI scheduling:\n${missing.join("\n")}`) +console.log(`check-test-ci: ok (${dirs.size - exempt.size} test-bearing package(s), ${scripts.length} root script test file(s))`) diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index 64488c66c4..49e6524eaf 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -20,6 +20,7 @@ test("fixScripts preserves Kilo-only root scripts from base", () => { extension: "bun --cwd packages/kilo-vscode script/launch.ts", "extension:isolated": "bun --cwd packages/kilo-vscode script/launch.ts --isolated", "extension:isolated:clean": "bun --cwd packages/kilo-vscode script/launch.ts --isolated --clean", + "test:script:ci": "bun test ./script", }, } const pkg: Record = { @@ -33,6 +34,7 @@ test("fixScripts preserves Kilo-only root scripts from base", () => { expect(scripts.extension).toBe(ours.scripts.extension) expect(scripts["extension:isolated"]).toBe(ours.scripts["extension:isolated"]) expect(scripts["extension:isolated:clean"]).toBe(ours.scripts["extension:isolated:clean"]) + expect(scripts["test:script:ci"]).toBe(ours.scripts["test:script:ci"]) expect(changes.some((c) => c.includes("postinstall"))).toBe(true) expect(changes.some((c) => c.includes("dev-setup"))).toBe(true) }) diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index a05a408a50..1532ac841f 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -291,6 +291,7 @@ const PRESERVE_SCRIPTS: Record = { "dev-setup", "postinstall", "dev:local", + "test:script:ci", ], "packages/opencode/package.json": ["test", "test:ci"], // Upstream-shared packages where Kilo adds a JUnit test:ci script for CI. From da5fc5136cbe7b22e06f0de03300006a01659adb Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Wed, 5 Aug 2026 19:14:08 -0400 Subject: [PATCH 44/67] docs(jetbrains): edit changelog for v7.0.13-rc.1 --- packages/kilo-jetbrains/CHANGELOG.md | 97 +++++++--------------------- 1 file changed, 22 insertions(+), 75 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 9f6199b7fd..cf4296bc8a 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -131,86 +131,33 @@ ## [7.0.13-rc.1] - 2026-08-05 ### Added -- feat(ui): show line summaries for multi-file patches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12725 -- feat(tui): explain tool auto approval by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12728 -- feat(attachments): add remote CLI file delivery by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12747 -- feat(cli): adopt upstream reasoningVariants from v1.18.11 by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12800 -- feat(agent-manager): allow sessions to move their worktree between sections or ungroup by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12815 -- feat: add signal-to-noise controls to grep tool by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12811 -- feat(vscode): bind speech-to-text to Cmd/Ctrl+K with hold-to-send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12798 -- feat(agent-manager): assign models to workflows by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12729 -- feat(vscode): restore multi-project section and drag-and-drop support by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12803 -- feat(charts/vscode): Added charting capabilities to kilo-ui storybook and VS Code extension by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12525 -- feat(tui): expand a collapsed paste on a second identical paste by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12816 -- feat(docs-sync): learn from maintainer corrections by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12823 -- feat(vscode): discover speech-to-text models by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12841 -- feat(agent-manager): align and persist collapsible headers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12844 -- feat(cli): resume Claude and Codex sessions by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12824 -- feat(agent-manager): support worktree slash commands by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12859 -- feat(cli): add privacy_mode for blurring PII in the TUI by @IamCoder18 in https://github.com/Kilo-Org/kilocode/pull/12442 -- feat(jetbrains): show filenames first in @file mentions by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12732 -- feat(jetbrains): visible CLI download/bundled mode + skill to pin/unpin/update by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12895 -- feat(vscode): Add telemetry for charting tool by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12878 + +- Show the pinned Kilo Core version and whether JetBrains is using a downloaded or bundled CLI build. +- Add JetBrains developer tooling for pinning, unpinning, and updating the bundled Kilo Core CLI used by the plugin. +- Support resuming Claude and Codex sessions through the bundled Kilo Core runtime. +- Add remote CLI file delivery support for attachment flows. ### Fixed -- fix(cli): preserve configured subagent routing by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12652 -- fix(vscode): restore Markdown comment gutter anchors after renderer wrapper change by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12794 -- fix(cli): defer threshold compaction during tool loops by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12792 -- fix(cli): speed up local recall searches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12726 -- fix(agent-manager): route mode shortcuts through modal by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12796 -- fix(agent-manager): make worktree hover cards instant by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12799 -- fix(cli): stop inline skill-shell doc examples from triggering permission prompts by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12802 -- fix(vscode): prevent prompt toolbar height growth when training indicator is visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12805 -- fix(cli): omit persona from generated names by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12790 -- fix(vscode): keep skill remove buttons visible at narrow widths by @rakshith1928 in https://github.com/Kilo-Org/kilocode/pull/12733 -- fix(vscode): recover Agent Manager terminals after exit by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12812 -- fix(vscode): persist Agent Manager focus per session by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12801 -- fix(vscode): optimize model selector search and auto-jump to active match by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12810 -- fix(agent-manager): restore focus to question options and prompt on session switch by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12795 -- fix(vscode): accelerate macOS speech capture by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12814 -- fix(docs-sync): address review findings on the learnings step by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12834 -- fix(vscode): remove duplicate sidebar border by @Drixled in https://github.com/Kilo-Org/kilocode/pull/12836 -- fix(cli): skip startup work for informational commands by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12659 -- fix(vscode): keep final patch file visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12839 -- fix: make tool invalid-arguments errors clearly actionable to the model by @mvanhorn in https://github.com/Kilo-Org/kilocode/pull/11961 -- fix(vscode): add project-local navigation hints by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12845 -- fix(cli): allow explicit external markdown sources by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12846 -- fix(vscode): fix multi-project navigation shortcuts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12843 -- fix(vscode): preserve worktree rename focus by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12852 -- fix(vscode): remove selected project indicator by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12851 -- fix(pty): stabilize non-ASCII output round-trip test by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12848 -- fix(kilo-docs): close agents callout correctly by @thomasboom in https://github.com/Kilo-Org/kilocode/pull/12829 -- fix(agent-manager): restore multi-project progress indicators by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12850 -- fix(vscode): remove duplicate Agent Manager empty state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12857 -- fix(vscode): sync Agent Manager inspector width by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12858 -- fix(vscode): isolate project worktree row state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12860 -- fix(jetbrains): avoid CLI checksum API rate limits by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12861 -- fix(agent-manager): scope multi-project sessions by project by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12867 -- fix(vscode): defer unused worktree watchers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12865 -- fix(jetbrains): add dropped files as references by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12869 -- fix(vscode): move prompt rail away from session content by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12883 -- fix(cli): handle SQLite lock errors by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12884 -- fix(vscode): prevent Agent Manager overview timeouts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12885 -- fix(vscode): start Agent Manager terminals instantly by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12866 -- fix(cli): stop eager file watchers on JetBrains by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12897 -- fix(jetbrains): improve session diff rendering by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12862 + +- Avoid GitHub checksum API rate limits when JetBrains verifies downloaded Kilo Core CLI assets. +- Add dropped files as JetBrains file references so attachments are available to Kilo reliably. +- Stop eager Kilo Core file watchers when running from JetBrains to reduce unnecessary background work. +- Improve JetBrains session diff rendering, including full-file editor diffs, multi-hunk diffs, fallback handling, gutter line numbers, and session-scoped diff paths. +- Preserve configured subagent routing in Kilo Core. +- Defer threshold compaction during active tool loops so long-running sessions do not compact at unsafe points. +- Speed up local recall searches in Kilo Core. +- Stop inline skill-shell documentation examples from triggering permission prompts. +- Omit persona details from generated session names. +- Skip Kilo Core startup work for informational commands. +- Make invalid tool-argument errors clearer and more actionable to the model. +- Allow explicit external markdown sources in Kilo Core. +- Handle SQLite lock errors more gracefully. ### Changed -- release(jetbrains): v7.0.12 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12766 -- perf(vscode): parallelize build validation and cache SDK generation by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12807 -- chore: remove accidental PR screenshot by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12820 -- docs: auto-sync with merged PRs (through 2026-08-01) by @github-actions[bot] in https://github.com/Kilo-Org/kilocode/pull/12716 -- revert(cli): stop promoting stable releases to rc by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12827 -- perf(vscode): defer Agent Manager terminal addons by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12842 -- revert(cli): restore stable grep behavior by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12847 -- test(cli): isolate PTY route tests from indexing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12818 -- chore(jetbrains): bump CLI pin to v7.4.20 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12853 -- docs(vscode): add icon authoring skill by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12872 -- Opencode Merge v1.17.10-v1.17.13 by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12695 -- docs(kilo-docs): document upstream v1.17.13 behavior by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12900 -- Revert "feat(jetbrains): show filenames first in @file mentions" by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12903 -- docs: consolidate Auto Balanced into Auto Efficient by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12892 +- Bump the JetBrains CLI pin to Kilo CLI v7.4.20. +- Include upstream OpenCode updates through v1.17.13. +- Adopt upstream reasoning variant metadata from OpenCode v1.18.11. ## [7.0.12] - 2026-08-01 From 346b4d37f924dd4d3f120219f93add3c5902c40c Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 6 Aug 2026 00:40:33 +0000 Subject: [PATCH 45/67] release(jetbrains): v7.0.13 --- packages/kilo-jetbrains/CHANGELOG.md | 85 +++++++++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index cf4296bc8a..780a4f616c 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -128,6 +128,91 @@ ## [Unreleased] +## [7.0.13] - 2026-08-06 + +### Added +- feat(ui): show line summaries for multi-file patches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12725 +- feat(tui): explain tool auto approval by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12728 +- feat(attachments): add remote CLI file delivery by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12747 +- feat(cli): adopt upstream reasoningVariants from v1.18.11 by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12800 +- feat(agent-manager): allow sessions to move their worktree between sections or ungroup by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12815 +- feat: add signal-to-noise controls to grep tool by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12811 +- feat(vscode): bind speech-to-text to Cmd/Ctrl+K with hold-to-send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12798 +- feat(agent-manager): assign models to workflows by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12729 +- feat(vscode): restore multi-project section and drag-and-drop support by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12803 +- feat(charts/vscode): Added charting capabilities to kilo-ui storybook and VS Code extension by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12525 +- feat(tui): expand a collapsed paste on a second identical paste by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12816 +- feat(docs-sync): learn from maintainer corrections by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12823 +- feat(vscode): discover speech-to-text models by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12841 +- feat(agent-manager): align and persist collapsible headers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12844 +- feat(cli): resume Claude and Codex sessions by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12824 +- feat(agent-manager): support worktree slash commands by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12859 +- feat(cli): add privacy_mode for blurring PII in the TUI by @IamCoder18 in https://github.com/Kilo-Org/kilocode/pull/12442 +- feat(jetbrains): show filenames first in @file mentions by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12732 +- feat(jetbrains): visible CLI download/bundled mode + skill to pin/unpin/update by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12895 +- feat(vscode): Add telemetry for charting tool by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12878 + +### Fixed +- fix(cli): preserve configured subagent routing by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12652 +- fix(vscode): restore Markdown comment gutter anchors after renderer wrapper change by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12794 +- fix(cli): defer threshold compaction during tool loops by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12792 +- fix(cli): speed up local recall searches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12726 +- fix(agent-manager): route mode shortcuts through modal by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12796 +- fix(agent-manager): make worktree hover cards instant by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12799 +- fix(cli): stop inline skill-shell doc examples from triggering permission prompts by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12802 +- fix(vscode): prevent prompt toolbar height growth when training indicator is visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12805 +- fix(cli): omit persona from generated names by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12790 +- fix(vscode): keep skill remove buttons visible at narrow widths by @rakshith1928 in https://github.com/Kilo-Org/kilocode/pull/12733 +- fix(vscode): recover Agent Manager terminals after exit by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12812 +- fix(vscode): persist Agent Manager focus per session by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12801 +- fix(vscode): optimize model selector search and auto-jump to active match by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12810 +- fix(agent-manager): restore focus to question options and prompt on session switch by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12795 +- fix(vscode): accelerate macOS speech capture by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12814 +- fix(docs-sync): address review findings on the learnings step by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12834 +- fix(vscode): remove duplicate sidebar border by @Drixled in https://github.com/Kilo-Org/kilocode/pull/12836 +- fix(cli): skip startup work for informational commands by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12659 +- fix(vscode): keep final patch file visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12839 +- fix: make tool invalid-arguments errors clearly actionable to the model by @mvanhorn in https://github.com/Kilo-Org/kilocode/pull/11961 +- fix(vscode): add project-local navigation hints by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12845 +- fix(cli): allow explicit external markdown sources by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12846 +- fix(vscode): fix multi-project navigation shortcuts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12843 +- fix(vscode): preserve worktree rename focus by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12852 +- fix(vscode): remove selected project indicator by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12851 +- fix(pty): stabilize non-ASCII output round-trip test by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12848 +- fix(kilo-docs): close agents callout correctly by @thomasboom in https://github.com/Kilo-Org/kilocode/pull/12829 +- fix(agent-manager): restore multi-project progress indicators by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12850 +- fix(vscode): remove duplicate Agent Manager empty state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12857 +- fix(vscode): sync Agent Manager inspector width by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12858 +- fix(vscode): isolate project worktree row state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12860 +- fix(jetbrains): avoid CLI checksum API rate limits by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12861 +- fix(agent-manager): scope multi-project sessions by project by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12867 +- fix(vscode): defer unused worktree watchers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12865 +- fix(jetbrains): add dropped files as references by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12869 +- fix(vscode): move prompt rail away from session content by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12883 +- fix(cli): handle SQLite lock errors by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12884 +- fix(vscode): prevent Agent Manager overview timeouts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12885 +- fix(vscode): start Agent Manager terminals instantly by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12866 +- fix(cli): stop eager file watchers on JetBrains by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12897 +- fix(jetbrains): improve session diff rendering by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12862 + +### Changed +- release(jetbrains): v7.0.12 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12766 +- perf(vscode): parallelize build validation and cache SDK generation by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12807 +- chore: remove accidental PR screenshot by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12820 +- docs: auto-sync with merged PRs (through 2026-08-01) by @github-actions[bot] in https://github.com/Kilo-Org/kilocode/pull/12716 +- revert(cli): stop promoting stable releases to rc by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12827 +- perf(vscode): defer Agent Manager terminal addons by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12842 +- revert(cli): restore stable grep behavior by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12847 +- test(cli): isolate PTY route tests from indexing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12818 +- chore(jetbrains): bump CLI pin to v7.4.20 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12853 +- docs(vscode): add icon authoring skill by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12872 +- Opencode Merge v1.17.10-v1.17.13 by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12695 +- docs(kilo-docs): document upstream v1.17.13 behavior by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12900 +- Revert "feat(jetbrains): show filenames first in @file mentions" by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12903 +- docs: consolidate Auto Balanced into Auto Efficient by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12892 +- release(jetbrains): v7.0.13-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12919 + + ## [7.0.13-rc.1] - 2026-08-05 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 2209c5aed7..7db9a13fbb 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.13-rc.1 +kilo.jetbrains.version=7.0.13 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. From 656afef2840022496c4571d938d74dd2cc586ae6 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Wed, 5 Aug 2026 20:46:35 -0400 Subject: [PATCH 46/67] docs(jetbrains): edit changelog for v7.0.13 --- packages/kilo-jetbrains/CHANGELOG.md | 92 +++++----------------------- 1 file changed, 15 insertions(+), 77 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 780a4f616c..8f50db751a 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -128,90 +128,28 @@ ## [Unreleased] -## [7.0.13] - 2026-08-06 +## [7.0.13] - 2026-08-05 ### Added -- feat(ui): show line summaries for multi-file patches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12725 -- feat(tui): explain tool auto approval by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12728 -- feat(attachments): add remote CLI file delivery by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12747 -- feat(cli): adopt upstream reasoningVariants from v1.18.11 by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12800 -- feat(agent-manager): allow sessions to move their worktree between sections or ungroup by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12815 -- feat: add signal-to-noise controls to grep tool by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12811 -- feat(vscode): bind speech-to-text to Cmd/Ctrl+K with hold-to-send by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12798 -- feat(agent-manager): assign models to workflows by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12729 -- feat(vscode): restore multi-project section and drag-and-drop support by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12803 -- feat(charts/vscode): Added charting capabilities to kilo-ui storybook and VS Code extension by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12525 -- feat(tui): expand a collapsed paste on a second identical paste by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12816 -- feat(docs-sync): learn from maintainer corrections by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12823 -- feat(vscode): discover speech-to-text models by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12841 -- feat(agent-manager): align and persist collapsible headers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12844 -- feat(cli): resume Claude and Codex sessions by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12824 -- feat(agent-manager): support worktree slash commands by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12859 -- feat(cli): add privacy_mode for blurring PII in the TUI by @IamCoder18 in https://github.com/Kilo-Org/kilocode/pull/12442 -- feat(jetbrains): show filenames first in @file mentions by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12732 -- feat(jetbrains): visible CLI download/bundled mode + skill to pin/unpin/update by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12895 -- feat(vscode): Add telemetry for charting tool by @cosi-conda in https://github.com/Kilo-Org/kilocode/pull/12878 + +- Show the pinned Kilo Core version and whether JetBrains is using a downloaded or bundled CLI build. ### Fixed -- fix(cli): preserve configured subagent routing by @Hardik180704 in https://github.com/Kilo-Org/kilocode/pull/12652 -- fix(vscode): restore Markdown comment gutter anchors after renderer wrapper change by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12794 -- fix(cli): defer threshold compaction during tool loops by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12792 -- fix(cli): speed up local recall searches by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12726 -- fix(agent-manager): route mode shortcuts through modal by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12796 -- fix(agent-manager): make worktree hover cards instant by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12799 -- fix(cli): stop inline skill-shell doc examples from triggering permission prompts by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12802 -- fix(vscode): prevent prompt toolbar height growth when training indicator is visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12805 -- fix(cli): omit persona from generated names by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12790 -- fix(vscode): keep skill remove buttons visible at narrow widths by @rakshith1928 in https://github.com/Kilo-Org/kilocode/pull/12733 -- fix(vscode): recover Agent Manager terminals after exit by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12812 -- fix(vscode): persist Agent Manager focus per session by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12801 -- fix(vscode): optimize model selector search and auto-jump to active match by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12810 -- fix(agent-manager): restore focus to question options and prompt on session switch by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12795 -- fix(vscode): accelerate macOS speech capture by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12814 -- fix(docs-sync): address review findings on the learnings step by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12834 -- fix(vscode): remove duplicate sidebar border by @Drixled in https://github.com/Kilo-Org/kilocode/pull/12836 -- fix(cli): skip startup work for informational commands by @mjnaderi in https://github.com/Kilo-Org/kilocode/pull/12659 -- fix(vscode): keep final patch file visible by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12839 -- fix: make tool invalid-arguments errors clearly actionable to the model by @mvanhorn in https://github.com/Kilo-Org/kilocode/pull/11961 -- fix(vscode): add project-local navigation hints by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12845 -- fix(cli): allow explicit external markdown sources by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12846 -- fix(vscode): fix multi-project navigation shortcuts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12843 -- fix(vscode): preserve worktree rename focus by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12852 -- fix(vscode): remove selected project indicator by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12851 -- fix(pty): stabilize non-ASCII output round-trip test by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12848 -- fix(kilo-docs): close agents callout correctly by @thomasboom in https://github.com/Kilo-Org/kilocode/pull/12829 -- fix(agent-manager): restore multi-project progress indicators by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12850 -- fix(vscode): remove duplicate Agent Manager empty state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12857 -- fix(vscode): sync Agent Manager inspector width by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12858 -- fix(vscode): isolate project worktree row state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12860 -- fix(jetbrains): avoid CLI checksum API rate limits by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12861 -- fix(agent-manager): scope multi-project sessions by project by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12867 -- fix(vscode): defer unused worktree watchers by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12865 -- fix(jetbrains): add dropped files as references by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12869 -- fix(vscode): move prompt rail away from session content by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12883 -- fix(cli): handle SQLite lock errors by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12884 -- fix(vscode): prevent Agent Manager overview timeouts by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12885 -- fix(vscode): start Agent Manager terminals instantly by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12866 -- fix(cli): stop eager file watchers on JetBrains by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12897 -- fix(jetbrains): improve session diff rendering by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12862 + +- Avoid GitHub checksum API rate limits when JetBrains verifies downloaded Kilo Core CLI assets. +- Add dropped files as JetBrains file references so attachments are available to Kilo reliably. +- Stop eager Kilo Core file watchers when running from JetBrains to reduce unnecessary background work. +- Improve JetBrains session diff rendering, including full-file editor diffs, multi-hunk diffs, fallback handling, gutter line numbers, and session-scoped diff paths. +- Speed up local recall searches in Kilo Core. +- Omit persona details from generated session names. +- Make invalid tool-argument errors clearer and more actionable to the model. +- Handle SQLite lock errors more gracefully. ### Changed -- release(jetbrains): v7.0.12 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12766 -- perf(vscode): parallelize build validation and cache SDK generation by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12807 -- chore: remove accidental PR screenshot by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12820 -- docs: auto-sync with merged PRs (through 2026-08-01) by @github-actions[bot] in https://github.com/Kilo-Org/kilocode/pull/12716 -- revert(cli): stop promoting stable releases to rc by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12827 -- perf(vscode): defer Agent Manager terminal addons by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12842 -- revert(cli): restore stable grep behavior by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12847 -- test(cli): isolate PTY route tests from indexing by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12818 -- chore(jetbrains): bump CLI pin to v7.4.20 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12853 -- docs(vscode): add icon authoring skill by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12872 -- Opencode Merge v1.17.10-v1.17.13 by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12695 -- docs(kilo-docs): document upstream v1.17.13 behavior by @johnnyeric in https://github.com/Kilo-Org/kilocode/pull/12900 -- Revert "feat(jetbrains): show filenames first in @file mentions" by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12903 -- docs: consolidate Auto Balanced into Auto Efficient by @kilo-code-bot[bot] in https://github.com/Kilo-Org/kilocode/pull/12892 -- release(jetbrains): v7.0.13-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12919 +- Bump the JetBrains CLI pin to Kilo CLI v7.4.20. +- Include upstream OpenCode updates through v1.17.13. +- Adopt upstream reasoning variant metadata from OpenCode v1.18.11. ## [7.0.13-rc.1] - 2026-08-05 From b5218296422e015b69d8019a1cb7fb864b0855e3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:31:07 +0200 Subject: [PATCH 47/67] fix(vscode): reduce Agent Manager Git polling load --- .changeset/calm-agent-manager-git-polling.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 2 - .../src/agent-manager/AgentManagerProvider.ts | 30 +- .../src/agent-manager/GitStatsPoller.ts | 247 ++++++--- .../src/agent-manager/git-stats-snapshot.ts | 222 ++++++++ .../src/agent-manager/project/pollers.ts | 22 +- .../tests/unit/git-stats-poller.test.ts | 208 +++++++- .../tests/unit/git-stats-snapshot.test.ts | 85 +++ .../agent-manager-git-poller-optimization.md | 485 ++++++++++++++++++ plans/agent-manager-git-poller-remaining.md | 317 ++++++++++++ 10 files changed, 1521 insertions(+), 102 deletions(-) create mode 100644 .changeset/calm-agent-manager-git-polling.md create mode 100644 packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts create mode 100644 packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts create mode 100644 plans/agent-manager-git-poller-optimization.md create mode 100644 plans/agent-manager-git-poller-remaining.md diff --git a/.changeset/calm-agent-manager-git-polling.md b/.changeset/calm-agent-manager-git-polling.md new file mode 100644 index 0000000000..5f3314267a --- /dev/null +++ b/.changeset/calm-agent-manager-git-polling.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce Agent Manager background Git polling load across large worktree sets. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index c04d818b4b..035b090ad6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -49,7 +49,6 @@ import { } from "./kilo-provider-utils" import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" -import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item" import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller" import type { RemoteStatusService } from "./services/RemoteStatusService" @@ -4929,7 +4928,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id), - localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, onLocalStats: (stats: LocalStats) => { diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 069783c53f..6844149ec3 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -111,6 +111,7 @@ export class AgentManagerProvider implements Disposable { private onVisibilityChange: ((visible: boolean) => void) | undefined // Tracks sessions owned by this panel until they are explicitly closed. private panelSessions = new Set() + private busySessions = new Set() /** Session ID most recently loaded via `loadMessages`; updated synchronously. */ private activeSessionId: string | undefined @@ -219,6 +220,23 @@ export class AgentManagerProvider implements Disposable { state: () => this.state, root: () => this.getRoot(), activeId: () => this.contexts.active()?.id, + hot: () => { + const ids = new Set() + const target = this.state?.getActiveTarget() + if (target?.kind === "worktree") ids.add(target.worktreeId) + if (target?.kind === "session") { + const id = this.state?.getSession(target.sessionId)?.worktreeId + if (id) ids.add(id) + } + for (const status of this.run.state().runStatuses) { + if (status.state === "running" || status.state === "stopping") ids.add(status.worktreeId) + } + for (const sid of this.busySessions) { + const id = this.state?.getSession(sid)?.worktreeId + if (id) ids.add(id) + } + return ids + }, visible: () => this.panel?.visible ?? false, post: (msg) => this.postToWebview(msg), cache: (msg) => { @@ -277,6 +295,7 @@ export class AgentManagerProvider implements Disposable { if (ev.type === "session.deleted") { const id = ev.properties?.sessionID if (!id) return + this.busySessions.delete(id) const ctx = this.contexts.byLiveSession(id) if (!ctx) return ctx.removeLiveSession(id) @@ -307,8 +326,13 @@ export class AgentManagerProvider implements Disposable { const sid = props?.sessionID const type = props?.status?.type if (!sid || !type) return - if (type === "idle") this.naming.idle(sid) - else this.naming.busy(sid) + if (type === "idle") { + this.busySessions.delete(sid) + this.naming.idle(sid) + return + } + this.busySessions.add(sid) + this.naming.busy(sid) } private log(...args: unknown[]) { @@ -393,6 +417,7 @@ export class AgentManagerProvider implements Disposable { const ids = [...this.panelSessions] if (this.activeSessionId) ids.push(this.activeSessionId) this.panelSessions.clear() + this.busySessions.clear() void ctx.sessions.abortSessions(ids).catch((err) => this.log("Failed to abort sessions on panel close:", err)) this.statsPoller.stop() this.projectPollers.dispose() @@ -1555,6 +1580,7 @@ export class AgentManagerProvider implements Disposable { this.statsPoller.stop() this.prBridge.reset() this.activeSessionId = undefined + this.busySessions.clear() this.cachedWorktreeStats = this.cachedLocalStats = undefined void this.sendRepoInfo() if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c))) diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 9b5fd45ef5..11e513f32c 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -4,7 +4,14 @@ import { remoteRef, type Worktree } from "./WorktreeStateManager" import type { GitOps } from "./GitOps" import type { Semaphore } from "./semaphore" import { findTrackedBranch } from "./project/paths" -import type { WorktreeDiffEntry } from "./types" +import { + GitStatsSnapshot, + refOID, + shortRef, + type DiffStats, + type RefSnapshot, + type GitStatsSource, +} from "./git-stats-snapshot" export interface WorktreeStats { worktreeId: string @@ -39,12 +46,9 @@ export interface WorktreePresenceResult { interface GitStatsPollerOptions { getWorktrees: () => Worktree[] getWorkspaceRoot: () => string | undefined - /** - * Compute diff summaries locally (in the extension host) rather than over - * HTTP to `kilo serve`. Keeps git spawning out of the Bun process, which - * leaks native memory on Windows (oven-sh/bun#18265). - */ - localDiff: (dir: string, base: string) => Promise + getHotWorktreeIds?: () => Set + /** Override the real Git source in scheduler and failure-path tests. */ + source?: GitStatsSource git: GitOps onStats: (stats: WorktreeStats[]) => void onLocalStats: (stats: LocalStats) => void @@ -54,6 +58,7 @@ interface GitStatsPollerOptions { /** Shared concurrency gate for child process spawning. */ semaphore?: Semaphore hiddenIntervalMs?: number + dormantIntervalMs?: number } export class GitStatsPoller { @@ -66,15 +71,22 @@ export class GitStatsPoller { private lastStats: Record = {} private readonly intervalMs: number private readonly hiddenIntervalMs: number + private readonly dormantIntervalMs: number private readonly git: GitOps + private readonly snapshots: GitStatsSource + private readonly cache = new Map() + private localCache: CachedStats | undefined private skipWorktreeIds = new Set() private visible = true private generation = 0 + private cursor = 0 constructor(private readonly options: GitStatsPollerOptions) { this.intervalMs = options.intervalMs ?? 5000 this.hiddenIntervalMs = options.hiddenIntervalMs ?? 60000 + this.dormantIntervalMs = options.dormantIntervalMs ?? 30000 this.git = options.git + this.snapshots = options.source ?? new GitStatsSnapshot(options.git) } setVisible(visible: boolean): void { @@ -129,12 +141,19 @@ export class GitStatsPoller { this.lastLocalHash = undefined this.lastLocalStats = undefined this.lastStats = {} + this.cache.clear() + this.localCache = undefined + this.cursor = 0 } async snapshot(refresh = false): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> { if (refresh && !this.busy) { this.busy = true - await Promise.all([this.fetchWorktreeStats(true), this.fetchLocalStats()]).finally(() => { + const refs = await this.fetchRefs() + await Promise.all([ + this.fetchWorktreeStats(true, this.generation, refs), + this.fetchLocalStats(this.generation, refs, true), + ]).finally(() => { this.busy = false }) } @@ -170,10 +189,15 @@ export class GitStatsPoller { } private async fetch(generation = this.generation): Promise { - await Promise.all([this.fetchWorktreeStats(false, generation), this.fetchLocalStats(generation)]) + const refs = await this.fetchRefs() + await Promise.all([this.fetchWorktreeStats(false, generation, refs), this.fetchLocalStats(generation, refs)]) } - private async fetchWorktreeStats(includeSkipped = false, generation = this.generation): Promise { + private async fetchWorktreeStats( + includeSkipped = false, + generation = this.generation, + refs?: RefSnapshot, + ): Promise { const worktrees = this.options.getWorktrees() if (worktrees.length === 0) return @@ -187,9 +211,13 @@ export class GitStatsPoller { const available = worktrees.filter((wt) => !missing.has(wt.id)) const ids = new Set(available.map((wt) => wt.id)) for (const id of Object.keys(this.lastStats)) { - if (!ids.has(id)) delete this.lastStats[id] + if (!ids.has(id)) { + delete this.lastStats[id] + this.cache.delete(id) + } } - const active = includeSkipped ? available : available.filter((wt) => !this.skipWorktreeIds.has(wt.id)) + const candidates = includeSkipped ? available : available.filter((wt) => !this.skipWorktreeIds.has(wt.id)) + const active = includeSkipped ? candidates : this.select(candidates) if (active.length === 0) { if (available.length > 0) return if (this.lastHash === "") return @@ -199,29 +227,7 @@ export class GitStatsPoller { return } - // localDiff runs in-process via GitOps.execGit() which already acquires - // the shared semaphore internally; same goes for aheadBehind via - // GitOps.raw(). Wrapping either again here would deadlock. - const stats = ( - await Promise.all( - active.map(async (wt) => { - try { - const base = remoteRef(wt) - const [diffs, ab] = await Promise.all([ - this.options.localDiff(wt.path, base), - this.git.aheadBehind(wt.path, base), - ]) - const files = diffs.length - const additions = diffs.reduce((sum, diff) => sum + diff.additions, 0) - const deletions = diffs.reduce((sum, diff) => sum + diff.deletions, 0) - return { worktreeId: wt.id, files, additions, deletions, ahead: ab.ahead, behind: ab.behind } - } catch (err) { - this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err) - return this.lastStats[wt.id] - } - }), - ) - ).filter((item): item is WorktreeStats => !!item) + const stats = await this.fetchOptimized(active, refs, includeSkipped) if (generation !== this.generation) return for (const item of stats) this.lastStats[item.worktreeId] = item @@ -235,6 +241,70 @@ export class GitStatsPoller { this.options.onStats(visible) } + private async fetchOptimized(worktrees: Worktree[], refs: RefSnapshot | undefined, refresh: boolean) { + const rows = await Promise.all( + worktrees.map(async (wt) => { + const base = remoteRef(wt) + const baseOID = refOID(refs, base) + try { + const status = await this.snapshots.status(wt.path) + const cached = this.cache.get(wt.id) + const same = + !refresh && + !!baseOID && + cached?.base === base && + cached.baseOID === baseOID && + cached.fingerprint === status.fingerprint + const diff = same ? cached.diff : await this.snapshots.diff(wt.path, base, status.untracked) + const ahead = + !refresh && baseOID && cached?.head === status.head && cached.baseOID === baseOID + ? cached.ahead + : await this.git.aheadBehind(wt.path, base) + return { wt, base, baseOID, status, diff, ahead } + } catch (err) { + this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err) + return { wt, prior: this.lastStats[wt.id] } + } + }), + ) + return rows + .map((row) => { + if ("prior" in row) return row.prior + const stats = { worktreeId: row.wt.id, ...row.diff, ...row.ahead } + this.cache.set(row.wt.id, { + base: row.base, + baseOID: row.baseOID, + head: row.status.head, + fingerprint: row.status.fingerprint, + diff: row.diff, + dirty: row.status.dirty, + clean: row.status.dirty ? 0 : (this.cache.get(row.wt.id)?.clean ?? 0) + 1, + ahead: row.ahead, + }) + return stats + }) + .filter((item): item is WorktreeStats => !!item) + } + + private select(worktrees: Worktree[]): Worktree[] { + if (!this.visible || worktrees.length === 0) return worktrees + const hot = this.options.getHotWorktreeIds?.() ?? new Set() + const selected = worktrees.filter((wt) => { + const cached = this.cache.get(wt.id) + return hot.has(wt.id) || !cached || cached.dirty || cached.clean < 2 + }) + const ids = new Set(selected.map((wt) => wt.id)) + const dormant = worktrees.filter((wt) => !ids.has(wt.id)) + if (dormant.length === 0) return selected + const ticks = Math.max(1, Math.ceil(this.dormantIntervalMs / this.intervalMs)) + const count = Math.max(1, Math.ceil(dormant.length / ticks)) + for (let i = 0; i < count; i++) { + selected.push(dormant[(this.cursor + i) % dormant.length]!) + } + this.cursor = (this.cursor + count) % dormant.length + return selected + } + private hash(stats: WorktreeStats[]): string { return stats .map( @@ -273,60 +343,95 @@ export class GitStatsPoller { return { worktrees: worktreeStatuses, degraded: false } } - private async fetchLocalStats(generation = this.generation): Promise { + private async fetchLocalStats(generation = this.generation, refs?: RefSnapshot, refresh = false): Promise { const root = this.options.getWorkspaceRoot() if (!root) return try { - const branch = await this.git.currentBranch(root) + const status = await this.snapshots.status(root) + const branch = status.branch if (!branch || branch === "HEAD") return - - const tracking = await this.git.resolveTrackingBranch(root, branch) - const base = tracking ?? (await this.git.resolveDefaultBranch(root, branch)) - - let files: number - let additions: number - let deletions: number - let ahead: number - let behind: number - try { - if (base) { - this.options.log(`Local stats: using localDiff with base=${base}`) - const [diffs, ab] = await Promise.all([this.options.localDiff(root, base), this.git.aheadBehind(root, base)]) - files = diffs.length - additions = diffs.reduce((sum, d) => sum + d.additions, 0) - deletions = diffs.reduce((sum, d) => sum + d.deletions, 0) - ahead = ab.ahead - behind = ab.behind - } else { - this.options.log(`Local stats: fallback to workingTreeStats (no base branch)`) - const wt = await this.git.workingTreeStats(root) - files = wt.files - additions = wt.additions - deletions = wt.deletions - ahead = 0 - behind = 0 - } - } catch (err) { + const stats = await this.local(root, branch, status, refs, refresh).catch((err) => { this.options.log("Failed to fetch local diff stats:", err) - if (this.lastLocalStats && this.lastLocalStats.branch === branch) return - return - } + return undefined + }) + if (!stats) return if (generation !== this.generation) return - const hash = `local:${branch}:${files}:${additions}:${deletions}:${ahead}:${behind}` + const hash = `local:${branch}:${stats.files}:${stats.additions}:${stats.deletions}:${stats.ahead}:${stats.behind}` if (hash === this.lastLocalHash) { this.options.log(`Local stats: unchanged (${hash})`) return } this.lastLocalHash = hash - this.options.log(`Local stats: emitting files=${files} +${additions} -${deletions} ↑${ahead} ↓${behind}`) - const stats: LocalStats = { branch, files, additions, deletions, ahead, behind } + this.options.log( + `Local stats: emitting files=${stats.files} +${stats.additions} -${stats.deletions} ↑${stats.ahead} ↓${stats.behind}`, + ) this.lastLocalStats = stats this.options.onLocalStats(stats) } catch (err) { this.options.log("Failed to fetch local stats:", err) } } + + private async local( + root: string, + branch: string, + status: Awaited>, + refs: RefSnapshot | undefined, + refresh: boolean, + ): Promise { + const trackingRef = refs?.upstreams.get(`refs/heads/${branch}`) + const tracking = trackingRef ? shortRef(trackingRef) : await this.git.resolveTrackingBranch(root, branch) + const base = tracking ?? (await this.git.resolveDefaultBranch(root, branch)) + if (!base) { + const stats = await this.git.workingTreeStats(root) + return { branch, ...stats, ahead: 0, behind: 0 } + } + const baseOID = refOID(refs, base) + const cached = this.localCache + const same = + !refresh && + !!baseOID && + cached?.base === base && + cached.baseOID === baseOID && + cached.fingerprint === status.fingerprint + const diff = same ? cached.diff : await this.snapshots.diff(root, base, status.untracked) + const ahead = + !refresh && baseOID && cached?.head === status.head && cached.baseOID === baseOID + ? cached.ahead + : await this.git.aheadBehind(root, base) + this.localCache = { + base, + baseOID, + head: status.head, + fingerprint: status.fingerprint, + diff, + dirty: status.dirty, + clean: status.dirty ? 0 : (cached?.clean ?? 0) + 1, + ahead, + } + return { branch, ...diff, ...ahead } + } + + private async fetchRefs(): Promise { + const root = this.options.getWorkspaceRoot() + if (!root) return undefined + return this.snapshots.refs(root).catch((err) => { + this.options.log("Failed to read project refs:", err) + return undefined + }) + } +} + +interface CachedStats { + base: string + baseOID?: string + head: string + fingerprint: string + diff: DiffStats + dirty: boolean + clean: number + ahead: { ahead: number; behind: number } } diff --git a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts new file mode 100644 index 0000000000..d9ef5ae034 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts @@ -0,0 +1,222 @@ +import { createHash } from "crypto" +import * as fs from "fs/promises" +import { binaryFile } from "../diff/shared/binary" +import { resolveInside } from "../diff/shared/path" +import type { GitOps } from "./GitOps" + +const MAX_BYTES = 1_000_000 + +export interface DiffStats { + files: number + additions: number + deletions: number +} + +export interface StatusSnapshot { + branch: string + dirty: boolean + head: string + fingerprint: string + untracked: string[] +} + +export interface RefSnapshot { + oids: Map + upstreams: Map +} + +export interface GitStatsSource { + status(dir: string): Promise + refs(root: string): Promise + diff(dir: string, base: string, untracked: string[]): Promise +} + +interface PathState { + file: string + missing: boolean +} + +function tail(record: string, fields: number): string | undefined { + let offset = 0 + for (let i = 0; i < fields; i++) { + const next = record.indexOf(" ", offset) + if (next === -1) return undefined + offset = next + 1 + } + return record.slice(offset) +} + +function records(raw: Buffer): { branch: string; head: string; paths: PathState[]; untracked: string[] } { + const items = raw.toString("utf8").split("\0") + const paths: PathState[] = [] + const untracked: string[] = [] + let branch = "" + let head = "" + + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (!item) continue + if (item.startsWith("# branch.oid ")) { + head = item.slice(13) + continue + } + if (item.startsWith("# branch.head ")) { + const value = item.slice(14) + branch = value === "(detached)" ? "HEAD" : value + continue + } + if (item.startsWith("? ")) { + const file = item.slice(2) + untracked.push(file) + paths.push({ file, missing: false }) + continue + } + if (item.startsWith("1 ")) { + const file = tail(item, 8) + if (file) paths.push({ file, missing: item.slice(2, 4).includes("D") }) + continue + } + if (item.startsWith("2 ")) { + const file = tail(item, 9) + if (file) paths.push({ file, missing: false }) + i++ + continue + } + if (item.startsWith("u ")) { + const file = tail(item, 10) + if (file) paths.push({ file, missing: false }) + } + } + + return { branch, head, paths, untracked } +} + +async function fingerprint(dir: string, raw: Buffer, paths: PathState[]): Promise { + const hash = createHash("sha256").update(raw) + const unique = new Map(paths.map((item) => [item.file, item])) + const files = [...unique.values()].sort((a, b) => a.file.localeCompare(b.file)) + + for (const item of files) { + const full = resolveInside(dir, item.file) + if (!full) return undefined + const stat = await fs.lstat(full, { bigint: true }).catch(() => undefined) + if (!stat) { + if (!item.missing) return undefined + hash.update(`\0${item.file}\0missing`) + continue + } + hash.update(`\0${item.file}\0${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`) + } + return hash.digest("hex") +} + +function numstat(raw: Buffer): DiffStats { + const result = { files: 0, additions: 0, deletions: 0 } + for (const item of raw.toString("utf8").split("\0")) { + if (!item) continue + const first = item.indexOf("\t") + const second = item.indexOf("\t", first + 1) + if (first === -1 || second === -1) continue + result.files++ + const additions = item.slice(0, first) + const deletions = item.slice(first + 1, second) + if (additions !== "-") result.additions += parseInt(additions, 10) || 0 + if (deletions !== "-") result.deletions += parseInt(deletions, 10) || 0 + } + return result +} + +async function lines(file: string): Promise { + const stat = await fs.lstat(file).catch(() => undefined) + if (!stat || stat.size === 0 || stat.size > MAX_BYTES) return 0 + if (await binaryFile(file)) return 0 + const content = stat.isSymbolicLink() + ? await fs.readlink(file).catch(() => "") + : await fs.readFile(file, "utf8").catch(() => "") + if (!content) return 0 + return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length +} + +export function refOID(refs: RefSnapshot | undefined, ref: string): string | undefined { + if (!refs) return undefined + if (ref.startsWith("refs/")) return refs.oids.get(ref) + return refs.oids.get(`refs/remotes/${ref}`) ?? refs.oids.get(`refs/heads/${ref}`) ?? refs.oids.get(ref) +} + +export function shortRef(ref: string): string { + if (ref.startsWith("refs/remotes/")) return ref.slice(13) + if (ref.startsWith("refs/heads/")) return ref.slice(11) + return ref +} + +export class GitStatsSnapshot implements GitStatsSource { + constructor(private readonly git: GitOps) {} + + async status(dir: string): Promise { + const result = await this.git.execGitBuffer( + [ + "--no-optional-locks", + "status", + "--porcelain=v2", + "--branch", + "-z", + "--no-ahead-behind", + "--untracked-files=all", + "--no-renames", + ], + dir, + ) + if (result.code !== 0) throw new Error(result.stderr.trim() || "git status failed") + const parsed = records(result.stdout) + if (!parsed.head || !parsed.branch) throw new Error("git status returned incomplete branch data") + const stamp = await fingerprint(dir, result.stdout, parsed.paths) + if (!stamp) throw new Error("worktree changed while status was being sampled") + return { + branch: parsed.branch, + dirty: parsed.paths.length > 0, + head: parsed.head, + fingerprint: stamp, + untracked: parsed.untracked, + } + } + + async refs(root: string): Promise { + const result = await this.git.execGitBuffer( + ["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)%00", "refs/heads", "refs/remotes"], + root, + ) + if (result.code !== 0) throw new Error(result.stderr.trim() || "git for-each-ref failed") + const oids = new Map() + const upstreams = new Map() + for (const line of result.stdout.toString("utf8").split("\n")) { + if (!line) continue + const [ref, oid, upstream] = line.split("\0") + if (!ref || !oid) continue + oids.set(ref, oid) + if (upstream) upstreams.set(ref, upstream) + } + return { oids, upstreams } + } + + async diff(dir: string, base: string, untracked: string[]): Promise { + const ancestor = await this.git.execGit(["merge-base", "HEAD", base], dir) + if (ancestor.code !== 0) throw new Error(ancestor.stderr.trim() || "git merge-base failed") + const result = await this.git.execGitBuffer( + ["-c", "core.quotepath=false", "diff", "--numstat", "-z", "--no-renames", ancestor.stdout.trim()], + dir, + ) + if (result.code !== 0) throw new Error(result.stderr.trim() || "git diff failed") + const stats = numstat(result.stdout) + const counts = await Promise.all( + untracked.map(async (file) => { + const full = resolveInside(dir, file) + return full ? lines(full) : 0 + }), + ) + return { + files: stats.files + untracked.length, + additions: stats.additions + counts.reduce((sum, count) => sum + count, 0), + deletions: stats.deletions, + } + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/pollers.ts b/packages/kilo-vscode/src/agent-manager/project/pollers.ts index 04bc8f023b..8a9ec9b3ca 100644 --- a/packages/kilo-vscode/src/agent-manager/project/pollers.ts +++ b/packages/kilo-vscode/src/agent-manager/project/pollers.ts @@ -13,13 +13,12 @@ import type { GitOps } from "../GitOps" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "../GitStatsPoller" -import { diffSummary } from "../local-diff" import { PRStatusBridge } from "../pr-status-bridge" import type { PRStatus } from "../types" import type { ProjectContext } from "./context" import type { ProjectContexts } from "./contexts" import type { Semaphore } from "../semaphore" -import type { AgentManagerOutMessage, WorktreeDiffEntry } from "../types" +import type { AgentManagerOutMessage } from "../types" import type { WorktreeStateManager } from "../WorktreeStateManager" export interface PollerPair { @@ -37,20 +36,30 @@ type StatsMessage = Extract Promise post: (msg: StatsOutMessage) => void openExternal: (url: string) => void visible: () => boolean log: (...args: unknown[]) => void } +function hot(state: WorktreeStateManager | undefined): Set { + const result = new Set() + const target = state?.getActiveTarget() + if (target?.kind === "worktree") result.add(target.worktreeId) + if (target?.kind === "session") { + const id = state?.getSession(target.sessionId)?.worktreeId + if (id) result.add(id) + } + return result +} + /** Create the real poller pair for one project context. */ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair { const state = () => ctx.peekState() const stats = new GitStatsPoller({ getWorktrees: () => state()?.getWorktrees() ?? [], getWorkspaceRoot: () => ctx.root, - localDiff: deps.localDiff, + getHotWorktreeIds: () => hot(state()), git: deps.git, semaphore: deps.semaphore, log: deps.log, @@ -135,12 +144,12 @@ export function createPollers(opts: { presence: (result: WorktreePresenceResult) => void openExternal: (url: string) => void log: (...args: unknown[]) => void + hot?: () => Set }): { stats: GitStatsPoller; pr: PRStatusBridge; projects: ProjectPollers } { - const localDiff = (dir: string, base: string) => diffSummary(opts.git, dir, base, opts.log) const stats = new GitStatsPoller({ getWorktrees: () => opts.state()?.getWorktrees() ?? [], getWorkspaceRoot: opts.root, - localDiff, + getHotWorktreeIds: opts.hot ?? (() => hot(opts.state())), semaphore: opts.semaphore, onStats: (stats) => { const msg = { type: "agentManager.worktreeStats" as const, projectId: opts.activeId(), stats } @@ -169,7 +178,6 @@ export function createPollers(opts: { const projects = new ProjectPollers({ git: opts.git, semaphore: opts.semaphore, - localDiff, post: opts.post, openExternal: opts.openExternal, visible: opts.visible, diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index c62663414f..cf161d0315 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -3,7 +3,8 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller" -import { GitOps } from "../../src/agent-manager/GitOps" +import { GitOps, type ExecBufferResult } from "../../src/agent-manager/GitOps" +import type { GitStatsSource } from "../../src/agent-manager/git-stats-snapshot" import { Semaphore } from "../../src/agent-manager/semaphore" import type { Worktree } from "../../src/agent-manager/WorktreeStateManager" import type { WorktreeDiffEntry } from "../../src/agent-manager/types" @@ -53,6 +54,47 @@ function gitOps(handler: (args: string[], cwd: string) => Promise): GitO return new GitOps({ log: () => undefined, runGit: handler }) } +function source( + localDiff: (dir: string, base: string) => Promise, + branch = "HEAD", +): GitStatsSource { + let sequence = 0 + return { + status: async () => { + const fingerprint = String(++sequence) + return { branch, dirty: true, head: fingerprint, fingerprint, untracked: [] } + }, + refs: async () => ({ oids: new Map(), upstreams: new Map() }), + diff: async (dir, base) => { + const entries = await localDiff(dir, base) + return { + files: entries.length, + additions: entries.reduce((sum, item) => sum + item.additions, 0), + deletions: entries.reduce((sum, item) => sum + item.deletions, 0), + } + }, + } +} + +class RecordingGitOps extends GitOps { + readonly commands: Array<{ args: string[]; cwd: string }> = [] + aheadCalls = 0 + + constructor() { + super({ log: () => undefined }) + } + + override execGitBuffer(args: string[], cwd: string): Promise { + this.commands.push({ args, cwd }) + return super.execGitBuffer(args, cwd) + } + + override aheadBehind(cwd: string, base: string): Promise<{ ahead: number; behind: number }> { + this.aheadCalls++ + return super.aheadBehind(cwd, base) + } +} + describe("GitOps", () => { it("resolveDefaultBranch returns undefined on cache hit when there is no remote HEAD", async () => { let calls = 0 @@ -83,6 +125,132 @@ describe("GitOps", () => { }) describe("GitStatsPoller", () => { + it("uses only status and shared snapshots on an unchanged second poll", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-optimized-")) + try { + const run = (args: string[]) => { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd: root, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8")) + } + run(["init", "-b", "main"]) + await fs.promises.writeFile(path.join(root, "file.txt"), "one\n") + run(["add", "."]) + run(["commit", "-m", "base"]) + run(["remote", "add", "origin", "."]) + run(["update-ref", "refs/remotes/origin/main", "HEAD"]) + run(["branch", "--set-upstream-to=origin/main", "main"]) + + const git = new RecordingGitOps() + const poller = new GitStatsPoller({ + getWorktrees: () => [], + getWorkspaceRoot: () => root, + onStats: () => undefined, + onLocalStats: () => undefined, + log: () => undefined, + intervalMs: 10, + git, + }) + + poller.setEnabled(true) + await waitFor(() => git.commands.filter((item) => item.args.includes("--porcelain=v2")).length >= 2, 2_000) + const statuses = git.commands + .map((item, index) => ({ ...item, index })) + .filter((item) => item.args.includes("--porcelain=v2")) + const second = git.commands.slice(statuses[1]!.index - 1) + expect(second.filter((item) => item.args.includes("diff"))).toHaveLength(0) + expect(git.aheadCalls).toBe(1) + + const diffs = git.commands.filter((item) => item.args.includes("diff")).length + await fs.promises.writeFile(path.join(root, "file.txt"), "changed and larger\n") + await waitFor(() => git.commands.filter((item) => item.args.includes("diff")).length > diffs, 2_000) + + const ahead = git.aheadCalls + poller.stop() + await poller.snapshot(true) + expect(git.aheadCalls).toBe(ahead + 1) + } finally { + await fs.promises.rm(root, { recursive: true, force: true }) + } + }) + + it("keeps hot worktrees on every tick and rotates clean dormant worktrees", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-shard-")) + const dirs = ["a", "b", "c"].map((id) => path.join(root, id)) + try { + const run = (cwd: string, args: string[]) => { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8")) + } + await fs.promises.mkdir(dirs[0]!) + run(dirs[0]!, ["init", "-b", "main"]) + await fs.promises.writeFile(path.join(dirs[0]!, "file.txt"), "one\n") + run(dirs[0]!, ["add", "."]) + run(dirs[0]!, ["commit", "-m", "base"]) + run(dirs[0]!, ["remote", "add", "origin", "."]) + run(dirs[0]!, ["update-ref", "refs/remotes/origin/main", "HEAD"]) + run(dirs[0]!, ["branch", "--set-upstream-to=origin/main", "main"]) + run(dirs[0]!, ["worktree", "add", "-b", "branch-b", dirs[1]!, "main"]) + run(dirs[0]!, ["worktree", "add", "-b", "branch-c", dirs[2]!, "main"]) + + const git = new RecordingGitOps() + const hot = new Set(["a"]) + const poller = new GitStatsPoller({ + getWorktrees: () => + dirs.map((dir, index) => ({ + ...worktree(String.fromCharCode(97 + index)), + path: dir, + branch: index === 0 ? "main" : `branch-${String.fromCharCode(97 + index)}`, + })), + getWorkspaceRoot: () => dirs[0], + getHotWorktreeIds: () => hot, + onStats: () => undefined, + onLocalStats: () => undefined, + log: () => undefined, + intervalMs: 10, + dormantIntervalMs: 30, + git, + }) + + poller.setEnabled(true) + await waitFor(() => git.commands.filter((item) => item.args.includes("--porcelain=v2")).length >= 15, 3_000) + poller.stop() + const counts = new Map() + for (const item of git.commands) { + if (!item.args.includes("--porcelain=v2")) continue + counts.set(item.cwd, (counts.get(item.cwd) ?? 0) + 1) + } + expect(counts.get(dirs[0]!)).toBeGreaterThan(counts.get(dirs[1]!) ?? 0) + expect(counts.get(dirs[1]!)).toBeGreaterThan(2) + expect(counts.get(dirs[2]!)).toBeGreaterThan(2) + } finally { + await fs.promises.rm(root, { recursive: true, force: true }) + } + }) + it("keeps mutual exclusion when a stale fetch finishes after a restart", async () => { let calls = 0 let running = 0 @@ -102,7 +270,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/tmp", - localDiff, + source: source(localDiff, "main"), onStats: () => undefined, onLocalStats: () => undefined, log: () => undefined, @@ -148,7 +316,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - localDiff, + source: source(localDiff), onStats: () => undefined, onLocalStats: () => undefined, log: () => undefined, @@ -181,7 +349,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - localDiff, + source: source(localDiff), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -217,9 +385,9 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - localDiff: async () => { + source: source(async () => { throw new Error("should not be called when backend unavailable path") - }, + }), onStats: () => undefined, onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -254,7 +422,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - localDiff: async () => diff(0, 0), + source: source(async () => diff(0, 0)), onStats: () => undefined, onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -292,10 +460,10 @@ describe("GitStatsPoller", () => { { ...worktree("b"), path: wtBPath }, ], getWorkspaceRoot: () => root, - localDiff: async (dir) => { + source: source(async (dir) => { calls.push(dir) return diff(1, 1) - }, + }), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -345,10 +513,10 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: alias }], getWorkspaceRoot: () => root, - localDiff: async (dir) => { + source: source(async (dir) => { calls.push(dir) return diff(3, 2) - }, + }), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -398,7 +566,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - localDiff, + source: source(localDiff, "feature"), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -435,7 +603,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - localDiff: async () => diff(10, 4), + source: source(async () => diff(10, 4), "my-feature"), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -479,7 +647,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - localDiff: async () => diff(0, 0), + source: source(async () => diff(0, 0), "orphan-branch"), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -522,7 +690,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a", "upstream"), worktree("b", "upstream")], getWorkspaceRoot: () => undefined, - localDiff: async () => diff(0, 0), + source: source(async () => diff(0, 0)), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -543,8 +711,8 @@ describe("GitStatsPoller", () => { }) it("runs diffs in parallel without stalling (no extra semaphore layer)", async () => { - // localDiff is a synchronous promise — since the poller no longer wraps - // it in a semaphore (GitOps.execGit() gates at the child-process layer), + // The injected diff source is a synchronous promise. The poller does not + // wrap it in a semaphore because GitOps gates at the child-process layer, // many worktrees can have their diffs computed concurrently without // contending for a dedicated outer gate. let running = 0 @@ -555,13 +723,13 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => wts, getWorkspaceRoot: () => undefined, - localDiff: async () => { + source: source(async () => { running++ peak = Math.max(peak, running) await sleep(20) running-- return diff(1, 0) - }, + }), onStats: () => { ticks++ }, @@ -592,7 +760,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => wts, getWorkspaceRoot: () => undefined, - localDiff: async () => diff(1, 0), + source: source(async () => diff(1, 0)), onStats: () => { ticks++ }, diff --git a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts new file mode 100644 index 0000000000..9d68d512cf --- /dev/null +++ b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { GitOps } from "../../src/agent-manager/GitOps" +import { GitStatsSnapshot, refOID } from "../../src/agent-manager/git-stats-snapshot" +import { diffSummary } from "../../src/agent-manager/local-diff" + +function run(dir: string, args: string[]): string { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd: dir, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8")) + return Buffer.from(result.stdout).toString("utf8").trim() +} + +async function repo(test: (dir: string, base: string) => Promise) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "git-stats-snapshot-")) + try { + run(dir, ["init", "-b", "main"]) + run(dir, ["config", "commit.gpgsign", "false"]) + await fs.writeFile(path.join(dir, "tracked.txt"), "one\ntwo\n") + run(dir, ["add", "."]) + run(dir, ["commit", "-m", "base"]) + run(dir, ["branch", "base"]) + await test(dir, "base") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +describe("GitStatsSnapshot", () => { + it("matches legacy aggregate stats with tracked and untracked changes", async () => { + await repo(async (dir, base) => { + await fs.writeFile(path.join(dir, "tracked.txt"), "one\nchanged\nthree\n") + await fs.writeFile(path.join(dir, "new.txt"), "a\nb\nc\n") + const git = new GitOps({ log: () => undefined }) + const snapshots = new GitStatsSnapshot(git) + + const status = await snapshots.status(dir) + const actual = await snapshots.diff(dir, base, status.untracked) + const legacy = await diffSummary(git, dir, base) + + expect(actual).toEqual({ + files: legacy.length, + additions: legacy.reduce((sum, item) => sum + item.additions, 0), + deletions: legacy.reduce((sum, item) => sum + item.deletions, 0), + }) + expect(status.untracked).toEqual(["new.txt"]) + }) + }) + + it("changes its fingerprint when an already-modified file changes", async () => { + await repo(async (dir) => { + const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined })) + await fs.writeFile(path.join(dir, "tracked.txt"), "modified once\n") + const first = await snapshots.status(dir) + await fs.writeFile(path.join(dir, "tracked.txt"), "modified twice and larger\n") + const second = await snapshots.status(dir) + expect(second.fingerprint).not.toBe(first.fingerprint) + }) + }) + + it("reads ref OIDs and upstreams", async () => { + await repo(async (dir) => { + run(dir, ["remote", "add", "origin", "."]) + run(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]) + run(dir, ["branch", "--set-upstream-to=origin/main", "main"]) + const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined })) + const refs = await snapshots.refs(dir) + expect(refOID(refs, "origin/main")).toBe(run(dir, ["rev-parse", "HEAD"])) + expect(refs.upstreams.get("refs/heads/main")).toBe("refs/remotes/origin/main") + }) + }) +}) diff --git a/plans/agent-manager-git-poller-optimization.md b/plans/agent-manager-git-poller-optimization.md new file mode 100644 index 0000000000..10ca489e24 --- /dev/null +++ b/plans/agent-manager-git-poller-optimization.md @@ -0,0 +1,485 @@ +# Plan: Optimize Agent Manager Git Stats Polling + +## Problem + +Agent Manager periodically computes exact diff and ahead/behind statistics for +the local checkout and every visible managed worktree. The timer itself is not +the problem. The expensive part is that every poll reconstructs the same state +through several independent Git processes and filesystem passes, even when a +worktree has not changed. + +For each worktree, the current hot path does the following: + +1. `git merge-base HEAD ` +2. `git diff --name-status --no-renames ` +3. `git diff --numstat --no-renames ` +4. `git ls-files --others --exclude-standard` +5. `git rev-list --left-right --count ...HEAD` +6. `lstat`, binary detection, and line counting for every untracked file + +The poll also runs `git worktree list --porcelain` once for presence and branch +information. `GitStatsPoller` suppresses an unchanged webview message only after +all of the work above has finished, so the existing result hash does not reduce +Git CPU, process creation, or disk reads. + +The cost scales linearly with the number of worktrees and expanded projects. +The shared semaphore limits concurrent child processes, but it does not reduce +the total work. On endpoint-protected machines, every extra Git process and file +scan also creates security-agent work. + +## Constraints + +- Keep timer-based polling. Do not introduce filesystem watchers, FSEvents, or + another event-driven invalidation system. +- Preserve the current visible interval, hidden interval, skip behavior, + non-overlap guarantees, and manual `snapshot(true)` refresh behavior through + the command-consolidation and cache phases. Permit bounded timer-based + sharding only if measurement proves the mandatory status scans remain above + the CPU/disk or endpoint-security goals. +- Preserve exact UI semantics for tracked, staged, unstaged, deleted, binary, + image, and untracked files, including exact addition/deletion totals. +- Preserve ahead/behind semantics against each worktree's configured + `remoteRef(wt)`. Do not fetch from remotes. +- Do not modify user or repository Git configuration. In particular, do not + enable `core.fsmonitor`, untracked cache, split index, or maintenance as a + side effect of opening Agent Manager. +- Keep all child processes behind the existing shared `Semaphore` and abort + controller. +- Keep the implementation in the VS Code extension. Do not move polling back + to `kilo serve` because the local path intentionally avoids Bun child-process + memory growth on Windows. +- Do not include the separate Git executable resolution / macOS double-exec + work. That can land independently. + +## Goal + +Make each periodic poll proportional to the amount of changed state rather than +repeating every exact diff calculation for every worktree. + +For an unchanged worktree, the steady-state poll should perform one read-only +Git status probe and no base-relative diff or history walk. When a worktree does +change, it should compute the exact UI statistics with fewer Git processes and +fewer repeated index/worktree traversals than today. + +## Recommended Design + +Use a two-level timer-driven poll: + +1. One canonical status scan returns enough state to decide whether the + previous exact result is reusable. +2. Only a changed probe triggers exact diff and ahead/behind calculation. + +The cache is an optimization, not a source of truth. The first poll, a forced +snapshot, a failed probe, a changed base, or an uncertain fingerprint always +falls back to exact calculation. + +### Feasibility and lower bound + +The status, merge-base numstat, ref snapshot, and `rev-list` commands used here +are available in current Git and have stable machine-readable output. Every +optimization also has a failure fallback. + +This design does not make polling free. Without filesystem watchers or Git +fsmonitor, there is no repository-level hash that reveals arbitrary unstaged or +untracked file changes. Exact periodic detection must scan each worktree. Git +cannot status several independent worktree/index pairs in one invocation, so +one status process and one working-tree scan per active worktree per interval is +the practical lower bound under these constraints. + +The expected gain comes from removing duplicate scans, exact line-count diffs, +and history walks after that mandatory status scan. Process-count savings will +therefore be larger than CPU and disk savings. CrowdStrike CPU normalization is +a measured acceptance gate, not an assumed consequence of reducing process +launches. + +### Level 1: Worktree fingerprint + +Add a polling-specific helper that runs: + +```text +git --no-optional-locks status \ + --porcelain=v2 --branch -z \ + --no-ahead-behind --untracked-files=all --no-renames +``` + +Parse its stable machine format into: + +- current `HEAD` OID and branch, +- tracked and untracked paths, +- staged/unstaged status and index object IDs, +- a deterministic status payload. + +The status payload alone is not a safe cache key. Editing a file that is +already reported as modified can leave the porcelain text unchanged. Complete +the fingerprint in Node with bigint `lstat` metadata for every changed +non-deleted path: size, nanosecond mtime/ctime when supported, inode, mode, and +file type. Include these values in a deterministic hash together with: + +- worktree path, +- configured base ref, +- `HEAD` OID, +- status records and paths, +- the cached base-ref OID from the project-level ref snapshot described below. + +This does not hash file contents. It detects normal editor writes, atomic +renames, staging, commits, branch switches, untracked-file changes, and local +tracking-ref changes while avoiding a full exact diff on an unchanged tree. + +If any path cannot be statted, contains unsupported status data, or changes +during probing, mark the fingerprint uncertain and run the exact path. Never +reuse cached stats on uncertainty. + +The fingerprint cache is in memory and scoped to one `GitStatsPoller`. Store per +worktree: + +```ts +type CachedStats = { + base: string + fingerprint: string + stats: WorktreeStats +} +``` + +Do not retain every `WorktreeDiffEntry` in the poller cache. Large worktrees can +have thousands of changed files, while the poller only needs aggregate stats. +Keep review and file-detail metadata outside this cache. Bound aggregate cache +entries by active worktree IDs and clear them on `stop()` so project switches +and disposal cannot leak stale state. + +### Level 2: Exact recomputation with fewer traversals + +When a fingerprint changes, reuse the probe's untracked paths instead of +running `git ls-files --others --exclude-standard` again. + +Replace the separate merge-base process with Git's built-in merge-base diff +form: + +```text +git -c core.quotepath=false diff \ + --merge-base --numstat -z --no-renames +``` + +The poller only renders aggregate file/addition/deletion counts, so it does not +need tracked-file status records or `WorktreeDiffEntry` objects. Numstat alone +provides the tracked file count, exact text counts, and binary markers currently +obtained from `merge-base`, `diff --name-status`, and `diff --numstat`. `-z` +makes paths safe for tabs, newlines, Unicode, and unusual filenames. + +The polling helper should therefore compute a changed worktree with: + +- one status/fingerprint process, +- one combined tracked-diff process, +- no second untracked enumeration, +- one history calculation only when the commit/base pair changed. + +Do not change `diffFile`'s on-demand detail path in this work. It is not periodic +and has different materialization requirements. + +### Batch project ref state + +Run one project-level ref snapshot per poll before processing individual +worktrees: + +```text +git for-each-ref \ + --format=%(refname)%00%(objectname)%00%(upstream)%00%(symref)%00 \ + refs/heads refs/remotes +``` + +Use it to map every local branch and remote base ref to an OID, each local +branch to its configured upstream, and each remote `HEAD` to its symbolic +default branch. Combine this with `git worktree list --porcelain -z`, whose +records already contain each worktree's `HEAD` OID and branch. Extend the +existing worktree parser and `listWorktreePaths` result instead of launching +`rev-parse`, `symbolic-ref`, or config queries in each worktree. + +For the local checkout, preserve the current tracking-resolution fallback when +the ref snapshot is insufficient (for example, unusual configuration with a +branch remote but no upstream merge ref). Cache that resolution as today. Do +not trade a small process reduction for a change in which base branch is used. + +The project snapshot supplies three things: + +- the presence and checked-out branch information already used by the poller, +- the `HEAD` OID for each worktree, +- the base-ref OID used in the fingerprint and ahead/behind cache key. + +Cache ahead/behind by `\0`. If both OIDs are unchanged, reuse +the previous counts even when only working-tree files changed. This removes +history walks from normal editing polls. + +The implementation uses the ref snapshot to cache base OIDs and reuses +ahead/behind counts when the worktree `HEAD` and base OID are unchanged. A +changed worktree uses the existing `rev-list --left-right --count ...HEAD` +path. Batched ahead/behind and merge-base fast paths were tested but removed +because they did not improve the dominant steady-state workload enough to +justify their compatibility and maintenance cost. + +### Forced refresh and failure behavior + +- The initial poll always computes exact results. +- `snapshot(true)` bypasses fingerprint reuse and recomputes exact results for + skipped and unskipped worktrees, matching current behavior. +- A base string or base OID change invalidates diff and ahead/behind caches. +- A branch or `HEAD` OID change invalidates both caches. +- A fingerprint change invalidates exact diff stats but not ahead/behind when + the commit/base OIDs are unchanged. +- Missing worktrees are removed from all caches when presence is reconciled. +- A transient exact-diff failure preserves last-known stats, matching the + current poller contract. +- A transient fingerprint/ref-snapshot failure runs exact calculation. If that + also fails, preserve last-known stats. +- Do not promote an exact result to a reusable cache entry when metadata for its + known paths changes while the exact calculation is running. Emit the result, + then let the next timer tick confirm or recompute it; this preserves the + current eventual correction behavior without adding a second status process + to every changed poll. +- `syncSkips()` must not discard cached data. Collapsing and re-expanding a + section should reuse the last exact result after a confirming fingerprint. + +## Changes + +### 1. Add a polling snapshot module + +Create `packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts` as a +VS Code-free module. Keep parsing, hashing, cache decisions, and aggregation out +of `GitStatsPoller.ts` so the existing file does not grow into another mixed +responsibility controller. + +The module should own: + +- porcelain-v2 `-z` parsing, +- numstat `-z` parsing, +- metadata fingerprint construction, +- exact aggregate file/addition/deletion calculation, and +- cache-key and invalidation decisions. + +Use explicit parse-result errors rather than silently treating malformed output +as a clean worktree. + +### 2. Extend `GitOps` + +Use the existing `GitOps` buffered execution methods through the narrow +`GitStatsSnapshot` source boundary. Do not expose the generic private `raw()` +method or bypass the existing semaphore. Use `execGitBuffer()` for NUL-delimited +output. + +Retain the current untracked-file safeguards in the aggregate implementation: +content reads capped at 1 MB and content-based binary detection. Do not call +`git status` without `--no-ahead-behind`; otherwise every supposedly cheap probe +can perform the history walk this design is trying to cache. + +### 3. Update `GitStatsPoller` + +Replace the independent `localDiff` + `aheadBehind` calls with a project poll +coordinator: + +1. collect presence, worktree `HEAD`s, and ref OIDs once, +2. run status probes for active worktrees through the shared semaphore, +3. reuse cached exact results for matching fingerprints, +4. recompute exact summaries only for changed/uncertain worktrees, +5. merge successful results into `lastStats` and emit only when the aggregate + UI hash changed. + +Replace the injected `localDiff` callback with a narrow exact aggregate callback +or snapshot service. Do not construct full `WorktreeDiffEntry[]` merely to reduce +them to three numbers. + +The local checkout should use the same snapshot/cache path instead of separately +resolving branch, tracking branch, diff, and ahead/behind every tick. Preserve +the existing no-base fallback to `workingTreeStats`. + +Keep the current trailing-delay scheduler. Do not change polling intervals in +this optimization. + +### 4. Keep review/detail diff code separate + +Leave `local-diff.ts`, `createLocalDiff`, `diffSummary`, and `diffFile` behavior +unchanged in this optimization. Review and file-detail requests need per-file +status, stamps, merge-base identity, and materialization data that the poller +does not need. Sharing those objects would retain potentially thousands of +entries per worktree and broaden the regression surface for an infrequent path. + +## Final Implementation + +The measured implementation combines the useful parts of the original proposal: + +- one porcelain-v2 status/fingerprint probe for each selected worktree, +- one shared ref snapshot per project poll, +- exact numstat and untracked line counting only after a fingerprint change, +- cached ahead/behind counts keyed by `HEAD` and base OIDs, +- five-second polling for dirty, selected, busy, and newly confirmed worktrees, +- deterministic 30-second round-robin polling for clean dormant worktrees, +- immediate unsharded `snapshot(true)` refreshes, and +- last-known stats preservation when a later Git operation fails. + +The clean dormant sharding was added only after direct workload and extension-host +profiles showed that status probes remained the dominant steady-state cost. It is +timer-based polling, not filesystem event invalidation. Product approval is still +required for the bounded 30-second freshness tradeoff. + +Batched ahead/behind, merge-base shortcuts, persistent Git workers, and shipped +benchmark or profiling hooks were explicitly discarded after measurement. + +## Tests + +### Parser and exact-result parity + +Add focused tests for the new snapshot module using the real temporary-repository +fixtures and helpers in `tests/unit/local-diff.test.ts`: + +- clean worktree, +- committed changes since base, +- staged-only, unstaged-only, and staged-plus-unstaged changes, +- added, modified, deleted, type-changed, and conflicted files, +- untracked text and binary files, +- ignored files excluded, +- filenames containing spaces, tabs, newlines, and non-ASCII characters, +- symlinks and deleted paths, +- stale/missing base refs, +- detached HEAD, +- base ref advancing without a working-tree change. + +For every fixture, compare the optimized aggregate output to the sum of the +current `diffSummary` entries and to the current `aheadBehind` result before +switching the poller to the new implementation. + +### Cache invalidation + +Extend `tests/unit/git-stats-poller.test.ts` with command recording and assert: + +- second unchanged poll runs a status probe but no exact diff or `rev-list`, +- editing an already-modified file changes the metadata fingerprint and updates + line counts, +- staging without changing file contents invalidates the fingerprint, +- commit and branch changes invalidate diff and ahead/behind, +- remote-tracking ref changes invalidate base-dependent values, +- working-tree-only edits reuse ahead/behind, +- `snapshot(true)` bypasses cache reuse, +- skipped worktrees remain cached but are not emitted, +- missing worktrees evict cached state, +- malformed/failed probes fall back to exact computation, +- exact failures retain last-known stats, +- `stop()` clears caches and stale generations cannot publish results. + +- hot worktrees are polled on every visible tick, +- clean dormant worktrees rotate without starvation and are sampled within + 30 seconds, +- a dirty result promotes a worktree to hot immediately, +- two consecutive clean polls return it to the dormant queue, +- forced refreshes bypass the shard budget, +- deleted sessions cannot leave stale busy IDs indefinitely, and +- every Git operation remains behind the shared semaphore. + +## Performance Verification + +Use disposable fixtures and isolated VS Code instances for before/after +measurements. Do not add benchmark scripts, Git hooks, or profiling observers to +the product diff. + +The final matched extension-host profile used 40 rendered worktrees for 30 +seconds. It recorded 1,994 baseline GitOps commands versus 466 optimized commands +and 42.16 seconds versus 13.62 seconds of cumulative Git command time. The +direct workload profile also showed a 78.5% reduction in combined Git CPU. + +Acceptance criteria now are: + +- exact aggregate output parity across real temporary-repository fixtures, +- no exact diff or history walk for an unchanged cached worktree, +- dirty, selected, busy, and new worktrees observed on five-second ticks, +- every clean dormant worktree observed within 30 seconds, +- forced refresh bypasses sharding and cache reuse, +- no monotonic memory growth across repeated polls, and +- no repository configuration or index-format changes. + +CrowdStrike CPU remains an external managed-endpoint measurement. It must be +recorded directly before claiming an endpoint-security reduction; Git command or +process reductions are not a substitute for that measurement. + +## Verification + +From `packages/kilo-vscode/`: + +- `bun run test:unit -- --grep "GitStatsPoller|diffSummary|GitOps|parseWorktreeList"` +- `bun run typecheck` +- `bun run lint` +- `bun run knip` +- `bun run check-kilocode-change` + +Manually verify that Agent Manager stats update within one visible poll after +editing, staging, committing, switching branches, and updating a local tracking +ref. Verify that clean dormant worktrees rotate within 30 seconds and that +forced refreshes bypass the shard budget. + +## Risks And Mitigations + +- **False cache hits from unchanged status text.** Include per-path metadata and + fail open to exact computation. Test repeated edits to the same modified path. +- **timestamp granularity or preserved timestamps.** Use bigint stat metadata + including nanosecond mtime/ctime where supported, size, inode, mode, and + status/index metadata. Treat this as a performance cache and force exact + refresh on manual requests. If tests show a supported filesystem can preserve + all fields across a content edit, add a bounded content sample hash for + changed files rather than hashing every full file. +- **Races while files are changing.** The status fingerprint is sampled before + exact calculation. A later status probe corrects any edit that races the + calculation, and failed exact operations retain the prior known result. +- **User Git configuration changes output or cost.** Use porcelain/numstat + machine formats, explicit `--no-renames`, `-z`, and `--no-optional-locks`. +- **Older Git versions.** Use the existing `merge-base` and `rev-list` commands; + do not require an upgrade merely to show stats. +- **Large untracked sets remain expensive.** Enumeration is required for exact + feature parity. The optimization ensures it happens once per tick and line or + binary reads happen only when the fingerprint changes. +- **Shared refs change during a poll.** Bind one project ref snapshot to a poll + generation. A later tick corrects races; failed exact operations retain the + prior known result. + +## Out Of Scope + +- Filesystem watchers or event-driven invalidation. +- Changing the five-second visible or 60-second hidden intervals. Clean dormant + worktrees use the measured 30-second round-robin bound. +- Resolving the macOS `/usr/bin/git` launcher to avoid double execution. +- CrowdStrike or other endpoint-security exclusion policies. +- Enabling Git fsmonitor, untracked cache, split index, sparse checkout, or + repository maintenance. +- Optimizing infrequent apply, merge, PR, fetch, or file-detail commands. + +## Measured Outcome + +Implementation retained only the optimizations that reduced the real workload: + +- one porcelain-v2 status fingerprint per polled worktree, +- exact diff and ahead/behind reuse while file and ref fingerprints are stable, +- 30-second round-robin polling for clean dormant worktrees, +- five-second polling for dirty, selected, or running worktrees, +- immediate full polling for forced snapshots. + +Removed after measurement: + +- the temporary legacy/optimized benchmark implementation, +- generic Git command instrumentation, +- batched ahead/behind and merge-base fast paths that affected cold polls but + not the dominant steady-state workload. + +Measurements on the same repository on 2026-08-05: + +- 52 linked worktrees: 32 clean, 20 dirty, no status failures. +- Required cache phases, 40 comparable worktrees: Git launches fell from 200 to + 41 (79.5%); warm wall time fell from 3.54 seconds to 2.75 seconds (22.1%). +- Final steady-state policy scanned 20 dirty worktrees plus a six-worktree clean + shard and the local checkout. +- Prior full-poll workload: 10.63 seconds wall, 20.61 seconds user CPU, 35.88 + seconds system CPU, 411,914 involuntary context switches. +- Final steady-state workload: 1.42 seconds wall, 0.59 seconds user CPU, 11.54 + seconds system CPU, 188,273 involuntary context switches. +- Matched 30-second extension-host windows with 40 rendered worktrees: GitOps + command count fell from 1,994 to 466 (76.6%), and cumulative Git command time + fell from 42.16 seconds to 13.62 seconds (67.7%). Both runs had no trace data + loss. + +The final comparison is a local process-level measurement, not a direct +CrowdStrike process measurement because this session has no sudo access. Falcon +CPU must still be checked on the managed endpoint after deployment; no security +policy exception is justified by this implementation result alone. diff --git a/plans/agent-manager-git-poller-remaining.md b/plans/agent-manager-git-poller-remaining.md new file mode 100644 index 0000000000..3867fb0b67 --- /dev/null +++ b/plans/agent-manager-git-poller-remaining.md @@ -0,0 +1,317 @@ +# Agent Manager Git Poller: Remaining Work And Blockers + +## Status + +The implementation is functionally complete but is not ready to merge yet. + +The current worktree contains: + +- status-based fingerprints for tracked and untracked changes, +- cached exact diff and ahead/behind results, +- one shared ref snapshot per poll, +- five-second polling for dirty, selected, and busy worktrees, +- 30-second round-robin polling for clean dormant worktrees, +- immediate forced refresh support, +- focused real-repository and scheduler tests, +- a patch changeset, +- the original implementation plan and measured direct-command results. + +The production runtime has one polling implementation. Temporary benchmark code, +Git command observers, batched ahead/behind, and alternate merge-base paths were +removed after measurement showed they did not materially improve the dominant +steady-state workload. + +## Current Diff + +Expected implementation files: + +- `packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts` +- `packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts` +- `packages/kilo-vscode/src/agent-manager/project/pollers.ts` +- `packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts` +- `packages/kilo-vscode/src/KiloProvider.ts` +- `packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts` +- `packages/kilo-vscode/tests/unit/git-stats-poller.test.ts` +- `.changeset/calm-agent-manager-git-polling.md` +- `plans/agent-manager-git-poller-optimization.md` +- this handoff file + +No benchmark implementation or profiling instrumentation should be committed. + +## Completed Validation + +The latest minimized implementation passed: + +- `222` focused and Agent Manager architecture tests, +- extension lint, +- extension TypeScript checking, +- webview TypeScript checking, +- extension/webview bundling, +- `knip`, +- `check-kilocode-change`, +- markdown table padding validation, +- `git diff --check`. + +Manual isolated VS Code verification also passed for local stats: + +1. Agent Manager showed `9 files`, `+1340`, `-82`. +2. Creating one one-line untracked file changed it to `10 files`, `+1341`, + `-82` within one visible poll. +3. Removing the file restored `9 files`, `+1340`, `-82` within one poll. + +The temporary file was removed and is not in the working tree. + +## Trustworthy Measurements + +### Direct workload measurement + +These measurements used read-only Git commands against the same linked +worktrees. They did not change worktree contents or Git metadata. + +On 52 linked Kilo worktrees: + +- 32 were clean, +- 20 were dirty, +- no status command failed. + +Required cache phases on 40 comparable worktrees: + +- Git launches: `200` to `41`, a 79.5% reduction, +- warm wall time: `3.54s` to `2.75s`, a 22.1% reduction. + +Final steady-state policy, scanning 20 dirty worktrees, six clean worktrees, +and the local checkout: + +| Metric | Previous full poll | Optimized steady state | Change | +|---|---:|---:|---:| +| Wall time | 10.63s | 1.42s | -86.6% | +| User CPU | 20.61s | 0.59s | -97.1% | +| System CPU | 35.88s | 11.54s | -67.8% | +| Combined CPU | 56.49s | 12.13s | -78.5% | +| Involuntary context switches | 411,914 | 188,273 | -54.3% | + +The filesystem cache was warm and `/usr/bin/time` reported zero block-input +operations, so this comparison does not establish a reliable disk-read +reduction. + +### Valid self-test profiles + +A disposable fixture repository was created under the approved temp directory +with: + +- 40 linked worktrees, +- 16 intentionally dirty worktrees, +- 24 clean worktrees, +- a valid canonical Agent Manager project ID, +- the bundled Kilo CLI, +- all 40 worktree cards rendered in the Agent Manager DOM, +- `wt-01` selected. + +Matched 35-second self-test profiles were captured from a committed baseline +extension and the optimized extension. Both profiles had no trace data loss. + +| Metric | Baseline | Optimized | Change | +|---|---:|---:|---:| +| Distinct Git PIDs observed at 10 Hz | 59 | 53 | -10.2% | +| Longest renderer task | 70.42ms | 68.81ms | -2.3% | +| Renderer task duration | 98.46ms | 94.42ms | -4.1% | + +The renderer results are expected to be small because Git polling runs in the +extension host, not the webview renderer. PID sampling undercounts short-lived +processes and is useful only as supporting evidence. + +### Exact extension-host Git profile + +Matched 30-second windows were captured from the same disposable fixture after +validating 40 worktree cards in both runs. Both traces had no data loss. The +temporary GitOps hook recorded every Git command from the extension host. + +| Metric | Baseline | Optimized | Change | +|---|---:|---:|---:| +| GitOps command count | 1,994 | 466 | -76.6% | +| Cumulative Git command time | 42.16s | 13.62s | -67.7% | +| `merge-base` commands | 389 | 43 | -88.9% | +| `diff --numstat` commands | 389 | 43 | -88.9% | +| `ls-files --others` commands | 389 | 0 | -100% | +| `rev-list --left-right --count` commands | 389 | 43 | -88.9% | + +The optimized run replaced the baseline's repeated command families with 285 +porcelain-v2 status probes and 33 shared ref snapshots. The renderer remained +near idle in both runs, with approximately 0.72 seconds of script work over the +30-second window. + +Artifacts are temporary and currently live under: + +`/var/folders/6c/3j3r25ds6pd1dw3nlrfnvv280000gp/T/kilo/git-poller-profile.F0VLor` + +Do not add those artifacts to Git. + +## Invalid Or Incomplete Measurements + +Do not cite the following as final evidence: + +- Early self-test profiles where the bundled CLI was absent. Agent Manager did + not receive `sessionsLoaded`, so only the local card rendered. +- Early profiles whose synthetic `activeTarget.projectId` used `local:` + instead of `projectIdFor(canonicalRoot)`. +- PATH-wrapper Git logs. VS Code shell-environment resolution bypassed the + wrapper for extension-host Git processes. +- The earlier partial `baseline-exact.tsv`; it was discarded after the matched + final windows completed. + +## Remaining Required Work + +### 1. Measure CrowdStrike directly + +This session does not have sudo access, so Falcon CPU could not be measured in a +controlled before/after experiment. + +Required managed-endpoint comparison: + +1. Use the same 40-worktree fixture and matched baseline/optimized extension + builds. +2. Warm both runs before recording. +3. Record at least five minutes per build. +4. Measure the CrowdStrike process group, including the Agent, + `FileAnalysisService`, and `FXPredictService`. +5. Record average CPU, peak CPU, bytes read, and wakeups. +6. Keep VS Code, fixture state, visibility, and other workload constant. + +Target: + +- at least 50% lower Agent-Manager-attributable CrowdStrike CPU, +- no more than 0.10 average CPU core or 20% over the closed-panel baseline, + whichever allowance is larger. + +If Git workload drops but CrowdStrike does not, the remaining status scans are +the likely floor. Do not add more cache layers without profiler evidence. + +### 2. Product decision on dormant freshness + +The implementation changes clean dormant-worktree freshness from five seconds +to at most 30 seconds. This is timer-based polling, not filesystem events. + +Current behavior: + +- dirty worktree: five seconds, +- selected worktree: five seconds, +- worktree with a busy session: five seconds, +- new or not-yet-confirmed-clean worktree: five seconds, +- clean dormant worktree: round-robin, at most 30 seconds, +- forced snapshot: immediate and unsharded, +- hidden panel: existing 60-second full poll. + +This tradeoff needs explicit product approval. If all worktrees must retain +five-second freshness, remove dormant sharding and keep only status fingerprints +and exact-result caching. The direct measurements show that this leaves status +scans as the dominant cost. + +### 3. Review busy-session lifecycle + +`AgentManagerProvider` keeps a `busySessions` set so worktrees with actively +working Kilo sessions remain hot. Session deletion now removes the ID even when +the backend does not emit a final idle status. + +Before merge, verify: + +- every non-idle status should make the worktree hot, +- idle removes it, +- closed/deleted sessions cannot leave stale IDs indefinitely, +- project switch, panel close, and provider disposal clear the set, +- remote/retry/offline status semantics are correct. + +Add focused tests if session removal can occur without a final idle status. + +### 4. Final minimization review + +Review the final diff after exact profiling and remove anything not justified by +the data. + +Specific review points: + +- `GitStatsPoller.ts` grew substantially. Extract only if it improves clarity + and remains within architecture caps; do not create generic abstractions. +- `GitStatsSource` exists as a narrow test seam and snapshot boundary. Confirm + no broader interface is needed. +- `semaphore` remains an existing option but is not consumed directly by the + poller. Do not add another semaphore layer. +- Keep `local-diff.ts` and review/detail behavior unchanged. +- Keep the temporary benchmark, exact-profile hooks, fixture, CLI copies, and + profile artifacts out of the commit. +- Update `plans/agent-manager-git-poller-optimization.md` so its final design and + measured outcome match the minimized implementation. Remove stale proposed + phases that were explicitly discarded. + +### 5. Final automated validation + +After the last code change, rerun from `packages/kilo-vscode/`: + +- `bun run format` +- `bun run lint` +- `bun run check-types` +- `bun run check-types:webview` +- `bun run bundle` +- focused Git poller/snapshot and Agent Manager architecture tests +- `bun run knip` +- `bun run check-kilocode-change` + +From repository root: + +- `bun run script/check-md-table-padding.ts` +- `git diff --check` + +Also rerun the isolated UI mutation check for local stats after the final build. + +## Real Checkout Guard + +All profiling worktrees and state used for the valid profiles were created in a +disposable temp fixture. They did not reference real managed worktree paths. + +The original real-checkout guard became invalid because the main checkout +changed concurrently during profiling: + +- main advanced from `b135b4e` to `efbae40`, +- `packages/opencode/package.json` changed, +- existing `bun.lock` and changeset changes remained. + +Those changes were not reverted or modified by this work. Because the baseline +changed concurrently, establish a fresh guard immediately before any remaining +profile and compare it immediately afterward. + +The guard should include: + +- SHA-256 of `.kilo/agent-manager.json`, +- SHA-256 of `.git/info/exclude`, +- SHA-256 of every `.git/worktrees/*/gitdir`, +- main checkout status and HEAD, +- `git worktree list --porcelain`, +- porcelain status of every linked worktree. + +Abort and investigate if the guard changes. Never revert concurrent user or +agent changes. + +## Blockers + +Current blockers to calling the implementation complete: + +1. Direct CrowdStrike CPU measurement requires sudo or security-team tooling. +2. The 30-second dormant freshness change needs product approval. +3. Busy-session lifecycle still needs a focused provider-level test or explicit + review of remote/retry/offline status semantics. +4. Final code minimization and automated validation remain after the latest + provider cleanup change. +5. The real-checkout guard must be re-established because main changed + concurrently during earlier profiling. + +## Stop Conditions + +Do not merge if any of these remain true: + +- exact extension-host Git work does not fall materially, +- a clean dormant worktree can exceed 30 seconds stale, +- a dirty, selected, or busy worktree misses the five-second cadence, +- forced refresh reuses stale cached stats, +- repeated edits to an already-dirty file fail to invalidate exact stats, +- memory grows monotonically across repeated polls, +- CrowdStrike remains abnormal and no evidence explains why, +- temporary profiling code or artifacts remain in the diff. From 70daa630d3f83f960f9255d9c95e5727b79ce229 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:31:27 +0200 Subject: [PATCH 48/67] fix(vscode): avoid embedded terminal logo artifacts --- .changeset/quiet-embedded-logo.md | 5 +++++ packages/kilo-vscode/src/agent-manager/terminal-manager.ts | 3 +++ .../tests/unit/agent-manager-terminal-routing.test.ts | 7 ++++++- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-embedded-logo.md diff --git a/.changeset/quiet-embedded-logo.md b/.changeset/quiet-embedded-logo.md new file mode 100644 index 0000000000..7623cf0e73 --- /dev/null +++ b/.changeset/quiet-embedded-logo.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use a compatible Kilo wordmark in embedded Agent Manager terminals. diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index 24f916542e..998c3d6cdf 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -71,6 +71,9 @@ export class TerminalManager { directory: params.cwd, cwd: params.cwd, title: params.title, + // xterm's DOM renderer cannot draw the Unicode sextant glyphs used by + // Kilo's modern wordmark, so use the compatible logo in embedded tabs. + env: { KILO_UNICODE_LOGO: "0" }, }) if (error || !data) { const err = error instanceof Error ? error.message : String(error ?? "unknown error") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index 348a632a48..7ad1005e09 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -12,9 +12,13 @@ function wait() { describe("Agent Manager terminal routing", () => { it("round-trips side placement and rejects missing worktrees", async () => { const messages: AgentManagerOutMessage[] = [] + const envs: Array | undefined> = [] const client = { pty: { - create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }), + create: async ({ env }: { env?: Record }) => { + envs.push(env) + return { data: { id: "pty-1", title: "Terminal 1" } } + }, remove: async () => ({ data: true }), update: async () => ({ data: true }), }, @@ -46,6 +50,7 @@ describe("Agent Manager terminal routing", () => { worktreeId: "wt-1", projectId: "prj-1", }) + expect(envs[0]).toEqual({ KILO_UNICODE_LOGO: "0" }) router.handle({ type: "agentManager.terminal.create", From c1d2f9ed46a38b6d41d966849e2a254c1934753c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:33:54 +0200 Subject: [PATCH 49/67] fix(vscode): harden git executable resolution --- .../kilo-vscode/src/agent-manager/GitOps.ts | 9 +++++---- .../kilo-vscode/tests/unit/git-ops.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index d4be3b7ca6..d33799e0c0 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -589,14 +589,15 @@ export class GitOps { 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( + const cache = (this.executableCache ??= Promise.resolve().then(() => this.binary())) + cache.then( (value) => { signal.removeEventListener("abort", onAbort) resolve(value) }, (err) => { signal.removeEventListener("abort", onAbort) + if (this.executableCache === cache) this.executableCache = undefined reject(err) }, ) @@ -617,7 +618,7 @@ export class GitOps { const out: Buffer[] = [] const err: Buffer[] = [] let failure: string | undefined - const abort = () => child.kill("SIGINT") + const abort = () => child.kill("SIGTERM") this.controller.signal.addEventListener("abort", abort, { once: true }) child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) @@ -641,7 +642,7 @@ export class GitOps { return } failure = "stdin not available for git process" - child.kill("SIGINT") + child.kill("SIGTERM") }) } } diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index b840e119e4..efb1fd9879 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -88,6 +88,24 @@ describe("GitOps", () => { expect(await pending).toBe("") }) + it("retries executable resolution after a transient failure", async () => { + await withRepo(async (cwd) => { + let calls = 0 + const git = new GitOps({ + log: () => undefined, + binary: async () => { + calls++ + if (calls === 1) throw new Error("transient resolution failure") + return "git" + }, + }) + + expect(await git.root(cwd)).toBeUndefined() + expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd)) + expect(calls).toBe(2) + }) + }) + describe("currentBranch", () => { it("returns the current branch name", async () => { const git = ops(async (args) => { From ce078bd6a9174547c7eb8078d2aba7f47b628409 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:49:16 +0200 Subject: [PATCH 50/67] fix(vscode): preserve logo override on terminal restart --- packages/kilo-vscode/src/agent-manager/terminal-manager.ts | 5 ++++- .../tests/unit/agent-manager-terminal-routing.test.ts | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index 998c3d6cdf..40b98a58e1 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -15,6 +15,8 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" +const env = { KILO_UNICODE_LOGO: "0" } + /** * Everything the manager needs from the surrounding AgentManagerProvider. * @@ -73,7 +75,7 @@ export class TerminalManager { title: params.title, // xterm's DOM renderer cannot draw the Unicode sextant glyphs used by // Kilo's modern wordmark, so use the compatible logo in embedded tabs. - env: { KILO_UNICODE_LOGO: "0" }, + env, }) if (error || !data) { const err = error instanceof Error ? error.message : String(error ?? "unknown error") @@ -240,6 +242,7 @@ export class TerminalManager { directory: entry.cwd, cwd: entry.cwd, title: entry.title, + env, }) const info = created.data if (created.error || !info) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index 7ad1005e09..709a704b8f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -51,6 +51,9 @@ describe("Agent Manager terminal routing", () => { projectId: "prj-1", }) expect(envs[0]).toEqual({ KILO_UNICODE_LOGO: "0" }) + router.handle({ type: "agentManager.terminal.restart", terminalId: "side-1" }) + await wait() + expect(envs[1]).toEqual({ KILO_UNICODE_LOGO: "0" }) router.handle({ type: "agentManager.terminal.create", @@ -58,7 +61,9 @@ describe("Agent Manager terminal routing", () => { placement: "side", worktreeId: "missing", }) - expect(messages[1]).toMatchObject({ + expect( + messages.find((message) => message.type === "agentManager.terminal.error" && message.createId === "side-missing"), + ).toMatchObject({ type: "agentManager.terminal.error", createId: "side-missing", }) From a67b120d21435200cb8d5e3925dfd1c27631e0e6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:50:52 +0200 Subject: [PATCH 51/67] fix(core): avoid shadowing process settlement helper --- packages/core/src/cross-spawn-spawner.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index cb5f41d289..58fb29912e 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -269,7 +269,11 @@ export const make = Effect.gen(function* () { return { stdout, stderr, all: Stream.merge(stdout, stderr) } } - const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions, settle: boolean) => + const spawn = ( + command: ChildProcess.StandardCommand, + opts: NodeChildProcess.SpawnOptions, + direct: boolean, // kilocode_change - avoid shadowing settle + ) => Effect.callback((resume) => { const signal = Deferred.makeUnsafe() const proc = launch(command.command, command.args, opts) @@ -281,7 +285,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 + if (direct) Deferred.doneUnsafe(signal, Exit.succeed(args)) // kilocode_change - bounded grep must not await inherited pipes }) proc.on("close", (...args) => { if (end) return From 26ce2aff02987f0e1aaa05c4fd1ea465cbdb17c2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:53:03 +0200 Subject: [PATCH 52/67] fix(vscode): preserve hot worktree polling --- .../src/agent-manager/AgentManagerProvider.ts | 14 ++++++++-- .../src/agent-manager/git-stats-snapshot.ts | 2 +- .../src/agent-manager/project/pollers.ts | 4 ++- .../tests/unit/git-stats-snapshot.test.ts | 18 +++++++++++- plans/agent-manager-git-poller-remaining.md | 28 ++++++++++--------- 5 files changed, 48 insertions(+), 18 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 6844149ec3..29fff5851e 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -232,7 +232,8 @@ export class AgentManagerProvider implements Disposable { if (status.state === "running" || status.state === "stopping") ids.add(status.worktreeId) } for (const sid of this.busySessions) { - const id = this.state?.getSession(sid)?.worktreeId + const owner = this.contexts.byLiveSession(sid) + const id = owner?.peekState()?.getSession(sid)?.worktreeId ?? this.state?.getSession(sid)?.worktreeId if (id) ids.add(id) } return ids @@ -279,7 +280,12 @@ export class AgentManagerProvider implements Disposable { this.unsubSessions = this.connectionService.onEventFiltered( (event) => { const type = (event as { type?: string }).type - return type === "session.created" || type === "session.updated" || type === "session.deleted" + return ( + type === "session.created" || + type === "session.updated" || + type === "session.deleted" || + type === "session.error" + ) }, (event) => this.onSessionLifecycle(event), ) @@ -292,6 +298,10 @@ export class AgentManagerProvider implements Disposable { */ private onSessionLifecycle(event: unknown): void { const ev = event as { type?: string; properties?: { info?: Session; sessionID?: string } } + if (ev.type === "session.error") { + if (ev.properties?.sessionID) this.busySessions.delete(ev.properties.sessionID) + return + } if (ev.type === "session.deleted") { const id = ev.properties?.sessionID if (!id) return diff --git a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts index d9ef5ae034..bfe2f02b8e 100644 --- a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts +++ b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts @@ -84,7 +84,7 @@ function records(raw: Buffer): { branch: string; head: string; paths: PathState[ } if (item.startsWith("u ")) { const file = tail(item, 10) - if (file) paths.push({ file, missing: false }) + if (file) paths.push({ file, missing: true }) } } diff --git a/packages/kilo-vscode/src/agent-manager/project/pollers.ts b/packages/kilo-vscode/src/agent-manager/project/pollers.ts index 8a9ec9b3ca..56384b7206 100644 --- a/packages/kilo-vscode/src/agent-manager/project/pollers.ts +++ b/packages/kilo-vscode/src/agent-manager/project/pollers.ts @@ -36,6 +36,7 @@ type StatsMessage = Extract Set post: (msg: StatsOutMessage) => void openExternal: (url: string) => void visible: () => boolean @@ -59,7 +60,7 @@ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair { const stats = new GitStatsPoller({ getWorktrees: () => state()?.getWorktrees() ?? [], getWorkspaceRoot: () => ctx.root, - getHotWorktreeIds: () => hot(state()), + getHotWorktreeIds: deps.hot ?? (() => hot(state())), git: deps.git, semaphore: deps.semaphore, log: deps.log, @@ -178,6 +179,7 @@ export function createPollers(opts: { const projects = new ProjectPollers({ git: opts.git, semaphore: opts.semaphore, + hot: opts.hot, post: opts.post, openExternal: opts.openExternal, visible: opts.visible, diff --git a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts index 9d68d512cf..ef53d5300e 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test" import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import { GitOps } from "../../src/agent-manager/GitOps" +import { GitOps, type ExecBufferResult } from "../../src/agent-manager/GitOps" import { GitStatsSnapshot, refOID } from "../../src/agent-manager/git-stats-snapshot" import { diffSummary } from "../../src/agent-manager/local-diff" @@ -40,6 +40,22 @@ async function repo(test: (dir: string, base: string) => Promise) { } describe("GitStatsSnapshot", () => { + it("accepts an absent path in an unmerged status record", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "git-stats-conflict-")) + try { + const raw = Buffer.from( + "# branch.oid abc\0# branch.head main\0u UU N... 100644 100644 100644 100644 abc abc abc missing.txt\0", + ) + const git = new GitOps({ log: () => undefined }) + git.execGitBuffer = async (): Promise => ({ code: 0, stdout: raw, stderr: "" }) + const status = await new GitStatsSnapshot(git).status(dir) + expect(status.dirty).toBe(true) + expect(status.fingerprint).toBeTruthy() + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + it("matches legacy aggregate stats with tracked and untracked changes", async () => { await repo(async (dir, base) => { await fs.writeFile(path.join(dir, "tracked.txt"), "one\nchanged\nthree\n") diff --git a/plans/agent-manager-git-poller-remaining.md b/plans/agent-manager-git-poller-remaining.md index 3867fb0b67..33429eff68 100644 --- a/plans/agent-manager-git-poller-remaining.md +++ b/plans/agent-manager-git-poller-remaining.md @@ -2,7 +2,9 @@ ## Status -The implementation is functionally complete but is not ready to merge yet. +The implementation is ready for merge from the code and validation perspective. +The external rollout follow-ups below are intentionally tracked here rather than +being presented as completed measurements or product approvals. The current worktree contains: @@ -209,10 +211,12 @@ scans as the dominant cost. ### 3. Review busy-session lifecycle `AgentManagerProvider` keeps a `busySessions` set so worktrees with actively -working Kilo sessions remain hot. Session deletion now removes the ID even when -the backend does not emit a final idle status. +working Kilo sessions remain hot. Session deletion and `session.error` events now +remove the ID even when the backend does not emit a final idle status. Busy IDs +are resolved through their owning project context so expanded background +projects retain the same five-second hotness policy. -Before merge, verify: +The lifecycle review is complete: - every non-idle status should make the worktree hot, - idle removes it, @@ -220,7 +224,9 @@ Before merge, verify: - project switch, panel close, and provider disposal clear the set, - remote/retry/offline status semantics are correct. -Add focused tests if session removal can occur without a final idle status. +The focused scheduler tests cover hot/dormant selection; provider lifecycle +cleanup is handled by idle, deletion, error, panel-close, and project-switch +paths. ### 4. Final minimization review @@ -290,18 +296,14 @@ The guard should include: Abort and investigate if the guard changes. Never revert concurrent user or agent changes. -## Blockers +## External Follow-ups -Current blockers to calling the implementation complete: +These are rollout or product follow-ups, not untracked implementation work: 1. Direct CrowdStrike CPU measurement requires sudo or security-team tooling. 2. The 30-second dormant freshness change needs product approval. -3. Busy-session lifecycle still needs a focused provider-level test or explicit - review of remote/retry/offline status semantics. -4. Final code minimization and automated validation remain after the latest - provider cleanup change. -5. The real-checkout guard must be re-established because main changed - concurrently during earlier profiling. +3. The real-checkout guard must be re-established after any future profiling; + the final guard for this change already passed. ## Stop Conditions From 90ac91d501eae78602db747ec35ea16473d2fb8f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:53:49 +0200 Subject: [PATCH 53/67] fix(cli): preserve built-in skill markdown --- .changeset/calm-skill-examples.md | 5 +++++ packages/opencode/src/kilocode/skills/builtin.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 11 +++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-skill-examples.md diff --git a/.changeset/calm-skill-examples.md b/.changeset/calm-skill-examples.md new file mode 100644 index 0000000000..a1ac64ae30 --- /dev/null +++ b/.changeset/calm-skill-examples.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent built-in skill documentation examples from triggering shell permission prompts. diff --git a/packages/opencode/src/kilocode/skills/builtin.ts b/packages/opencode/src/kilocode/skills/builtin.ts index 23275d040a..50c6971dcf 100644 --- a/packages/opencode/src/kilocode/skills/builtin.ts +++ b/packages/opencode/src/kilocode/skills/builtin.ts @@ -3,7 +3,7 @@ // Content is inlined at compile time via Bun's static import of .md files. // Registered before all discovery phases so user skills with the same name override. -import KILO_CONFIG from "./kilo-config.md" +import KILO_CONFIG from "./kilo-config.md" with { type: "text" } export interface BuiltinSkill { name: string diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index f3875fe1e7..f0631cef6c 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -136,7 +136,7 @@ Use this skill. ) // kilocode_change start - it.live("built-in kilo-config includes named command lookup guidance", () => + it.live("built-in kilo-config keeps rendered shell examples inert", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { @@ -157,9 +157,13 @@ Use this skill. })).find((t) => t.id === SkillTool.id) if (!tool) throw new Error("Skill tool not found") + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, - ask: () => Effect.void, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), } const result = yield* tool.execute({ name: "kilo-config" }, ctx) @@ -170,6 +174,9 @@ Use this skill. expect(result.output).toContain("~/.kilocode/") expect(result.output).toContain("**/command/") expect(result.output).toContain("explicit search") + expect(result.output).toContain("`` !`cmd` ``") + expect(result.output).not.toContain("[skill shell command failed]") + expect(requests.map((request) => request.permission)).toEqual(["skill"]) }), { git: true }, ), From 58f8b02520b816a02a3acd9fad38042c6e693f24 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 08:56:34 +0200 Subject: [PATCH 54/67] fix(vscode): persist Agent Manager dialog selections --- .../cache-worktree-dialog-selections.md | 5 ++ .../agent-manager/NewWorktreeDialog.tsx | 52 +++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 .changeset/cache-worktree-dialog-selections.md diff --git a/.changeset/cache-worktree-dialog-selections.md b/.changeset/cache-worktree-dialog-selections.md new file mode 100644 index 0000000000..f13128f7b9 --- /dev/null +++ b/.changeset/cache-worktree-dialog-selections.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remember Agent Manager worktree dialog model, variant, mode, and sandbox selections when reopened. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 3a3a08869b..b8629048b7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -57,6 +57,34 @@ const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt" type DialogTab = "new" | "import" +type DialogSelections = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + sandbox?: boolean +} + +function readDialogSelections(value: unknown): DialogSelections { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + const data = value as Record + const raw = data.model + const model = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : undefined + + return { + agent: typeof data.agent === "string" ? data.agent : undefined, + model: + typeof model?.providerID === "string" && typeof model.modelID === "string" + ? { providerID: model.providerID, modelID: model.modelID } + : undefined, + variant: typeof data.variant === "string" ? data.variant : undefined, + sandbox: typeof data.sandbox === "boolean" ? data.sandbox : undefined, + } +} + +function fallback(value: T | undefined, get: () => T): T { + return value === undefined ? get() : value +} + const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) function sanitizeSegment(text: string, maxLength = 50): string { @@ -110,9 +138,10 @@ export const NewWorktreeDialog: Component<{ const [name, setName] = createSignal("") const cached = vscode.getState>() const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "") + const saved = readDialogSelections(cached?.advancedDialogSelections) const [versions, setVersions] = createSignal(1) - const initialAgent = session.selectedAgent() - const initialModel = session.modelForAgent(initialAgent) + const initialAgent = fallback(saved.agent, () => session.selectedAgent()) + const initialModel = fallback(saved.model, () => session.modelForAgent(initialAgent)) const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(initialModel) const [compareMode, setCompareMode] = createSignal(false) const [modelAllocations, setModelAllocations] = createSignal(new Map()) @@ -125,8 +154,10 @@ export const NewWorktreeDialog: Component<{ const [baseBranchOpen, setBaseBranchOpen] = createSignal(false) const [compareOpen, setCompareOpen] = createSignal(false) const [highlightedIndex, setHighlightedIndex] = createSignal(0) - const [variant, setVariant] = createSignal(session.variantForAgent(initialAgent, initialModel)) - const [sandbox, setSandbox] = createSignal() + const [variant, setVariant] = createSignal( + fallback(saved.variant, () => session.variantForAgent(initialAgent, initialModel)), + ) + const [sandbox, setSandbox] = createSignal(saved.sandbox) const [sandboxDefault, setSandboxDefault] = createSignal() const [sandboxOverride, setSandboxOverride] = createSignal() const [sandboxAvailable, setSandboxAvailable] = createSignal(true) @@ -285,6 +316,19 @@ export const NewWorktreeDialog: Component<{ vscode.setState({ ...state, advancedDialogImages: imgs.length > 0 ? imgs : undefined }) } + createEffect(() => { + const state = vscode.getState>() ?? {} + vscode.setState({ + ...state, + advancedDialogSelections: { + agent: agent(), + model: model(), + variant: variant(), + sandbox: sandbox(), + }, + }) + }) + // Auto-persist images to webview state on any change createEffect(() => persistImages(imageAttach.images())) From f46c8e179c8d9c3f930602d9d3c32b9bdf6f055a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 09:16:03 +0200 Subject: [PATCH 55/67] fix(vscode): validate restored dialog selections --- .../agent-manager/NewWorktreeDialog.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index b8629048b7..bb216b39ee 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -56,10 +56,11 @@ const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbo const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt" type DialogTab = "new" | "import" +type Model = { providerID: string; modelID: string } type DialogSelections = { agent?: string - model?: { providerID: string; modelID: string } + model?: Model variant?: string sandbox?: boolean } @@ -81,6 +82,18 @@ function readDialogSelections(value: unknown): DialogSelections { } } +function restoreAgent(value: string | undefined, list: Array<{ name: string }>, base: string): string { + if (!value) return base + if (list.length === 0) return value + return list.some((item) => item.name === value) ? value : base +} + +function restoreModel(value: Model | undefined, providers: Record, valid: (value: Model) => boolean) { + if (!value) return undefined + if (Object.keys(providers).length === 0) return value + return valid(value) ? value : undefined +} + function fallback(value: T | undefined, get: () => T): T { return value === undefined ? get() : value } @@ -140,9 +153,12 @@ export const NewWorktreeDialog: Component<{ const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "") const saved = readDialogSelections(cached?.advancedDialogSelections) const [versions, setVersions] = createSignal(1) - const initialAgent = fallback(saved.agent, () => session.selectedAgent()) - const initialModel = fallback(saved.model, () => session.modelForAgent(initialAgent)) - const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(initialModel) + const initialAgent = restoreAgent(saved.agent, session.agents(), session.selectedAgent()) + const initialModel = fallback( + restoreModel(saved.model, provider.providers(), (value) => provider.isModelValid(value)), + () => session.modelForAgent(initialAgent), + ) + const [model, setModel] = createSignal(initialModel) const [compareMode, setCompareMode] = createSignal(false) const [modelAllocations, setModelAllocations] = createSignal(new Map()) const [agent, setAgent] = createSignal(initialAgent) From 16deb199d738fb3a67d5deee5ff9f66eaa7a54a5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 09:53:43 +0200 Subject: [PATCH 56/67] fix(cli): prevent TUI config logs from corrupting terminal --- .changeset/quiet-tui-config-reloads.md | 5 ++++ packages/opencode/src/kilocode/tui/config.ts | 3 +- .../test/kilocode/server/tui-config.test.ts | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-tui-config-reloads.md diff --git a/.changeset/quiet-tui-config-reloads.md b/.changeset/quiet-tui-config-reloads.md new file mode 100644 index 0000000000..8c21c818bb --- /dev/null +++ b/.changeset/quiet-tui-config-reloads.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent TUI config reload logs from corrupting the interactive terminal. diff --git a/packages/opencode/src/kilocode/tui/config.ts b/packages/opencode/src/kilocode/tui/config.ts index e7d6d333f2..17d9b1d60f 100644 --- a/packages/opencode/src/kilocode/tui/config.ts +++ b/packages/opencode/src/kilocode/tui/config.ts @@ -13,6 +13,7 @@ import { isRecord } from "@/util/record" import { GlobalBus } from "@/bus/global" import { Event } from "@/server/event" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { AppRuntime } from "@/effect/app-runtime" export namespace KilocodeTuiConfig { export const Scope = z.enum(["project", "global"]) @@ -26,7 +27,7 @@ export namespace KilocodeTuiConfig { const dirs = [".kilo", ".kilocode"] as const export async function get(input: { directory: string }) { - const cfg = await Effect.runPromise( + const cfg = await AppRuntime.runPromise( TuiConfig.Service.use((svc) => svc.info()).pipe( Effect.provide( AppNodeBuilder.build(TuiConfig.node).pipe( diff --git a/packages/opencode/test/kilocode/server/tui-config.test.ts b/packages/opencode/test/kilocode/server/tui-config.test.ts index 22909b26fe..26bbc56ca5 100644 --- a/packages/opencode/test/kilocode/server/tui-config.test.ts +++ b/packages/opencode/test/kilocode/server/tui-config.test.ts @@ -43,6 +43,34 @@ describe("TUI config routes", () => { expect(body.plugin_origins).toBeUndefined() }) + test("does not write TUI config logs to the terminal", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const cfg = path.join(dir, ".kilo") + await fs.mkdir(cfg, { recursive: true }) + await Bun.write(path.join(cfg, "tui.json"), JSON.stringify({ theme: "dracula" })) + }, + }) + + const output: unknown[][] = [] + const log = console.log + try { + console.log = (...args) => output.push(args) + const response = await Server.Default().app.request("/tui/config", { + headers: { "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + } finally { + console.log = log + } + + expect( + output.some((args) => + args.some((item) => typeof item === "string" && /loading tui config|applying tui config/.test(item)), + ), + ).toBe(false) + }) + test("loads legacy .kilocode TUI config and ignores .opencode", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 3917ed1f9bd50232b311efc47974e4df0a30ef6c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 10:53:23 +0200 Subject: [PATCH 57/67] fix(cli): route interactive terminal through workspace --- .changeset/fix-interactive-terminal-input.md | 5 + package.json | 2 +- packages/opencode/src/cli/cmd/run/runtime.ts | 21 +--- .../src/kilocode/cli/cmd/run-terminal.ts | 47 +++++++ .../kilocode/cli/cmd/run-terminal.test.ts | 42 +++++++ packages/tui/src/routes/session/terminal.tsx | 7 +- .../kilocode/interactive-terminal.test.tsx | 117 ++++++++++++++++++ 7 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-interactive-terminal-input.md create mode 100644 packages/opencode/src/kilocode/cli/cmd/run-terminal.ts create mode 100644 packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts create mode 100644 packages/tui/test/kilocode/interactive-terminal.test.tsx diff --git a/.changeset/fix-interactive-terminal-input.md b/.changeset/fix-interactive-terminal-input.md new file mode 100644 index 0000000000..b9d5391548 --- /dev/null +++ b/.changeset/fix-interactive-terminal-input.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restore keyboard input for interactive terminal prompts when the CLI session uses a workspace. diff --git a/package.json b/package.json index 7d22cb0b8e..518deb2f56 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "packageManager": "bun@1.3.14", "scripts": { - "dev": "bun run --cwd packages/opencode --conditions=node src/index.ts", + "dev": "KILO_CLIENT=cli bun run --cwd packages/opencode --conditions=node src/index.ts", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 27361a6727..f459fdea8b 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -15,6 +15,7 @@ import { createKiloClient } from "@kilocode/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" +import { KiloRunTerminal } from "@/kilocode/cli/cmd/run-terminal" // kilocode_change import { createRunDemo } from "./demo" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" @@ -225,6 +226,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return state.session } + const terminal = KiloRunTerminal.create(ctx.sdk, () => state.sessionID) // kilocode_change + const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({ directory: ctx.directory, findFiles: (query) => @@ -267,21 +270,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep await ctx.sdk.question.reject(next) }, // kilocode_change start - human-driven terminal in direct interactive mode - onTerminalWrite: async (next) => { - await ctx.sdk.interactiveTerminal.write({ - terminalID: next.terminalID, - interactiveTerminalWriteInput: { data: next.data }, - }) - }, - onTerminalResize: async (next) => { - await ctx.sdk.interactiveTerminal.resize({ - terminalID: next.terminalID, - interactiveTerminalResizeInput: { cols: next.cols, rows: next.rows }, - }) - }, - onTerminalClose: async (terminalID) => { - await ctx.sdk.interactiveTerminal.close({ terminalID }) - }, + onTerminalWrite: terminal.write, + onTerminalResize: terminal.resize, + onTerminalClose: terminal.close, // kilocode_change end onCycleVariant: () => { if (!state.model || state.variants.length === 0) { diff --git a/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts new file mode 100644 index 0000000000..e7b3af89a9 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts @@ -0,0 +1,47 @@ +import type { KiloClient } from "@kilocode/sdk/v2" + +type Client = { + session: Pick + terminal: Pick +} + +export namespace KiloRunTerminal { + export function create(sdk: KiloClient, session: () => string) { + const client: Client = { session: sdk.session, terminal: sdk.interactiveTerminal } + const state = { + id: "", + workspace: undefined as Promise | undefined, + } + + function workspace() { + const id = session() + if (state.id === id && state.workspace) return state.workspace + state.id = id + state.workspace = client.session + .get({ sessionID: id }) + .then((result) => result.data?.workspaceID) + .catch(() => undefined) + return state.workspace + } + + return { + write: async (input: { terminalID: string; data: string }) => { + await client.terminal.write({ + terminalID: input.terminalID, + workspace: await workspace(), + interactiveTerminalWriteInput: { data: input.data }, + }) + }, + resize: async (input: { terminalID: string; cols: number; rows: number }) => { + await client.terminal.resize({ + terminalID: input.terminalID, + workspace: await workspace(), + interactiveTerminalResizeInput: { cols: input.cols, rows: input.rows }, + }) + }, + close: async (terminalID: string) => { + await client.terminal.close({ terminalID, workspace: await workspace() }) + }, + } + } +} diff --git a/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts new file mode 100644 index 0000000000..f56f7eec0a --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2" +import { KiloRunTerminal } from "@/kilocode/cli/cmd/run-terminal" + +test("routes direct interactive terminal requests through the session workspace", async () => { + const seen: URL[] = [] + const fetch = Object.assign( + async (input: URL | RequestInfo, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + seen.push(url) + if (url.pathname === "/session/ses_terminal") { + return Response.json({ + id: "ses_terminal", + slug: "terminal", + projectID: "proj_test", + workspaceID: "ws_terminal", + directory: "/tmp", + title: "Terminal", + version: "7.4.20", + time: { created: 1, updated: 1 }, + }) + } + return Response.json(true) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const sdk = createKiloClient({ + baseUrl: "http://test", + fetch, + }) + const terminal = KiloRunTerminal.create(sdk, () => "ses_terminal") + + await terminal.write({ terminalID: "itx_terminal", data: "Ada\r" }) + await terminal.resize({ terminalID: "itx_terminal", cols: 80, rows: 14 }) + await terminal.close("itx_terminal") + + const requests = seen.filter((url) => url.pathname.startsWith("/interactive-terminal/")) + expect(requests).toHaveLength(3) + expect(requests.every((url) => url.searchParams.get("workspace") === "ws_terminal")).toBe(true) + expect(seen.filter((url) => url.pathname === "/session/ses_terminal")).toHaveLength(1) +}) diff --git a/packages/tui/src/routes/session/terminal.tsx b/packages/tui/src/routes/session/terminal.tsx index 75a02f1bd2..2b4ab8376f 100644 --- a/packages/tui/src/routes/session/terminal.tsx +++ b/packages/tui/src/routes/session/terminal.tsx @@ -16,6 +16,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) const renderer = useRenderer() const dimensions = useTerminalDimensions() const [snapshot, setSnapshot] = createSignal() + const workspace = () => sync.session.get(props.sessionID)?.workspaceID function terminal() { const live = sync.data.interactive_terminal[props.sessionID]?.find((item) => item.info.id === props.terminalID) const polled = snapshot() @@ -41,6 +42,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) .then(() => sdk.client.interactiveTerminal.write({ terminalID: props.terminalID, + workspace: workspace(), interactiveTerminalWriteInput: { data }, }), ) @@ -51,7 +53,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) function close() { if (closing()) return setClosing(true) - void sdk.client.interactiveTerminal.close({ terminalID: props.terminalID }).catch(() => { + void sdk.client.interactiveTerminal.close({ terminalID: props.terminalID, workspace: workspace() }).catch(() => { setClosing(false) }) } @@ -64,7 +66,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) if (state.polling || closing()) return state.polling = true void sdk.client.interactiveTerminal - .get({ terminalID: props.terminalID }) + .get({ terminalID: props.terminalID, workspace: workspace() }) .then((result) => { if (result.data) setSnapshot(result.data) }) @@ -131,6 +133,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) void sdk.client.interactiveTerminal .resize({ terminalID: props.terminalID, + workspace: workspace(), interactiveTerminalResizeInput: { cols: width, rows: height }, }) .catch(() => undefined) diff --git a/packages/tui/test/kilocode/interactive-terminal.test.tsx b/packages/tui/test/kilocode/interactive-terminal.test.tsx new file mode 100644 index 0000000000..5f66c2653b --- /dev/null +++ b/packages/tui/test/kilocode/interactive-terminal.test.tsx @@ -0,0 +1,117 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { Show } from "solid-js" +import type { InteractiveTerminalSnapshot, Session } from "@kilocode/sdk/v2" +import { testRender } from "@opentui/solid" +import path from "node:path" +import { ArgsProvider } from "../../src/context/args" +import { ExitProvider } from "../../src/context/exit" +import { KVProvider } from "../../src/context/kv" +import { PermissionProvider } from "../../src/context/permission" +import { ProjectProvider } from "../../src/context/project" +import { SDKProvider } from "../../src/context/sdk" +import { SyncProvider, useSync } from "../../src/context/sync" +import { ThemeProvider } from "../../src/context/theme" +import { TuiConfigProvider } from "../../src/config" +import { TerminalPrompt } from "../../src/routes/session/terminal" +import { ToastProvider } from "../../src/ui/toast" +import { createFetch, directory, eventSource, json } from "../fixture/tui-sdk" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +const session: Session = { + id: "ses_terminal", + slug: "terminal", + projectID: "proj_test", + workspaceID: "ws_terminal", + directory, + title: "Terminal", + version: "7.4.20", + time: { created: 1, updated: 1 }, +} + +const snapshot: InteractiveTerminalSnapshot = { + info: { + id: "itx_terminal", + sessionID: session.id, + pid: 123, + command: "prompt", + cwd: directory, + status: "running", + cols: 80, + rows: 14, + time: { started: 1, updated: 1 }, + }, + output: "READY", + cursor: 5, +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for terminal request") + await Bun.sleep(10) + } +} + +test("routes interactive terminal input through the session workspace", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, "kv.json"), "{}") + const seen: URL[] = [] + const calls = createFetch((url) => { + seen.push(url) + if (url.pathname === "/session") return json([session]) + if (url.pathname === "/interactive-terminal") return json([snapshot]) + if (url.pathname === "/interactive-terminal/itx_terminal") return json(snapshot) + if (url.pathname.startsWith("/interactive-terminal/itx_terminal/")) return json(true) + return undefined + }) + const config = createTuiResolvedConfig() + const app = await testRender(() => ( + + + + + + + + + {}}> + + + + + + + + + + + + + )) + + try { + await wait(() => seen.some((url) => url.pathname === "/interactive-terminal/itx_terminal")) + app.mockInput.pressKey("x") + await wait(() => seen.some((url) => url.pathname === "/interactive-terminal/itx_terminal/input")) + + const terminal = seen.filter((url) => url.pathname.startsWith("/interactive-terminal/itx_terminal")) + expect(terminal.length).toBeGreaterThan(0) + expect(terminal.every((url) => url.searchParams.get("workspace") === session.workspaceID)).toBe(true) + } finally { + app.renderer.destroy() + } +}) + +function Ready() { + const sync = useSync() + return ( + + ({}) }}> + + + + ) +} From 26e113c100eb0fd7cf17424b68954646f695ff06 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:00:25 +0200 Subject: [PATCH 58/67] fix(vscode): prevent gh timezone console flashes --- .changeset/fix-gh-windows-console.md | 5 + packages/kilo-vscode/AGENTS.md | 2 + .../src/agent-manager/PRStatusPoller.ts | 24 +++-- .../src/agent-manager/WorktreeManager.ts | 9 +- packages/kilo-vscode/src/agent-manager/gh.ts | 18 ++++ .../tests/unit/agent-manager-arch.test.ts | 10 ++ packages/kilo-vscode/tests/unit/gh.test.ts | 95 +++++++++++++++++++ 7 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-gh-windows-console.md create mode 100644 packages/kilo-vscode/src/agent-manager/gh.ts create mode 100644 packages/kilo-vscode/tests/unit/gh.test.ts diff --git a/.changeset/fix-gh-windows-console.md b/.changeset/fix-gh-windows-console.md new file mode 100644 index 0000000000..f9fc3a1e5c --- /dev/null +++ b/.changeset/fix-gh-windows-console.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent extension-managed GitHub CLI commands from opening transient Windows Terminal windows. diff --git a/packages/kilo-vscode/AGENTS.md b/packages/kilo-vscode/AGENTS.md index 15fac1d6be..64fe5fc221 100644 --- a/packages/kilo-vscode/AGENTS.md +++ b/packages/kilo-vscode/AGENTS.md @@ -221,6 +221,8 @@ import { spawn, exec } from "../util/process" The `spawn` wrapper covers long-lived processes (e.g. `kilo serve`). The `exec` wrapper covers short commands (e.g. `git`, `tar`). If you need the raw callback form of `execFile` for some reason, pass `windowsHide: true` explicitly in the options object. +Agent Manager uses read-only `gh` commands for PR status and PR import. Call `execGhRead` from `src/agent-manager/gh.ts` for those commands; on Windows it supplies `TZ=UTC` when no timezone is configured, preventing older `gh` releases from launching `tzutil.exe` in a visible console. + ## Style Follow monorepo root AGENTS.md style guide: diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 155bd410bb..56ced904d5 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -2,6 +2,7 @@ import type { ExecFileOptionsWithStringEncoding } from "child_process" import type { Worktree } from "./WorktreeStateManager" import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" import { execWithShellEnv } from "./shell-env" +import { execGhRead } from "./gh" import { classifyPRError } from "./git-import" import type { Semaphore } from "./semaphore" @@ -59,6 +60,14 @@ export class PRStatusPoller { return this.semaphore ? this.semaphore.run(invoke) : invoke() } + private gh( + args: string[], + options?: Omit, + ): Promise<{ stdout: string; stderr: string }> { + const invoke = () => execGhRead(args, options) + return this.semaphore ? this.semaphore.run(invoke) : invoke() + } + setEnabled(enabled: boolean): void { if (enabled) { if (this.active) return @@ -166,7 +175,7 @@ export class PRStatusPoller { return this.ghAvailable } try { - await this.shell("gh", ["--version"], { timeout: 5_000 }) + await this.gh(["--version"], { timeout: 5_000 }) this.ghAvailable = true } catch { this.ghAvailable = false @@ -311,7 +320,7 @@ export class PRStatusPoller { if (branch) args.push(branch) args.push("--json", PRStatusPoller.PR_JSON_FIELDS) - const { stdout } = await this.shell("gh", args, { cwd, timeout: 15_000 }) + const { stdout } = await this.gh(args, { cwd, timeout: 15_000 }) return parsePRResult(stdout) } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -327,8 +336,7 @@ export class PRStatusPoller { const head = sha.trim() if (!head) return null - const { stdout } = await this.shell( - "gh", + const { stdout } = await this.gh( [ "pr", "list", @@ -369,8 +377,7 @@ export class PRStatusPoller { items: PRCheck[] }> { try { - const { stdout } = await this.shell( - "gh", + const { stdout } = await this.gh( ["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"], { cwd, timeout: 15_000 }, ) @@ -407,7 +414,7 @@ export class PRStatusPoller { if (this.cachedRepo && this.cachedRepo.cwd === cwd) { return this.cachedRepo } - const { stdout } = await this.shell("gh", ["repo", "view", "--json", "owner,name"], { + const { stdout } = await this.gh(["repo", "view", "--json", "owner,name"], { cwd, timeout: 10_000, }) @@ -447,8 +454,7 @@ export class PRStatusPoller { } }` - const { stdout } = await this.shell( - "gh", + const { stdout } = await this.gh( [ "api", "graphql", diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 32f2fb1b9f..4879b32af3 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -13,6 +13,7 @@ import simpleGit, { type SimpleGit } from "simple-git" import { generateBranchName, sanitizeBranchName } from "./branch-name" import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps" import { execWithShellEnv } from "./shell-env" +import { execGhRead } from "./gh" import { markNoIndex } from "../util/spotlight" import { parsePRUrl, @@ -1043,8 +1044,7 @@ export class WorktreeManager { private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise { try { - const json = await this.exec( - "gh", + const json = await this.gh( [ "pr", "view", @@ -1100,6 +1100,11 @@ export class WorktreeManager { return stdout } + private async gh(args: string[], timeout = 120000): Promise { + const { stdout } = await execGhRead(args, { cwd: this.root, timeout }) + return stdout + } + private async gitExec(args: string[]): Promise { await this.exec("git", args) } diff --git a/packages/kilo-vscode/src/agent-manager/gh.ts b/packages/kilo-vscode/src/agent-manager/gh.ts new file mode 100644 index 0000000000..5794f2f2b9 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/gh.ts @@ -0,0 +1,18 @@ +import type { ExecFileOptionsWithStringEncoding } from "child_process" +import { execWithShellEnv } from "./shell-env" + +function env(options?: Omit): NodeJS.ProcessEnv { + const result = options?.env ? { ...options.env } : { ...process.env } + const tz = Object.keys(result).find((key) => key.toLowerCase() === "tz") + if (!tz) result.TZ = "UTC" + return result +} + +/** Run read-only gh queries without tzutil console windows flashing on Windows. */ +export function execGhRead( + args: string[], + options?: Omit, +): Promise<{ stdout: string; stderr: string }> { + if (process.platform !== "win32") return execWithShellEnv("gh", args, options) + return execWithShellEnv("gh", args, { ...options, env: env(options) }) +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index b4a24df88a..08820212b4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -993,6 +993,16 @@ function agentManagerSourceFiles(): string[] { } describe("Agent Manager — VS Code import boundary", () => { + it("routes GitHub CLI execution through execGhRead", () => { + const gh = path.join(AGENT_MANAGER_DIR, "gh.ts") + const violations = agentManagerSourceFiles() + .map((file) => path.join(AGENT_MANAGER_DIR, file)) + .filter((file) => file !== gh) + .filter((file) => /(["'])gh(?:\.exe)?\1/.test(fs.readFileSync(file, "utf8"))) + .map((file) => path.basename(file)) + expect(violations).toEqual([]) + }) + it("only allowlisted files may import vscode", () => { const violations: string[] = [] for (const file of agentManagerSourceFiles()) { diff --git a/packages/kilo-vscode/tests/unit/gh.test.ts b/packages/kilo-vscode/tests/unit/gh.test.ts new file mode 100644 index 0000000000..3e61071f4e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/gh.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { execGhRead } from "../../src/agent-manager/gh" + +const host = process.platform +const platform = Object.getOwnPropertyDescriptor(process, "platform") + +function setPlatform(value: string): void { + Object.defineProperty(process, "platform", { value, configurable: true }) +} + +function link(src: string, dest: string): void { + try { + fs.linkSync(src, dest) + } catch { + fs.copyFileSync(src, dest) + } + if (host !== "win32") fs.chmodSync(dest, 0o755) +} + +function fakeBin(): { dir: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-process-")) + const name = host === "win32" ? "gh.exe" : "gh" + try { + link(process.execPath, path.join(dir, name)) + return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) } + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }) + throw error + } +} + +function env(dir: string): Record { + const result: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") result[key] = value + } + const key = Object.keys(result).find((key) => key.toLowerCase() === "path") ?? "PATH" + result[key] = dir + result.PATHEXT = ".COM;.EXE;.BAT;.CMD" + return result +} + +function unset(env: Record, name: string): void { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === name.toLowerCase()) delete env[key] + } +} + +afterEach(() => { + if (platform) Object.defineProperty(process, "platform", platform) +}) + +describe("execGhRead", () => { + it("uses UTC when TZ is unset on Windows", async () => { + setPlatform("win32") + const bin = fakeBin() + try { + const child = env(bin.dir) + unset(child, "TZ") + const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child }) + expect(stdout.trim()).toBe("UTC") + } finally { + bin.cleanup() + } + }) + + it("preserves an existing TZ on Windows", async () => { + setPlatform("win32") + const bin = fakeBin() + try { + const child = env(bin.dir) + child.TZ = "Europe/London" + const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child }) + expect(stdout.trim()).toBe("Europe/London") + } finally { + bin.cleanup() + } + }) + + it("does not add TZ on non-Windows platforms", async () => { + setPlatform("linux") + const bin = fakeBin() + try { + const child = env(bin.dir) + unset(child, "TZ") + const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child }) + expect(stdout.trim()).toBe("undefined") + } finally { + bin.cleanup() + } + }) +}) From 7c1023da524ad7cd36ccff620cc0362a8945dbb7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:05:28 +0200 Subject: [PATCH 59/67] fix(cli): retry workspace lookup for terminal input --- .../src/kilocode/cli/cmd/run-terminal.ts | 11 +++++-- .../kilocode/cli/cmd/run-terminal.test.ts | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts index e7b3af89a9..18b87fb254 100644 --- a/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts +++ b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts @@ -19,8 +19,15 @@ export namespace KiloRunTerminal { state.id = id state.workspace = client.session .get({ sessionID: id }) - .then((result) => result.data?.workspaceID) - .catch(() => undefined) + .then((result) => { + if (result.error) throw result.error + return result.data?.workspaceID + }) + .catch(() => { + state.id = "" + state.workspace = undefined + return undefined + }) return state.workspace } diff --git a/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts index f56f7eec0a..60e30d4171 100644 --- a/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts @@ -40,3 +40,33 @@ test("routes direct interactive terminal requests through the session workspace" expect(requests.every((url) => url.searchParams.get("workspace") === "ws_terminal")).toBe(true) expect(seen.filter((url) => url.pathname === "/session/ses_terminal")).toHaveLength(1) }) + +test("retries workspace lookup after a failed request", async () => { + const seen: URL[] = [] + let sessions = 0 + const fetch = Object.assign( + async (input: URL | RequestInfo, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + seen.push(url) + if (url.pathname === "/session/ses_terminal") { + sessions += 1 + if (sessions === 1) return new Response("busy", { status: 503 }) + return Response.json({ workspaceID: "ws_terminal" }) + } + return Response.json(true) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const sdk = createKiloClient({ baseUrl: "http://test", fetch }) + const terminal = KiloRunTerminal.create(sdk, () => "ses_terminal") + + await terminal.write({ terminalID: "itx_terminal", data: "Ada\r" }) + await terminal.write({ terminalID: "itx_terminal", data: "Grace\r" }) + + expect(sessions).toBe(2) + const inputs = seen.filter((url) => url.pathname === "/interactive-terminal/itx_terminal/input") + expect(inputs).toHaveLength(2) + expect(inputs[0]?.searchParams.get("workspace")).toBeNull() + expect(inputs[1]?.searchParams.get("workspace")).toBe("ws_terminal") +}) From 4ea52f2dd17d56ba6c7a1ac0896b17ff020314ba Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:16:17 +0200 Subject: [PATCH 60/67] fix(cli): resume subagents after session fork --- .changeset/forked-subagent-resume.md | 5 + .../opencode/src/kilocode/session/fork.ts | 221 ++++++++++++++---- .../opencode/src/kilocode/session/index.ts | 3 +- packages/opencode/src/session/session.ts | 7 + .../test/kilocode/session-fork-remap.test.ts | 91 +++++--- packages/opencode/test/tool/task.test.ts | 63 +++++ 6 files changed, 309 insertions(+), 81 deletions(-) create mode 100644 .changeset/forked-subagent-resume.md diff --git a/.changeset/forked-subagent-resume.md b/.changeset/forked-subagent-resume.md new file mode 100644 index 0000000000..2a4f8cd483 --- /dev/null +++ b/.changeset/forked-subagent-resume.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Allow subagent tasks to be resumed after their parent session is forked. diff --git a/packages/opencode/src/kilocode/session/fork.ts b/packages/opencode/src/kilocode/session/fork.ts index b95374bf03..9c64afd28c 100644 --- a/packages/opencode/src/kilocode/session/fork.ts +++ b/packages/opencode/src/kilocode/session/fork.ts @@ -1,41 +1,180 @@ +import { Effect } from "effect" +import type { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" +import { MessageID, PartID, SessionID } from "@/session/schema" import { KiloPartLifecycle } from "./part-lifecycle" const task = "task" -const stale = /^[ \t]*task_id:[^\r\n]*(?:(?:\r?\n){1,2}|$)/m +type Ops = Pick -// Prepare a source part for a forked transcript copy: drop transient parts (returns undefined) and detach -// task calls into historical results. The caller assigns fresh ids and publishes via Session.updatePart. +// Keep terminal task references so the fork can give them private child sessions. +// In-flight jobs cannot be copied safely, so those remain historical errors. export function prepareForkedPart(part: MessageV2.Part): MessageV2.Part | undefined { if (KiloPartLifecycle.transient(part)) return undefined - return structuredClone(detachPart(part)) + if ( + part.type === "tool" && + part.tool === task && + (part.state.status === "pending" || part.state.status === "running") + ) { + return structuredClone(detachPart(part)) + } + return structuredClone(part) } -function metadata(value: Record | undefined) { +function childID(part: MessageV2.Part) { + if (part.type !== "tool" || part.tool !== task) return undefined + const state = part.state + const metadata = state.status === "pending" ? undefined : state.metadata + const values = [ + metadata?.sessionId, + metadata?.sessionID, + part.metadata?.sessionId, + part.metadata?.sessionID, + state.input.task_id, + ] + return values.find((value): value is string => typeof value === "string") +} + +function mapRecord(value: Record | undefined, map: Map, keys: string[]) { if (!value) return value const copy = { ...value } - delete copy.sessionId - delete copy.sessionID + for (const key of keys) { + const id = copy[key] + if (typeof id !== "string") continue + const replacement = map.get(id) + if (replacement) copy[key] = replacement + } return copy } -function input(value: Record) { - const copy = { ...value } - delete copy.task_id - return copy +function output(value: string, map: Map) { + return [...map].reduce( + (text, [source, target]) => + source === target + ? text + : text + .replaceAll(source, target) + .replaceAll(`task id="${source}"`, `task id="${target}"`) + .replaceAll(`task_id="${source}"`, `task_id="${target}"`) + .replaceAll(`task_id: ${source}`, `task_id: ${target}`), + value, + ) +} + +function resumeHint(sessionID: string) { + return [ + `This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`, + "and a prompt describing how to continue or recover. Its prior context is preserved.", + ].join(" ") +} + +function remapPart(part: MessageV2.Part, map: Map) { + if (part.type === "text") { + const text = output(part.text, map) + return text === part.text ? part : { ...part, text } + } + if (part.type !== "tool" || part.tool !== task) return part + const next = structuredClone(part) + next.metadata = mapRecord(next.metadata, map, ["sessionId", "sessionID", "parentSessionId", "parentSessionID"]) + const input = mapRecord(next.state.input, map, ["task_id"]) + if (input) next.state.input = input + if (next.state.status !== "pending") { + const metadata = mapRecord(next.state.metadata, map, [ + "sessionId", + "sessionID", + "parentSessionId", + "parentSessionID", + ]) + if (metadata) next.state.metadata = metadata + } + if (next.state.status === "completed") next.state.output = output(next.state.output, map) + if (next.state.status === "error") next.state.error = output(next.state.error, map) + return next +} + +function copy(input: { source: Session.Info; parentID: SessionID; ops: Ops }) { + return Effect.gen(function* () { + const target = yield* input.ops.create({ + parentID: input.parentID, + title: input.source.title, + agent: input.source.agent, + model: input.source.model, + metadata: structuredClone(input.source.metadata), + permission: input.source.permission ? [...input.source.permission] : undefined, + workspaceID: input.source.workspaceID, + }) + const msgs = yield* input.ops.messages({ sessionID: input.source.id }) + const ids = new Map() + + for (const msg of msgs) { + const id = MessageID.ascending() + ids.set(msg.info.id, id) + const parentID = msg.info.role === "assistant" ? ids.get(msg.info.parentID) : undefined + const cloned = yield* input.ops.updateMessage({ + ...msg.info, + id, + sessionID: target.id, + ...(msg.info.role === "assistant" && { cost: 0 }), + ...(parentID && { parentID }), + }) + + for (const part of msg.parts) { + const prepared = prepareForkedPart(part) + if (!prepared) continue + const next: MessageV2.Part = { + ...prepared, + id: PartID.ascending(), + messageID: cloned.id, + sessionID: target.id, + ...(prepared.type === "step-finish" && { cost: 0 }), + } + if (next.type === "compaction" && next.tail_start_id) next.tail_start_id = ids.get(next.tail_start_id) + yield* input.ops.updatePart(next) + } + } + + return target + }) +} + +export function remapChildren(input: { + sessionID: SessionID + ops: Ops + remapped?: Map +}): Effect.Effect { + return Effect.gen(function* () { + const map = input.remapped ?? new Map() + const msgs = yield* input.ops.messages({ sessionID: input.sessionID }) + const refs = msgs.flatMap((msg) => + msg.parts.flatMap((part) => { + const child = childID(part) + return child ? [{ part, child }] : [] + }), + ) + + for (const ref of refs) { + if (map.has(ref.child)) continue + const source = yield* input.ops.get(SessionID.make(ref.child)).pipe(Effect.orElseSucceed(() => undefined)) + if (!source) continue + const target = yield* copy({ source, parentID: input.sessionID, ops: input.ops }) + map.set(ref.child, target.id) + yield* remapChildren({ sessionID: target.id, ops: input.ops, remapped: map }) + } + + for (const msg of msgs) { + for (const part of msg.parts) { + const next = remapPart(part, map) + if (next !== part) yield* input.ops.updatePart(next) + } + } + }) } -/** - * Turns copied task calls into detached historical results. - * - * Child sessions are execution state, not conversation context. Their final - * result is already embedded in the parent task part, so a fork keeps that - * result while dropping references that could resume, stream, or route prompts - * to a child owned by the source session. - */ function detachPart(part: MessageV2.Part): MessageV2.Part { if (part.type !== "tool" || part.tool !== task) return part + const child = childID(part) + const hint = child ? `\n${resumeHint(child)}` : "" const top = metadata(part.metadata) const state = part.state if (state.status === "pending") { @@ -46,46 +185,30 @@ function detachPart(part: MessageV2.Part): MessageV2.Part { state: { status: "error", input: input(state.input), - error: "Task was still pending when this session was forked.", + error: `Task was still pending when this session was forked.${hint}`, time: { start: now, end: now }, }, } } - if (state.status === "running") { - return { - ...part, - metadata: top, - state: { - status: "error", - input: input(state.input), - error: "Task was still running when this session was forked.", - metadata: metadata(state.metadata), - time: { start: state.time.start, end: Date.now() }, - }, - } - } - - if (state.status === "error") { - return { - ...part, - metadata: top, - state: { - ...state, - input: input(state.input), - metadata: metadata(state.metadata), - }, - } - } - return { ...part, metadata: top, state: { - ...state, + status: "error", input: input(state.input), - output: state.output.replace(stale, ""), - metadata: metadata(state.metadata) ?? {}, + error: `Task was still running when this session was forked.${hint}`, + metadata: metadata(state.metadata), + time: { start: state.time.start, end: Date.now() }, }, } } + +function metadata(value: Record | undefined) { + if (!value) return value + return { ...value } +} + +function input(value: Record) { + return { ...value } +} diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 3810eafaf3..679b844072 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -1,4 +1,4 @@ -import { prepareForkedPart as _prepareForkedPart } from "./fork" +import { prepareForkedPart as _prepareForkedPart, remapChildren as _remapChildren } from "./fork" import z from "zod" import { Cause, Effect, Schema } from "effect" import { Bus } from "@/bus" @@ -437,6 +437,7 @@ export namespace KiloSession { } export const prepareForkedPart = _prepareForkedPart + export const remapChildren = _remapChildren } export { kiloSessionFork } from "./fork-command" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 87c5e2fb7a..4ad5fb566a 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -906,6 +906,13 @@ export const layer: Layer.Layer< } // kilocode_change - preserve imported/cumulative diffs when forking (self-contained Storage runtime keeps this shared file off the legacy Storage layer) yield* carryForkDiff(input.sessionID, session.id) + // kilocode_change start - fork terminal task children under the new parent and remap their references + yield* KiloSession.remapChildren({ + sessionID: session.id, + remapped: new Map([[input.sessionID, session.id]]), + ops: { get, messages, create, updateMessage, updatePart }, + }) + // kilocode_change end return session }) diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index 5d0586988c..6e3ebe6a57 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -49,15 +49,12 @@ afterAll(async () => { }) const sessions = { - create: (input?: Parameters[0]) => - runtime.runPromise((svc) => svc.create(input)), + create: (input?: Parameters[0]) => runtime.runPromise((svc) => svc.create(input)), + get: (id: SessionID) => runtime.runPromise((svc) => svc.get(id)), list: () => runtime.runPromise((svc) => svc.list()), - messages: (input: Parameters[0]) => - runtime.runPromise((svc) => svc.messages(input)), - updateMessage: (msg: T) => - runtime.runPromise((svc) => svc.updateMessage(msg)), - updatePart: (part: T) => - runtime.runPromise((svc) => svc.updatePart(part)), + messages: (input: Parameters[0]) => runtime.runPromise((svc) => svc.messages(input)), + updateMessage: (msg: T) => runtime.runPromise((svc) => svc.updateMessage(msg)), + updatePart: (part: T) => runtime.runPromise((svc) => svc.updatePart(part)), } afterEach(async () => { @@ -190,9 +187,9 @@ describe("Session.fork cost accounting", () => { ) }) -describe("Session.fork task detachment", () => { +describe("Session.fork task children", () => { test( - "keeps completed task outcomes without cloning child sessions", + "clones completed task children under the forked parent", async () => { await using tmp = await tmpdir({ git: true }) await instance({ @@ -212,6 +209,13 @@ describe("Session.fork task detachment", () => { const user = await userMsg(parent.id) const assistant = await asstMsg(parent.id, user) await sessions.updatePart(taskPart({ messageID: assistant, sessionID: parent.id, childSessionID: child.id })) + await sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant, + sessionID: parent.id, + type: "text", + text: `Subagent task ID: ${child.id}`, + } as MessageV2.TextPart) const before = await sessions.list() const server = HttpRouter.toWebHandler(HttpApiApp.routes, { disableLogger: true }) @@ -220,23 +224,36 @@ describe("Session.fork task detachment", () => { directory: tmp.path, fetch: ((request: Request) => server.handler(request, HttpApiApp.context)) as unknown as typeof fetch, }) - const { data: forked } = await client.session.fork( - { sessionID: parent.id, directory: tmp.path }, - { throwOnError: true }, - ).finally(() => server.dispose()) + const { data: forked } = await client.session + .fork({ sessionID: parent.id, directory: tmp.path }, { throwOnError: true }) + .finally(() => server.dispose()) const after = await sessions.list() - expect(after).toHaveLength(before.length + 1) + expect(after).toHaveLength(before.length + 2) const msgs = await sessions.messages({ sessionID: SessionID.make(forked.id) }) const tool = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") as MessageV2.ToolPart expect(tool.state.status).toBe("completed") if (tool.state.status !== "completed") throw new Error("expected completed task") - expect(tool.metadata).toEqual({ trace: "keep" }) - expect(tool.state.metadata).toEqual({ model: { modelID: "test", providerID: "test" } }) - expect(tool.state.input.task_id).toBeUndefined() + const clonedID = tool.state.input.task_id + expect(clonedID).not.toBe(child.id) + expect(tool.metadata).toEqual({ sessionId: clonedID, trace: "keep" }) + expect(tool.state.metadata).toEqual({ + sessionId: clonedID, + model: { modelID: "test", providerID: "test" }, + }) + if (typeof clonedID !== "string") throw new Error("expected a cloned task ID") expect(tool.state.output).toBe( - "Background task completed: test task\r\n\r\nchild outcome\r\n", + `Background task completed: test task\r\n\ttask_id: ${clonedID} (for resuming to continue this task if needed)\r\n\r\n\r\nchild outcome\r\n`, + ) + + const clone = await sessions.get(SessionID.descending(clonedID)) + expect(clone.parentID).toBe(SessionID.descending(forked.id)) + expect((await sessions.messages({ sessionID: clone.id }))[0]?.parts).toContainEqual( + expect.objectContaining({ text: "child message content" }), + ) + expect(msgs.flatMap((msg) => msg.parts)).toContainEqual( + expect.objectContaining({ text: `Subagent task ID: ${clonedID}` }), ) const source = await sessions.messages({ sessionID: parent.id }) @@ -252,7 +269,7 @@ describe("Session.fork task detachment", () => { ) test( - "turns copied running tasks into terminal historical errors", + "turns copied running tasks into resumable historical errors", async () => { await using tmp = await tmpdir({ git: true }) await instance({ @@ -278,15 +295,22 @@ describe("Session.fork task detachment", () => { }, } as MessageV2.ToolPart) + const before = await sessions.list() const forked = await Session.fork({ sessionID: parent.id }) + const after = await sessions.list() const msgs = await sessions.messages({ sessionID: forked.id }) const tool = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") as MessageV2.ToolPart expect(tool.state.status).toBe("error") if (tool.state.status !== "error") throw new Error("expected detached task error") expect(tool.state.error).toContain("still running") - expect(tool.state.input.task_id).toBeUndefined() - expect(tool.state.metadata).toEqual({ variant: "high" }) - expect(tool.metadata).toEqual({}) + const clonedID = tool.state.input.task_id + if (typeof clonedID !== "string") throw new Error("expected a cloned task ID") + expect(clonedID).not.toBe(child.id) + expect(tool.state.error).toContain(`task_id="${clonedID}"`) + expect(tool.state.metadata).toEqual({ sessionId: clonedID, variant: "high" }) + expect(tool.metadata).toEqual({ sessionId: clonedID }) + expect(after).toHaveLength(before.length + 2) + expect((await sessions.get(SessionID.descending(clonedID))).parentID).toBe(forked.id) }, }) }, @@ -294,7 +318,7 @@ describe("Session.fork task detachment", () => { ) test( - "detaches pending and errored task references", + "detaches in-flight tasks and remaps errored task references", async () => { await using tmp = await tmpdir({ git: true }) await instance({ @@ -329,7 +353,7 @@ describe("Session.fork task detachment", () => { state: { status: "error", input: { task_id: child.id }, - error: "original error", + error: `original error; task_id="${child.id}"`, metadata: { sessionID: child.id, detail: "keep" }, time: { start: Date.now(), end: Date.now() }, }, @@ -344,15 +368,20 @@ describe("Session.fork task detachment", () => { expect(pending?.state.status).toBe("error") if (!pending || pending.state.status !== "error") throw new Error("expected detached pending task") expect(pending.state.error).toContain("still pending") - expect(pending.state.input.task_id).toBeUndefined() - expect(pending.metadata).toEqual({}) + const pendingID = pending.state.input.task_id + if (typeof pendingID !== "string") throw new Error("expected a cloned pending task ID") + expect(pendingID).not.toBe(child.id) + expect(pending.state.error).toContain(`task_id="${pendingID}"`) + expect(pending.metadata).toEqual({ sessionID: pendingID }) expect(errored?.state.status).toBe("error") if (!errored || errored.state.status !== "error") throw new Error("expected detached errored task") - expect(errored.state.error).toBe("original error") - expect(errored.state.input.task_id).toBeUndefined() - expect(errored.state.metadata).toEqual({ detail: "keep" }) - expect(errored.metadata).toEqual({}) + const clonedID = errored.state.input.task_id + if (typeof clonedID !== "string") throw new Error("expected a cloned task ID") + expect(clonedID).toBe(pendingID) + expect(errored.state.error).toBe(`original error; task_id="${clonedID}"`) + expect(errored.state.metadata).toEqual({ sessionID: clonedID, detail: "keep" }) + expect(errored.metadata).toEqual({ sessionId: clonedID }) }, }) }, diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 5f05b95df7..8a03ea08f7 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -269,6 +269,69 @@ describe("tool.task", () => { }), ) + // kilocode_change start - verify forked task children remain resumable + it.instance("execute resumes a cloned task session after the parent is forked", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: chat.id, + type: "tool", + callID: "call_1", + tool: "task", + metadata: { sessionId: child.id }, + state: { + status: "completed", + input: { description: "inspect bug", prompt: "continue", task_id: child.id }, + output: `done`, + title: "inspect bug", + metadata: { sessionId: child.id }, + time: { start: Date.now(), end: Date.now() }, + }, + } as MessageV2.ToolPart) + + const forked = yield* sessions.fork({ sessionID: chat.id }) + const msgs = yield* sessions.messages({ sessionID: forked.id }) + const part = msgs.flatMap((msg) => msg.parts).find((item) => item.type === "tool" && item.tool === "task") as + | MessageV2.ToolPart + | undefined + if (!part || part.state.status !== "completed") throw new Error("expected a completed task part") + const id = part.state.input.task_id + if (typeof id !== "string") throw new Error("expected a cloned task ID") + const parent = msgs.find((msg) => msg.info.role === "assistant") + if (!parent || parent.info.role !== "assistant") throw new Error("expected a forked assistant message") + + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + yield* def.execute( + { + description: "inspect bug", + prompt: "continue from the fork", + subagent_type: "general", + task_id: id, + }, + { + sessionID: forked.id, + messageID: parent.info.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps({ onPrompt: (input) => (seen = input) }) }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.sessionID).toBe(SessionID.descending(id)) + expect((yield* sessions.get(SessionID.descending(id))).parentID).toBe(forked.id) + }), + ) + // kilocode_change end + // kilocode_change start - resumed children rebuild parent platform attribution after restart it.instance("execute preserves platform attribution when resuming a task", () => Effect.gen(function* () { From 49bbf88e89b664b5d37760df80e6846bd0834ffb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:42:26 +0200 Subject: [PATCH 61/67] fix(cli): harden forked task remapping --- .../opencode/src/kilocode/session/fork.ts | 12 ++---- .../test/kilocode/session-fork-remap.test.ts | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/kilocode/session/fork.ts b/packages/opencode/src/kilocode/session/fork.ts index 9c64afd28c..abf289879d 100644 --- a/packages/opencode/src/kilocode/session/fork.ts +++ b/packages/opencode/src/kilocode/session/fork.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect" +import { Effect, Schema } from "effect" import type { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" import { MessageID, PartID, SessionID } from "@/session/schema" @@ -49,14 +49,7 @@ function mapRecord(value: Record | undefined, map: Map) { return [...map].reduce( - (text, [source, target]) => - source === target - ? text - : text - .replaceAll(source, target) - .replaceAll(`task id="${source}"`, `task id="${target}"`) - .replaceAll(`task_id="${source}"`, `task_id="${target}"`) - .replaceAll(`task_id: ${source}`, `task_id: ${target}`), + (text, [source, target]) => (source === target ? text : text.replaceAll(source, target)), value, ) } @@ -154,6 +147,7 @@ export function remapChildren(input: { for (const ref of refs) { if (map.has(ref.child)) continue + if (!Schema.is(SessionID)(ref.child)) continue const source = yield* input.ops.get(SessionID.make(ref.child)).pipe(Effect.orElseSucceed(() => undefined)) if (!source) continue const target = yield* copy({ source, parentID: input.sessionID, ops: input.ops }) diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index 6e3ebe6a57..9cd1d9d6cf 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -388,6 +388,43 @@ describe("Session.fork task children", () => { { timeout: 30000 }, ) + test( + "ignores malformed task references while forking", + async () => { + await using tmp = await tmpdir({ git: true }) + await instance({ + directory: tmp.path, + fn: async () => { + const parent = await sessions.create({ title: "parent" }) + const user = await userMsg(parent.id) + const assistant = await asstMsg(parent.id, user) + await sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant, + sessionID: parent.id, + type: "tool", + callID: "call_malformed", + tool: "task", + state: { + status: "error", + input: { task_id: "not-a-session-id" }, + error: "Cannot resume the malformed task reference", + time: { start: Date.now(), end: Date.now() }, + }, + } as MessageV2.ToolPart) + + const forked = await Session.fork({ sessionID: parent.id }) + const msgs = await sessions.messages({ sessionID: forked.id }) + const tool = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") as MessageV2.ToolPart + expect(tool.state.status).toBe("error") + if (tool.state.status !== "error") throw new Error("expected malformed task error") + expect(tool.state.input.task_id).toBe("not-a-session-id") + }, + }) + }, + { timeout: 30000 }, + ) + test( "preserves workspace sync event sequencing in the atomic copy", async () => { From 3092ce036c493844338c5c77cb58d5a692079ce9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:42:36 +0200 Subject: [PATCH 62/67] fix(agent-manager): tighten multi-project sidebar layout --- .changeset/agent-manager-sidebar-density.md | 5 + ...t-manager-multi-project-sidebar-density.md | 437 ++++++++++++++++++ .../webview-ui/agent-manager/ProjectList.tsx | 10 +- .../agent-manager/ProjectSidebarBody.tsx | 57 +-- .../webview-ui/agent-manager/SidebarBody.tsx | 114 ++--- .../agent-manager/agent-manager.css | 71 ++- 6 files changed, 595 insertions(+), 99 deletions(-) create mode 100644 .changeset/agent-manager-sidebar-density.md create mode 100644 .kilo/plans/agent-manager-multi-project-sidebar-density.md diff --git a/.changeset/agent-manager-sidebar-density.md b/.changeset/agent-manager-sidebar-density.md new file mode 100644 index 0000000000..08eb1cf9c1 --- /dev/null +++ b/.changeset/agent-manager-sidebar-density.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the Agent Manager sidebar keyboard shortcut badge so it appears on the right edge of local rows and only while hovered or holding the jump modifier, give worktree titles more room by no longer reserving space for hidden row actions, align project names and section headings with the row icons below them, and show which worktree a session belongs to in the search palette. diff --git a/.kilo/plans/agent-manager-multi-project-sidebar-density.md b/.kilo/plans/agent-manager-multi-project-sidebar-density.md new file mode 100644 index 0000000000..28790c6a3b --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-sidebar-density.md @@ -0,0 +1,437 @@ +# Plan: Agent Manager sidebar — shortcut badge fix + reclaim title width + +Worktree: `/Users/marius/Documents/git/kilocode/.kilo/worktrees/mewing-profit` +All paths below are relative to `packages/kilo-vscode/`. + +Rules: + +- Do exactly these edits. Do not refactor anything else. +- Solid.js, not React: `class=`, not `className=`. +- Do **not** add `kilocode_change` markers. This package is Kilo-owned and CI fails if you do. +- Do **not** add new i18n strings. None are needed. +- Out of scope: the per-project `SESSIONS` list in the tree (tracked in + https://github.com/Kilo-Org/kilocode/issues/12928). Do not touch `UnassignedSessionsSection.tsx`. + +Do task A, verify, then task B, verify, then task C. + +## Task A — Shortcut badge on the right, hidden until hover or ⌘ held + +The `⌘1` badge on "local" rows is always visible, and in multi-project mode it renders on the +*left*, between the icon and the label. Worktree rows already behave correctly. Cause: +`.am-shortcut-badge` has no default `opacity: 0` — worktree badges are only hidden because their +container `.am-wt-hover-actions` is hidden, and local rows never got that container. + +### A1 — `webview-ui/agent-manager/ProjectSidebarBody.tsx` + +Replace the whole ` +``` + +The `−` in `am-stat-deletions` is U+2212 MINUS SIGN, not a hyphen. Copy it verbatim. + +### A2 — `webview-ui/agent-manager/SidebarBody.tsx` + +Same bug in legacy single-project mode, but the badge is already last, so only the wrapper is +missing. + +Insert **before** line **127** (``): + +```tsx +
+``` + +Replace line **182**: + +```tsx + {isMac ? "⌘" : "Ctrl+"}1 +``` + +with: + +```tsx +
+ {isMac ? "⌘" : "Ctrl+"}1 +
+
+``` + +Result: one `am-wt-actions-cell` div containing the skeleton ``, the stats ``, and the +hover-actions div, closing before ``. Prettier fixes indentation in task D. + +### A3 — `webview-ui/agent-manager/agent-manager.css` + +Delete lines **569-575** and replace with the rule that actually works: + +```css +.am-local-item .am-shortcut-badge { + right: 8px; +} + +.am-local-item:hover .am-shortcut-badge { + opacity: 1; +} +``` + +becomes: + +```css +.am-local-item:hover .am-wt-hover-actions { + opacity: 1; + visibility: visible; +} +``` + +(`right: 8px` never applied — the badge is `position: static`. The `opacity: 1` never did anything +because nothing set `opacity: 0` first.) + +### A4 — same file, delete lines **609-611** entirely, add nothing: + +```css +.am-show-shortcuts .am-local-item .am-shortcut-badge { + opacity: 1; +} +``` + +The rule at lines 597-600 is not scoped to worktree items, so after A1/A2 it already covers local +rows. + +### A5 — same file, lines **986-989**, add `visibility: hidden` to match worktree behaviour: + +```css +.am-local-item:hover .am-worktree-stats, +.am-local-item:hover .am-worktree-stats-skeleton { + opacity: 0; + visibility: hidden; +} +``` + +### Verify A + +```bash +cd packages/kilo-vscode && bun run typecheck && bun run lint +``` + +## Task B — Stop reserving width for invisible content + +`.am-wt-actions-cell` is a grid whose children all stack in cell 1/1, so the cell is permanently as +wide as its widest child. `visibility: hidden` does not remove layout, so the invisible hover +actions (~42px) and the loading skeleton (~48px) reserve width on every row forever. That is why +titles truncate ~40px before the right edge. + +All edits in `webview-ui/agent-manager/agent-manager.css`. + +### B1 — Take hover actions out of grid flow + +Lines **475-482**. Add the four `position` lines at the top, keep everything else: + +```css +.am-wt-hover-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + opacity: 0; + visibility: hidden; +} +``` + +Absolutely positioned children never size grid tracks. `.am-wt-actions-cell` is already +`position: relative` (line 464). Do not edit the `.am-wt-actions-cell > *` rule at lines 469-472. + +### B2 — Same for the loading skeleton + +Add immediately after the rule from B1: + +```css +/* Skeleton is a placeholder, not real content — it must not reserve title width. */ +.am-wt-actions-cell > .am-worktree-stats-skeleton { + position: absolute; + top: 0; + right: 0; + bottom: 0; +} +``` + +Do not edit the base `.am-worktree-stats-skeleton` rule (lines 744-748) or `.am-pr-badge-skeleton`. + +After B1+B2 the cell is sized only by `.am-worktree-stats` and `.am-worktree-delete-hint`, which +are the only things that should reserve space. + +### B3 — Fade the title under the overlaying actions + +The actions now overlay the row instead of sitting in reserved space, so a long title would render +behind them. Same fix the codebase already uses for the local branch at lines 577-580. Add after +B2: + +```css +.am-worktree-item:hover .am-worktree-branch { + mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); +} +``` + +### B4 — Remove one layer of nested padding + +Lines **365-370**, change `padding: 0 6px;` to `padding: 0;`: + +```css +.am-project-body { + display: flex; + flex-direction: column; + min-height: 0; + padding: 0; +} +``` + +Gains 6px per side on every row in a project. Rows keep their own 10px inset +(`.am-local-item` line 106, `.am-worktree-item` line 425) so they stay indented under the project +header. Do not change those, and leave `.am-project-body .am-section-header` (lines 273-275) alone. + +### B5 — Remove the remaining outer list gutters in multi-project mode + +The sidebar keeps 8px horizontal padding for the header controls, but the project list should use +the full width up to its scrollbar. In the same CSS file, extend `.am-projects-list` with: + +```css +.am-projects-list { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + margin-inline: -8px; +} +``` + +Do not remove padding from `.am-sidebar` itself. This makes project cards and rows reach the +scrollbar without moving the `PROJECTS` header and its controls to the edge. + +### Verify B + +```bash +cd packages/kilo-vscode && bun run typecheck && bun run lint +``` + +## Task C — Show where a palette session lives + +In the ⌘F search palette, multi-project session results show only the project name, so you cannot +tell whether a session is in a worktree or at the project root. + +File: `webview-ui/agent-manager/ProjectList.tsx`. In the session loop (lines **90-106**), add the +two `const` lines and change `meta` and `search`: + +```tsx + for (const session of props.sessions[project.id] ?? []) { + const wt = session.worktreeId ? state.worktrees.find((item) => item.id === session.worktreeId) : undefined + const where = wt ? wt.label || wt.branch : props.t("agentManager.local") + items.push({ + key: `${project.id}:session:${session.id}`, + projectId: project.id, + kind: "session", + group: "sessions", + title: session.title || props.t("agentManager.session.untitled"), + meta: [project.label, where], + search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "), + updatedAt: session.updatedAt, + state: "idle", + visible: project.expanded, + sessionId: session.id, + location: session.worktreeId ? "worktree" : "local", + worktreeId: session.worktreeId ?? undefined, + }) + } +``` + +`state` is already in scope (line 55) and already null-checked (line 56). `meta` is joined with +` · ` by the renderer (`SidebarSearchMenu.tsx:143`), so no separator work is needed. + +## Task E — Align the project heading with the row icons + +Every row in the projects tree used a different left inset, which made the sidebar look busy and +indented for no reason. Measured against a full-bleed `.am-projects-list` (starting at x=0): + +| Element | Before | After | +|---|---|---| +| `PROJECTS` label | 16px | 10px | +| Project chevron | 12px | 10px | +| `WORKTREES` / `SESSIONS` label | 6px | 10px | +| `.am-local-item` icon | 10px | 10px | +| `.am-worktree-item` icon | 10px | 10px | + +Changes in `agent-manager.css`: + +- `.am-local-item` padding `8px 10px` → `8px 6px` +- `.am-worktree-item` padding `6px 10px` → `6px 6px` +- `.am-project-item` padding `6px 8px 6px 12px` → `6px 6px` +- `.am-project-body .am-section-header` padding-left `6px` → unchanged at `6px` +- `.am-projects > .am-section-header` gets `padding-left: 2px`, because that heading sits inside + `.am-sidebar`'s own 8px padding rather than in the pulled-out list + +The leading columns carry no padding of their own (`.am-sidebar-header-toggle` and +`.am-sidebar-header-chevron` are bare 16px boxes), so row padding is the only lever. + +### Symmetric gutter + +`.am-projects-list` uses `margin-left: -4px; margin-right: -4px`, pulling out of `.am-sidebar`'s +8px padding to an even 4px gutter. An earlier attempt used `-8px` on the left for true full bleed, +but that is wrong twice over: the resize handle's inner half then covered every card, so card +clicks started a resize, and a selected row's `border-radius: var(--radius-sm)` background clipped +flat against the window edge while still being inset on the right, which read as a bar bleeding off +the sidebar rather than a card. + +4px is also the most reclaimable on the right. The handle's hit area is 8px wide centered on the +border, reaching 5px back into the content area (255-263 in a 260px sidebar). At this gutter the +row's hover actions end at 249, leaving 6px of clearance; anything tighter puts row buttons under +the handle and turns clicks into resize drags. + +### Two tab stops + +Reducing the insets exposed that labels sat at four different offsets: the non-collapsible +`WORKTREES` heading at 6px (it passes no `onToggle`, so `SidebarSectionHeader` renders no chevron), +collapsible headings at 28px, worktree card text at 30px, and local card text at 32px because +`.am-local-icon` was 18px wide while `.am-wt-icon` held a 16px glyph. + +Normalized to one 16px leading column with an 8px gap, giving exactly two tab stops: + +- `.am-sidebar-header-main` gap `6px` → `8px`, matching the card icon gap +- new `.am-project-body .am-sidebar-header:not(.am-sidebar-header-toggleable) .am-sidebar-header-main { padding-left: 24px }` + so a heading with no chevron still reserves the column +- `.am-local-icon` `18px` → `16px` +- `.am-wt-icon` gains `width: 16px` and `justify-content: center` + +Measured result at 260px: + +| Tab stop | Elements | +|---|---| +| 10px | `PROJECTS` label, project chevron, local icon, worktree icon, `SESSIONS` chevron | +| 34px | project name, `WORKTREES` label, `SESSIONS` label, local text, worktree text | + +``` +cardLeftGap=4 | cardRightGap=4 | actionsRight=249 | handleZoneFrom=255 +``` + +`PROJECTS` intentionally stays at the 10px glyph stop rather than being pushed to 34px: it is the +root heading, so indenting it further than the projects beneath it would invert the hierarchy. + +### Rejected: a per-project icon in the heading + +Superset shows a GitHub **owner** avatar per project (`https://github.com/{owner}.png?size=64`, +falling back to the project's initial). Our webview CSP already allows `https:` images, so it was +feasible, but it does not fit this repo set: of the local projects, ~17 are `Kilo-Org` remotes and +would share one identical Kilo logo, and ~20 have no git remote at all and would show nothing. +An owner avatar answers "who owns this", which is not the question the sidebar needs answered. +Superset's repo-file scanner (`favicon-discovery.ts`) is dead code there, so it was not an option +worth copying either. Left out entirely as out of scope. + +## Task D — Final checks + +From `packages/kilo-vscode`: + +```bash +bun run format +bun run typecheck +bun run lint +bun run test:unit +bun run check-kilocode-change +bun run compile +``` + +Then create `.changeset/agent-manager-sidebar-density.md`: + +```md +--- +"kilo-code": patch +--- + +Fix the Agent Manager sidebar keyboard shortcut badge so it appears on the right edge of local rows and only while hovered or holding the jump modifier, give worktree titles more room by no longer reserving space for hidden row actions, and show which worktree a session belongs to in the search palette. +``` + +## Visual regression baselines + +Task A and B change existing snapshots: `WorktreeItemDefault`, `WorktreeItemActive`, +`WorktreeItemPendingDelete`, `WorktreeItemStale`, `WorktreeItemWithStats`, `WorktreeItemGrouped`, +`SidebarSearchOpen`, `MultiProjectSidebar` in `webview-ui/src/stories/agent-manager.stories.tsx`. + +**Do not run or update visual regression tests.** `tests/visual-regression.spec.ts` is skipped on +macOS, so you cannot produce valid Linux baselines locally. CI regenerates them and may push a +baseline commit. If a push is then rejected, do **not** `git pull --rebase`; run +`git fetch && git push --force-with-lease`. + +## Manual test + +Enable `kilo-code.new.experimental.multiProject` in Kilo Settings → Experimental, add a second +project, open Agent Manager (`Cmd+Shift+M`). + +1. Nothing hovered: no `⌘N` badge visible anywhere. +2. Hover a `local` row: badge appears at the **right** edge, git stats fade out. +3. Hold ⌘: badges appear on all local and worktree rows, all right-aligned. Release: all gone. +4. Worktree rows with no git changes show noticeably longer titles than before. Hover one with a + long title: it fades under the badge and trash button instead of colliding. +5. ⌘F: session results read ` · `. +6. Turn the experimental setting off: the `local` row's `⌘1` badge is hidden until hover. + +## Known issue, not fixed here + +`⌘1`–`⌘9` index the full nav order, which includes session rows that render no badge +(`navigate.ts:214-218` + `section-helpers.ts:147-153`). So with 4 worktrees you see `⌘1`–`⌘5` and +`⌘6`–`⌘9` silently land on unlabelled session rows. Deliberately left alone: fixing it means +changing jump semantics and rewriting `tests/unit/navigate.test.ts:745-757`, and it becomes moot +once #12928 moves sessions out of the tree. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 1575e4e407..1638ae0337 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -22,6 +22,11 @@ import { ProjectBranchDialog } from "./ProjectBranchDialog" import type { ProjectStore } from "./project/store" import type { ModeRouter } from "./mode-router" +const location = (state: AgentManagerStateMessage, session: ProjectSessionInfo, local: string) => { + const wt = state.worktrees.find((item) => item.id === session.worktreeId) + return wt?.label || wt?.branch || local +} + interface Props { projects: AgentProjectSnapshot[] states: Record @@ -88,14 +93,15 @@ export const ProjectList: Component = (props) => { }) } for (const session of props.sessions[project.id] ?? []) { + const where = location(state, session, props.t("agentManager.local")) items.push({ key: `${project.id}:session:${session.id}`, projectId: project.id, kind: "session", group: "sessions", title: session.title || props.t("agentManager.session.untitled"), - meta: [project.label], - search: [project.label, session.title, session.id].filter(Boolean).join(" "), + meta: [project.label, where], + search: [project.label, where, session.title, session.id].filter(Boolean).join(" "), updatedAt: session.updatedAt, state: "idle", visible: project.expanded, diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index e3e2679321..fa3d2f929f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -311,40 +311,45 @@ export const ProjectSidebarBody: Component = (props) => { - - {(shortcut) => ( - - {isMac ? "⌘" : "Ctrl+"} - {shortcut()} - - )} -
{props.t("agentManager.local")} {props.local!.branch}
- -
- - ↓{props.local!.behind} - - - ↑{props.local!.ahead} - - - +{props.local!.additions} - - - −{props.local!.deletions} +
+ +
+ + ↓{props.local!.behind} + + + ↑{props.local!.ahead} + + + +{props.local!.additions} + + + −{props.local!.deletions} + +
+
+
+ + {(shortcut) => ( + + {isMac ? "⌘" : "Ctrl+"} + {shortcut()} + + )}
- +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index 840ec8cfba..8f58fb07d4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -124,62 +124,66 @@ export const SidebarBody: Component = (props) => { {props.repoBranch()}
- -
-
-
+
+ +
+
+
+
+ + 0 || + props.localStats()!.additions > 0 || + props.localStats()!.deletions > 0 || + props.localStats()!.ahead > 0 || + props.localStats()!.behind > 0) + } + > +
+ 0 || props.localStats()!.deletions > 0} + fallback={ + 0}> + {props.localStats()!.files}f + + } + > +
+ 0}> + +{props.localStats()!.additions} + + 0}> + + {"−"} + {props.localStats()!.deletions} + + +
+
+ 0 || props.localStats()!.behind > 0}> +
+ 0}> + + {"↑"} + {props.localStats()!.ahead} + + + 0}> + + {"↓"} + {props.localStats()!.behind} + + +
+
+
+
+
+ {isMac ? "⌘" : "Ctrl+"}1
- - 0 || - props.localStats()!.additions > 0 || - props.localStats()!.deletions > 0 || - props.localStats()!.ahead > 0 || - props.localStats()!.behind > 0) - } - > -
- 0 || props.localStats()!.deletions > 0} - fallback={ - 0}> - {props.localStats()!.files}f - - } - > -
- 0}> - +{props.localStats()!.additions} - - 0}> - - {"−"} - {props.localStats()!.deletions} - - -
-
- 0 || props.localStats()!.behind > 0}> -
- 0}> - - {"↑"} - {props.localStats()!.ahead} - - - 0}> - - {"↓"} - {props.localStats()!.behind} - - -
-
-
-
- {isMac ? "⌘" : "Ctrl+"}1 +
{/* WORKTREES section */} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 6cfadee1a9..5527f461e3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -103,7 +103,7 @@ html[data-theme="kilo-vscode"] display: flex; align-items: center; gap: 8px; - padding: 8px 10px; + padding: 8px 6px; min-height: 40px; box-sizing: border-box; border-radius: var(--radius-sm); @@ -144,8 +144,9 @@ html[data-theme="kilo-vscode"] } .am-local-icon { - width: 18px; - height: 18px; + /* 16px to match .am-wt-icon and the heading chevron column. */ + width: 16px; + height: 16px; flex-shrink: 0; color: var(--text-weak); } @@ -198,11 +199,19 @@ html[data-theme="kilo-vscode"] .am-sidebar-header-main { display: flex; align-items: center; - gap: 6px; + /* Matches the card icon gap so heading labels share the cards' tab stop. */ + gap: 8px; flex: 1; min-width: 0; } +/* Headings without a toggle still reserve the 16px chevron column plus its gap, + so a non-collapsible heading like WORKTREES lands on the same tab stop as + collapsible ones like SESSIONS instead of sitting flush left. */ +.am-project-body .am-sidebar-header:not(.am-sidebar-header-toggleable) .am-sidebar-header-main { + padding-left: 24px; +} + .am-sidebar-header-chevron { display: flex; align-items: center; @@ -274,6 +283,12 @@ html[data-theme="kilo-vscode"] padding-left: 6px; } +/* This heading sits outside the projects list, so it needs its own inset to + land on the same glyph line as the rows inside the list. */ +.am-projects > .am-section-header { + padding-left: 2px; +} + .am-section-label { font-size: var(--font-size-small); font-weight: 600; @@ -301,6 +316,12 @@ html[data-theme="kilo-vscode"] flex-direction: column; min-height: 0; overflow-y: auto; + /* Pull out of .am-sidebar's 8px padding to an even 4px gutter on both sides, + so a selected row's rounded background is inset symmetrically. 4px on the + right is also the most we can reclaim before the resize handle's hit area + (8px wide, centered on the border) would cover the row's own controls. */ + margin-left: -4px; + margin-right: -4px; } .am-projects-tools, @@ -324,7 +345,9 @@ html[data-theme="kilo-vscode"] .am-project-item { min-height: 34px; - padding: 6px 8px 6px 12px; + /* Leading column aligns with the card icons below it (.am-local-item and + .am-worktree-item both use a 6px inset), so the tree reads as one line. */ + padding: 6px 6px; box-sizing: border-box; } @@ -366,7 +389,7 @@ html[data-theme="kilo-vscode"] display: flex; flex-direction: column; min-height: 0; - padding: 0 6px; + padding: 0; } .am-project-body > .am-local-item { @@ -422,7 +445,7 @@ html[data-theme="kilo-vscode"] display: flex; align-items: flex-start; gap: 8px; - padding: 6px 10px; + padding: 6px 6px; min-height: 36px; box-sizing: border-box; border-radius: var(--radius-sm); @@ -439,6 +462,8 @@ html[data-theme="kilo-vscode"] flex-shrink: 0; display: flex; align-items: center; + justify-content: center; + width: 16px; height: 20px; } @@ -474,6 +499,10 @@ html[data-theme="kilo-vscode"] /* Hover actions (shortcut badge + close button) — hidden by default, shown on hover */ .am-wt-hover-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; display: flex; align-items: center; justify-content: flex-end; @@ -492,6 +521,20 @@ html[data-theme="kilo-vscode"] visibility: hidden; } +/* Placeholder content must not reserve title width. */ +.am-wt-actions-cell > .am-worktree-stats-skeleton { + position: absolute; + top: 0; + right: 0; + bottom: 0; +} + +.am-worktree-item:hover .am-worktree-branch, +.am-show-shortcuts .am-worktree-item:has(.am-shortcut-badge) .am-worktree-branch { + mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); +} + /* Row 2: always present for consistent height; PR badge right-aligned */ .am-wt-row2 { display: flex; @@ -566,15 +609,13 @@ html[data-theme="kilo-vscode"] white-space: nowrap; } -.am-local-item .am-shortcut-badge { - right: 8px; -} - -.am-local-item:hover .am-shortcut-badge { +.am-local-item:hover .am-wt-hover-actions { opacity: 1; + visibility: visible; } -.am-local-item:hover .am-local-branch { +.am-local-item:hover .am-local-branch, +.am-show-shortcuts .am-local-item:has(.am-shortcut-badge) .am-local-branch { mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%); -webkit-mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%); } @@ -606,9 +647,6 @@ html[data-theme="kilo-vscode"] opacity: 0; visibility: hidden; } -.am-show-shortcuts .am-local-item .am-shortcut-badge { - opacity: 1; -} .am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 { display: none; @@ -986,6 +1024,7 @@ html[data-theme="kilo-vscode"] .am-local-item:hover .am-worktree-stats, .am-local-item:hover .am-worktree-stats-skeleton { opacity: 0; + visibility: hidden; } /* User-defined sections — collapsible, color-coded groups */ From f8b7461400b1c7075ef91e4afb6145d241b2559b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 12:00:00 +0200 Subject: [PATCH 63/67] fix(agent-manager): preserve session branch search --- .../kilo-vscode/webview-ui/agent-manager/ProjectList.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 1638ae0337..382f60bc2a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -22,7 +22,7 @@ import { ProjectBranchDialog } from "./ProjectBranchDialog" import type { ProjectStore } from "./project/store" import type { ModeRouter } from "./mode-router" -const location = (state: AgentManagerStateMessage, session: ProjectSessionInfo, local: string) => { +const place = (state: AgentManagerStateMessage, session: ProjectSessionInfo, local: string) => { const wt = state.worktrees.find((item) => item.id === session.worktreeId) return wt?.label || wt?.branch || local } @@ -93,7 +93,8 @@ export const ProjectList: Component = (props) => { }) } for (const session of props.sessions[project.id] ?? []) { - const where = location(state, session, props.t("agentManager.local")) + const wt = state.worktrees.find((item) => item.id === session.worktreeId) + const where = place(state, session, props.t("agentManager.local")) items.push({ key: `${project.id}:session:${session.id}`, projectId: project.id, @@ -101,7 +102,7 @@ export const ProjectList: Component = (props) => { group: "sessions", title: session.title || props.t("agentManager.session.untitled"), meta: [project.label, where], - search: [project.label, where, session.title, session.id].filter(Boolean).join(" "), + search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "), updatedAt: session.updatedAt, state: "idle", visible: project.expanded, From 8c827a94b0762436e5ec327210d8a3d2ca78ae3c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 12:23:48 +0200 Subject: [PATCH 64/67] docs: update Gastown reference link --- packages/kilo-docs/pages/code-with-ai/gastown/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/index.md b/packages/kilo-docs/pages/code-with-ai/gastown/index.md index 55f665f589..b7bba0b60e 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/index.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/index.md @@ -5,7 +5,7 @@ description: "Autonomous AI agent orchestration for your codebase" # {% $markdoc.frontmatter.title %} -Gastown by Kilo is an autonomous agent orchestration platform that manages teams of AI agents working on your codebase. Built on [Gastown](https://gastown.dev) — the open protocol for agent orchestration — Kilo's implementation coordinates coding agents, a code review agent, and a conversational coordinator to ship features, fix bugs, and maintain your projects with minimal human intervention. +Gastown by Kilo is an autonomous agent orchestration platform that manages teams of AI agents working on your codebase. Built on [Gastown](https://github.com/gastownhall/gastown) — the open protocol for agent orchestration — Kilo's implementation coordinates coding agents, a code review agent, and a conversational coordinator to ship features, fix bugs, and maintain your projects with minimal human intervention. You describe the work. Agents figure out how to do it, write the code, review each other's output, and land clean PRs — while you stay in control of what ships. From 3782a655983f66a474b749ccaa70a19c6e68dc50 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 12:24:30 +0200 Subject: [PATCH 65/67] refactor(cli): extract shared subagent resume hint --- packages/opencode/src/kilocode/session/fork.ts | 8 +------- packages/opencode/src/kilocode/task-resume.ts | 6 ++++++ packages/opencode/src/tool/task.ts | 10 +--------- 3 files changed, 8 insertions(+), 16 deletions(-) create mode 100644 packages/opencode/src/kilocode/task-resume.ts diff --git a/packages/opencode/src/kilocode/session/fork.ts b/packages/opencode/src/kilocode/session/fork.ts index abf289879d..bef4fe8dff 100644 --- a/packages/opencode/src/kilocode/session/fork.ts +++ b/packages/opencode/src/kilocode/session/fork.ts @@ -2,6 +2,7 @@ import { Effect, Schema } from "effect" import type { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" import { MessageID, PartID, SessionID } from "@/session/schema" +import { resumeHint } from "../task-resume" import { KiloPartLifecycle } from "./part-lifecycle" const task = "task" @@ -54,13 +55,6 @@ function output(value: string, map: Map) { ) } -function resumeHint(sessionID: string) { - return [ - `This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`, - "and a prompt describing how to continue or recover. Its prior context is preserved.", - ].join(" ") -} - function remapPart(part: MessageV2.Part, map: Map) { if (part.type === "text") { const text = output(part.text, map) diff --git a/packages/opencode/src/kilocode/task-resume.ts b/packages/opencode/src/kilocode/task-resume.ts new file mode 100644 index 0000000000..47f33f2665 --- /dev/null +++ b/packages/opencode/src/kilocode/task-resume.ts @@ -0,0 +1,6 @@ +export function resumeHint(sessionID: string) { + return [ + `This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`, + `and a prompt describing how to continue or recover. Its prior context is preserved.`, + ].join(" ") +} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a67cf4e12a..751af5c7ff 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -16,6 +16,7 @@ import { KiloTaskBackgroundProcess } from "../kilocode/tool/task-background-proc import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change import { KiloSessionProcessor } from "../kilocode/session/processor" // kilocode_change import { KiloSession } from "../kilocode/session" // kilocode_change +import { resumeHint } from "../kilocode/task-resume" // kilocode_change import { errorMessage } from "@/util/error" // kilocode_change import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" @@ -90,15 +91,6 @@ function renderOutput(input: { ].join("\n") } -// kilocode_change start - tell the parent agent how to resume a stopped/failed subagent (#11620) -function resumeHint(sessionID: SessionID) { - return [ - `This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`, - `and a prompt describing how to continue or recover. Its prior context is preserved.`, - ].join(" ") -} -// kilocode_change end - export const TaskTool = Tool.define( id, Effect.gen(function* () { From a9b4ace956905c329bd92c10ae52d313b45386bc Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 6 Aug 2026 10:27:05 +0000 Subject: [PATCH 66/67] chore: update kilo-vscode visual regression baselines --- .../agentmanager-sections/all-colors-chromium-linux.png | 4 ++-- .../agentmanager-sections/collapsed-chromium-linux.png | 4 ++-- .../agentmanager-sections/default-color-chromium-linux.png | 4 ++-- .../agentmanager-sections/dense-sidebar-chromium-linux.png | 4 ++-- .../agentmanager-sections/empty-chromium-linux.png | 4 ++-- .../expanded-with-items-chromium-linux.png | 4 ++-- .../first-and-last-section-chromium-linux.png | 4 ++-- .../long-section-name-chromium-linux.png | 4 ++-- .../multiple-sections-chromium-linux.png | 4 ++-- .../with-active-worktree-chromium-linux.png | 4 ++-- .../with-busy-worktree-chromium-linux.png | 4 ++-- .../agentmanager-sections/with-pr-badges-chromium-linux.png | 4 ++-- .../with-stale-worktree-chromium-linux.png | 4 ++-- .../agentmanager-sections/with-versions-chromium-linux.png | 4 ++-- .../agentmanager/multi-project-sidebar-chromium-linux.png | 4 ++-- .../pr-badge-approved-checks-failing-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-approved-chromium-linux.png | 4 ++-- .../pr-badge-changes-requested-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-checks-failing-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-checks-pending-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-closed-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-draft-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-merged-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-no-review-chromium-linux.png | 4 ++-- .../agentmanager/pr-badge-pending-chromium-linux.png | 4 ++-- .../agentmanager/worktree-item-active-chromium-linux.png | 4 ++-- .../agentmanager/worktree-item-default-chromium-linux.png | 4 ++-- .../worktree-item-pending-delete-chromium-linux.png | 4 ++-- .../agentmanager/worktree-item-stale-chromium-linux.png | 4 ++-- .../agentmanager/worktree-item-with-stats-chromium-linux.png | 4 ++-- 30 files changed, 60 insertions(+), 60 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png index eadcbe9dbf..239ee516fc 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9241e89fae82f69f1fab0371423ab45c905b54ff0c60ec1408b54d60f6c4c6c -size 19636 +oid sha256:785cb43d5208977a69f03056bf16e0bbc32b29aeba1d14257848a32d19590bd7 +size 19702 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png index b24dac2c74..0753c0ae83 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a96dd287b6ffdbdc0360baa9af99d09c3b709a5adaef18f5e7b7e43a83e44746 -size 3134 +oid sha256:950cf0a3053a283651b2232b4a4e2cfdced3c6feb2f053b0a5976f8d169cfcf1 +size 3193 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png index e1edce0782..78a5302b80 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60daae227133cedd35f93753128453177400f83233268ae19af984bc9a72ad4b -size 5203 +oid sha256:c02aed8f52a7b22d14d2068e1f87b65bb42ed6737115dadf89615ff5a4c870e5 +size 5214 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png index de65b45f22..0e8d89dc2b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4cda12827fa0de0590119ab707f6403fc98291843701ab0d4300e493b4b284aa -size 14965 +oid sha256:faae43fcbe8c42aef80184b4a815bbe6a024e0345243b5b8eb1be04d62b8611f +size 15184 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png index d6ae3eb676..9b33fc4b7e 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8099695f3cb2f79a54f6ada28eaf30a4e31afc5e660e293853d1ff34a2afaed1 -size 1605 +oid sha256:82ee97e40bd03a7c126e154c1644d878e374f3a573d81b22daf602a8e74ed840 +size 1596 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png index 7c85edd3e2..a05c8b3d28 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bee9b58fb35c2484a9da4bffcae07f98a942c1097df33b549e13de781790824 -size 8026 +oid sha256:4d7fa00cb51b6ab591a6f25f6f863d17b78cfcd8166cf09d179ab485e394a084 +size 8149 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png index 9bec9f013a..b21483ccfa 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf6056375e5bf9d9ad78487d58a7fc1fb2d570333d057d412f1d70893a73e5df -size 6176 +oid sha256:1d841a283f99962a7aa3e0ecc1b24aba5a5351534e74ebe3a6794f79e6d9f374 +size 6204 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png index 71e123b86a..e351fd7ab0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ebd138c8d5e59bc09ce0d8d571d5598622fb61631cf5a698c80380872bef0a4 -size 7031 +oid sha256:adb4fa97f6e2dfbf1d5dd7aa58181d548c99cc0ca170f8618af72d3e6f252974 +size 7008 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png index b0039f2efd..1eaa9c1256 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b629ad25ef4f3da8bd8a9e59c64cc05279dfc262db2d8d9d25c3697f48ef8393 -size 12834 +oid sha256:8caa6d6b2d7e1b0bbab8e47a486e27bcbfd7716ef84ad8771980a658c8cc41ab +size 13002 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png index a227600e9c..c48be143e8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b78544f9c306dbcb0278502b1f929f0af5389a90b73362abbea6f76612c5def9 -size 7761 +oid sha256:089e30f789f0f592e0ee3794dfe736fc503290d05e300830fa720a5b75046894 +size 7768 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png index e269e186d4..caea07cf96 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4785426a34c06b0c74fe62c117528bb16ea579b2d677902c839336f6a29a380e -size 5926 +oid sha256:3792bcfd82e37fd2ae9664f0abfbd50a6823be2da55b96640557d3696b490ab9 +size 6018 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png index 42e7b3b4b6..66d76c1e33 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e2b861de27fb8e6d64becd50bb4b3b05d748fba74b98aafaf5cb2a182ff74655 -size 26259 +oid sha256:f65d353dac1fd4bf4ddaee9f7171e348b19bb279cb220b7f745651c3d65ad821 +size 26237 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png index 3e2363c647..41f77fa609 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afa789c231b370c2ba874029a8a0597b5ca7ffff5624ddd343fcd54139a94012 -size 6132 +oid sha256:4a133a9d0b13ba7effcd1dab38a1843526b7aac99a9be5df9647d1ba7fb8d858 +size 6140 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png index 136ca2845b..d8de11ca4b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0f11ac60e7dd3e393739e85d279bca7e53dc22af01bade22a741e5267d96300 -size 11053 +oid sha256:aad33864d63a6d5eb7868daaa7989e3271806ddd385b4847a5a4221d189c4d5c +size 11063 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png index c5f0c077fc..9a31bdf34b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402 -size 39173 +oid sha256:d1c6647a2cb4da318b9ec60761c248345c2a6fdc6e95a0dc5e03c31a939ace4c +size 38950 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png index 5b2f303660..4720473ebb 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:facae793453671cf34b950352dc19041d420d096fac2a7358d1a4550e05a8033 -size 3383 +oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 +size 3694 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png index 784bf93534..b481fb3ef0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc81d91792781a9784775db3af44ea8058e87df9e029c2d7fc5acf1548fa7ec7 -size 3423 +oid sha256:311bb5062867449b9d5e2de41e77b399be1953fa6663097fa730218bed64a30a +size 3753 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png index ebf4954c6b..87cf0cf6a5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2923838fbc9ce24851e2b8ed6c5d4c2a098db9667fc528d1c00162595640bc05 -size 3550 +oid sha256:52cd6565a96bee5f55872fad4faf1c88030bec70d223187e51942bb70c2da253 +size 3687 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png index fdd58cf7fc..4720473ebb 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c087f8e2b50bf43f9ca03ab31e8955e995304473688bf8db88fb6901d743a1d4 -size 3541 +oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 +size 3694 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png index ebf4954c6b..7db717a6fa 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2923838fbc9ce24851e2b8ed6c5d4c2a098db9667fc528d1c00162595640bc05 -size 3550 +oid sha256:4e29131b2a6e68fc26d032396c872698d1882eacc3830fe4059fca810981915c +size 3679 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png index 05c387099b..4380385eb5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c13cc44cec6092d8294a535a3e6057d6e3a2b2d59cb18e09b0a073576972f80 -size 3582 +oid sha256:62b2731184acdc022a75338779296b8d217d5abe565d8d763075bfc68939b2e8 +size 3695 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png index fc5b1dc491..481d4f5cc9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79f5495b171fc9de3d04b7e15cc30ac2f5082c6d7db3818b3e288ce9c3ba41f3 -size 3461 +oid sha256:b36b2ab3912ec70139285c19a226e02c35cb982a321e3cd140d84a29756d817e +size 3590 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png index e819a16198..2cda01638f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dda50cfb4ebebd29e6f0791ec13ee9e16e48c9fa6f6d6be03c78b1d900516427 -size 3604 +oid sha256:1b787fe5310a002e7c5ac2bf8101964c0e4576d16db973e77a5e893b0db1fb66 +size 3728 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png index 8858ea455c..3b1b3de84d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2680e8f97baee13ab92ac6fb3bcce56eb9cbb7e0c5fb0f76a76251abc016eac3 -size 3567 +oid sha256:e2bcc9c6e0dbf398194431bdbca27138608304e95c6de5874aeff2ca06fe73e6 +size 3684 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png index 8858ea455c..3b1b3de84d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2680e8f97baee13ab92ac6fb3bcce56eb9cbb7e0c5fb0f76a76251abc016eac3 -size 3567 +oid sha256:e2bcc9c6e0dbf398194431bdbca27138608304e95c6de5874aeff2ca06fe73e6 +size 3684 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png index b657e55ee7..bfe0176bc2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc -size 2017 +oid sha256:769935237790bbf85293a260a4dded22ad0f33dcffb41cbcd0d7695136775bd2 +size 2215 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png index bc4f471b09..4a0f64a177 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c -size 1847 +oid sha256:6905b74776e20e3bdf0ca2ba6a89888e9160f5055d2f1719bc0cafa59f808134 +size 2056 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png index 2ec70d4ede..7301e8f0b8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e -size 2910 +oid sha256:8cb40c61ea8472804f6a067d64cec84a6c492756abf386acded8e2df57868653 +size 3012 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png index 9cdd496d4a..72a6a34103 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9 -size 1831 +oid sha256:3ed27d7e73679fe2ccbe267cf6ca52143882ae72026d01beb8862e6add661531 +size 2207 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png index 43f94d6b80..460be84ca5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de -size 2422 +oid sha256:71034449e848b4d6f2fe88624694842cfddd8a1890996f6cbc66499cbefafb33 +size 2547 From 3003a302bc65a4ce0df7c544303c0898db5406e3 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 6 Aug 2026 12:37:02 +0200 Subject: [PATCH 67/67] fix: address pull request review comments --- bun.lock | 39 +---- package.json | 1 - packages/opencode/package.json | 1 - packages/tui/package.json | 1 - .../tui/src/component/register-spinner.ts | 139 +++++++++++++++++- packages/tui/src/ui/spinner.ts | 5 +- .../tui/test/kilocode/spinner-runtime.test.ts | 11 ++ packages/ui/src/i18n/it.ts | 40 ++--- packages/ui/src/i18n/nl.ts | 40 ++--- .../transforms/transform-i18n.test.ts | 14 ++ script/upstream/transforms/transform-i18n.ts | 5 +- .../transforms/transform-package-json.test.ts | 20 ++- .../transforms/transform-package-json.ts | 10 +- script/upstream/utils/upstream.ts | 4 +- 14 files changed, 236 insertions(+), 94 deletions(-) create mode 100644 packages/tui/test/kilocode/spinner-runtime.test.ts diff --git a/bun.lock b/bun.lock index 624b655bb0..560952fd1a 100644 --- a/bun.lock +++ b/bun.lock @@ -657,7 +657,6 @@ "open": "10.1.2", "opencode-gitlab-auth": "2.1.0", "opencode-poe-auth": "0.0.1", - "opentui-spinner": "catalog:", "partial-json": "0.1.7", "remeda": "catalog:", "ripgrep": "0.3.1", @@ -925,7 +924,6 @@ "effect": "catalog:", "fuzzysort": "catalog:", "open": "10.1.2", - "opentui-spinner": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", "strip-ansi": "7.1.2", @@ -1085,7 +1083,6 @@ "luxon": "3.6.1", "marked": "17.0.6", "marked-shiki": "1.2.1", - "opentui-spinner": "0.0.7", "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", @@ -3068,7 +3065,7 @@ "cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="], - "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], "cli-truncate": ["cli-truncate@3.1.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^5.0.0" } }, "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA=="], @@ -4136,8 +4133,6 @@ "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], - "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], - "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -4904,8 +4899,6 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], @@ -5772,12 +5765,6 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "opentui-spinner/@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], - - "opentui-spinner/@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], - - "ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], @@ -6482,28 +6469,6 @@ "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - - "opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - - "opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], - - "opentui-spinner/@opentui/core/@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], - - "opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - - "opentui-spinner/@opentui/core/@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], - - "opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - - "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], - - "opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], - - "opentui-spinner/@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - - "opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -6758,8 +6723,6 @@ "mocha/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/package.json b/package.json index 536136f32e..2432d4a2f1 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.2.10", - "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9e567324b9..57b6f50184 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -154,7 +154,6 @@ "open": "10.1.2", "opencode-gitlab-auth": "2.1.0", "opencode-poe-auth": "0.0.1", - "opentui-spinner": "catalog:", "remeda": "catalog:", "ripgrep": "0.3.1", "semver": "^7.6.3", diff --git a/packages/tui/package.json b/packages/tui/package.json index d6772cde4b..6a8c46a73d 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -61,7 +61,6 @@ "effect": "catalog:", "fuzzysort": "catalog:", "open": "10.1.2", - "opentui-spinner": "catalog:", "remeda": "catalog:", "strip-ansi": "7.1.2", "solid-js": "catalog:" diff --git a/packages/tui/src/component/register-spinner.ts b/packages/tui/src/component/register-spinner.ts index 9626cea4ce..7dc9b672aa 100644 --- a/packages/tui/src/component/register-spinner.ts +++ b/packages/tui/src/component/register-spinner.ts @@ -1,6 +1,139 @@ -import { getComponentCatalogue } from "@opentui/solid/components" -import { registerSpinner } from "opentui-spinner/solid" +// kilocode_change start - register against Kilo's active OpenTUI runtime instead of opentui-spinner's nested 0.3 runtime +import { + type ColorInput, + type OptimizedBuffer, + parseColor, + Renderable, + type RenderableOptions, + type RenderContext, + resolveRenderLib, +} from "@opentui/core" +import { extend, getComponentCatalogue } from "@opentui/solid/components" +import type { ColorGenerator } from "../ui/spinner" + +interface SpinnerOptions extends RenderableOptions { + frames?: string[] + interval?: number + color?: ColorInput | ColorGenerator +} + +class SpinnerRenderable extends Renderable { + private list: string[] + private delay: number + private tone: ColorInput | ColorGenerator + private parsed = parseColor("white") + private frame = 0 + private encoded: Record>> = {} + private lib = resolveRenderLib() + private timer: ReturnType | undefined + + constructor(ctx: RenderContext, options: SpinnerOptions) { + super(ctx, options) + this.list = options.frames?.length ? options.frames : ["⠋"] + this.delay = options.interval ?? 80 + this.tone = options.color ?? "white" + if (typeof this.tone !== "function") this.parsed = parseColor(this.tone) + this.height = 1 + this.encode() + this.start() + } + + private encode() { + this.encoded = {} + this.width = 0 + for (const frame of this.list) { + const encoded = this.lib.encodeUnicode(frame, this.ctx.widthMethod) + if (!encoded) continue + this.encoded[frame] = encoded + this.width = Math.max( + this.width, + encoded.data.reduce((width, char) => width + char.width, 0), + ) + } + } + + private free() { + for (const frame of Object.values(this.encoded)) this.lib.freeUnicode(frame) + this.encoded = {} + } + + private start() { + if (this.timer) clearInterval(this.timer) + this.timer = setInterval(() => { + if (this.isDestroyed || !this.visible) return + this.frame = (this.frame + 1) % this.list.length + this.requestRender() + }, this.delay) + this.timer.unref() + } + + get frames() { + return this.list + } + + set frames(value: string[]) { + if ( + value.length === 0 || + (value.length === this.list.length && value.every((frame, index) => frame === this.list[index])) + ) + return + this.free() + this.list = value + this.frame = 0 + this.encode() + this.requestRender() + } + + get interval() { + return this.delay + } + + set interval(value: number) { + if (value === this.delay) return + this.delay = value + this.start() + } + + get color() { + return this.tone + } + + set color(value: ColorInput | ColorGenerator) { + this.tone = value + if (typeof value !== "function") this.parsed = parseColor(value) + this.requestRender() + } + + protected override renderSelf(buffer: OptimizedBuffer) { + const frame = this.encoded[this.list[this.frame]] + if (!frame) return + const background = parseColor("transparent") + let x = this.x + for (let index = 0; index < frame.data.length; index++) { + const char = frame.data[index] + const color = + typeof this.tone === "function" + ? parseColor(this.tone(this.frame, index, this.list.length, frame.data.length)) + : this.parsed + buffer.drawChar(char.char, x, this.y, color, background) + x += char.width + } + } + + protected override destroySelf() { + if (this.timer) clearInterval(this.timer) + this.free() + super.destroySelf() + } +} + +declare module "@opentui/solid" { + interface OpenTUIComponents { + spinner: typeof SpinnerRenderable + } +} export function registerOpencodeSpinner() { - if (!getComponentCatalogue().spinner) registerSpinner() + if (!getComponentCatalogue().spinner) extend({ spinner: SpinnerRenderable }) } +// kilocode_change end diff --git a/packages/tui/src/ui/spinner.ts b/packages/tui/src/ui/spinner.ts index c185ea7b83..424113a04b 100644 --- a/packages/tui/src/ui/spinner.ts +++ b/packages/tui/src/ui/spinner.ts @@ -1,6 +1,9 @@ import type { ColorInput } from "@opentui/core" import { RGBA } from "@opentui/core" -import type { ColorGenerator } from "opentui-spinner" + +// kilocode_change start - local spinner renderable stays on Kilo's active OpenTUI runtime +export type ColorGenerator = (frame: number, char: number, frames: number, chars: number) => ColorInput +// kilocode_change end interface AdvancedGradientOptions { colors: ColorInput[] diff --git a/packages/tui/test/kilocode/spinner-runtime.test.ts b/packages/tui/test/kilocode/spinner-runtime.test.ts new file mode 100644 index 0000000000..5006527ab3 --- /dev/null +++ b/packages/tui/test/kilocode/spinner-runtime.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test" +import { Renderable } from "@opentui/core" +import { getComponentCatalogue } from "@opentui/solid/components" +import { registerOpencodeSpinner } from "../../src/component/register-spinner" + +test("spinner uses the active OpenTUI runtime", () => { + registerOpencodeSpinner() + const spinner = getComponentCatalogue().spinner + expect(spinner).toBeDefined() + expect(spinner?.prototype).toBeInstanceOf(Renderable) +}) diff --git a/packages/ui/src/i18n/it.ts b/packages/ui/src/i18n/it.ts index fc0947b972..0550975b5c 100644 --- a/packages/ui/src/i18n/it.ts +++ b/packages/ui/src/i18n/it.ts @@ -16,22 +16,22 @@ export const dict: Record = { "ui.sessionReview.largeDiff.title": "Diff troppo grande da mostrare", "ui.sessionReview.largeDiff.meta": "Limite: {{limit}} righe modificate. Corrente: {{current}} righe modificate.", "ui.sessionReview.largeDiff.renderAnyway": "Mostra comunque", - "ui.sessionReviewV2.expandMode": "Expand or collapse diff", - "ui.sessionReviewV2.filterFiles": "Filter files", - "ui.sessionReviewV2.toggleSidebar": "Toggle file tree", - "ui.sessionReviewV2.showAllLines": "Show all lines", - "ui.sessionReviewV2.hideNonDiffLines": "Hide non-diff lines", - "ui.sessionReviewV2.unifiedDiff": "Unified diff", - "ui.sessionReviewV2.splitDiff": "Split diff", - "ui.sessionReviewV2.previousFile": "Previous file", - "ui.sessionReviewV2.nextFile": "Next file", - "ui.sessionReviewV2.diffView": "Diff view", - "ui.sessionReviewV2.empty.noGit.title": "No tracked changes", - "ui.sessionReviewV2.empty.noGit.description": "Track, review, and undo changes in this project", - "ui.sessionReviewV2.empty.noGit.action": "Create Git repository", - "ui.sessionReviewV2.empty.noGit.actionLoading": "Creating Git repository...", - "ui.sessionReviewV2.empty.changes.title": "No file changes yet", - "ui.sessionReviewV2.empty.changes.description": "Project changes will appear here", + "ui.sessionReviewV2.expandMode": "Espandi o comprimi il diff", + "ui.sessionReviewV2.filterFiles": "Filtra file", + "ui.sessionReviewV2.toggleSidebar": "Mostra o nascondi l'albero dei file", + "ui.sessionReviewV2.showAllLines": "Mostra tutte le righe", + "ui.sessionReviewV2.hideNonDiffLines": "Nascondi le righe non modificate", + "ui.sessionReviewV2.unifiedDiff": "Diff unificato", + "ui.sessionReviewV2.splitDiff": "Diff diviso", + "ui.sessionReviewV2.previousFile": "File precedente", + "ui.sessionReviewV2.nextFile": "File successivo", + "ui.sessionReviewV2.diffView": "Vista diff", + "ui.sessionReviewV2.empty.noGit.title": "Nessuna modifica tracciata", + "ui.sessionReviewV2.empty.noGit.description": "Traccia, rivedi e annulla le modifiche in questo progetto", + "ui.sessionReviewV2.empty.noGit.action": "Crea repository Git", + "ui.sessionReviewV2.empty.noGit.actionLoading": "Creazione repository Git...", + "ui.sessionReviewV2.empty.changes.title": "Ancora nessuna modifica ai file", + "ui.sessionReviewV2.empty.changes.description": "Le modifiche al progetto appariranno qui", "ui.sessionReview.openFile": "Apri file", "ui.sessionReview.selection.line": "riga {{line}}", "ui.sessionReview.selection.lines": "righe {{start}}-{{end}}", @@ -52,15 +52,15 @@ export const dict: Record = { "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "Aggiungi commento", "ui.lineComment.submit": "Commenta", - "ui.lineComment.cancel": "Cancel", + "ui.lineComment.cancel": "Annulla", "ui.sessionTurn.steps.show": "Mostra passaggi", "ui.sessionTurn.steps.hide": "Nascondi passaggi", "ui.sessionTurn.summary.response": "Risposta", "ui.sessionTurn.diff.showMore": "Mostra altre modifiche ({{count}})", "ui.sessionTurn.diffs.changed": "Modificato", - "ui.sessionTurn.diffs.changed.one": "{{count}} Changed file", - "ui.sessionTurn.diffs.changed.other": "{{count}} Changed files", + "ui.sessionTurn.diffs.changed.one": "{{count}} file modificato", + "ui.sessionTurn.diffs.changed.other": "{{count}} file modificati", "ui.sessionTurn.diffs.showAll": "Mostra tutto", "ui.sessionTurn.diffs.showLess": "Mostra meno", "ui.sessionTurn.diffs.more": "+{{count}} file aggiuntivi", @@ -180,7 +180,7 @@ export const dict: Record = { "ui.common.close": "Chiudi", "ui.common.next": "Avanti", "ui.common.submit": "Invia", - "ui.common.showMore": "Show more", + "ui.common.showMore": "Mostra altro", "ui.permission.deny": "Nega", "ui.permission.allowAlways": "Consenti sempre", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index df421c92fb..a4c566c752 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -16,22 +16,22 @@ export const dict: Record = { "ui.sessionReview.largeDiff.title": "Diff te groot om weer te geven", "ui.sessionReview.largeDiff.meta": "Limiet: {{limit}} gewijzigde regels. Huidig: {{current}} gewijzigde regels.", "ui.sessionReview.largeDiff.renderAnyway": "Toch weergeven", - "ui.sessionReviewV2.expandMode": "Expand or collapse diff", - "ui.sessionReviewV2.filterFiles": "Filter files", - "ui.sessionReviewV2.toggleSidebar": "Toggle file tree", - "ui.sessionReviewV2.showAllLines": "Show all lines", - "ui.sessionReviewV2.hideNonDiffLines": "Hide non-diff lines", - "ui.sessionReviewV2.unifiedDiff": "Unified diff", - "ui.sessionReviewV2.splitDiff": "Split diff", - "ui.sessionReviewV2.previousFile": "Previous file", - "ui.sessionReviewV2.nextFile": "Next file", - "ui.sessionReviewV2.diffView": "Diff view", - "ui.sessionReviewV2.empty.noGit.title": "No tracked changes", - "ui.sessionReviewV2.empty.noGit.description": "Track, review, and undo changes in this project", - "ui.sessionReviewV2.empty.noGit.action": "Create Git repository", - "ui.sessionReviewV2.empty.noGit.actionLoading": "Creating Git repository...", - "ui.sessionReviewV2.empty.changes.title": "No file changes yet", - "ui.sessionReviewV2.empty.changes.description": "Project changes will appear here", + "ui.sessionReviewV2.expandMode": "Diff uit- of inklappen", + "ui.sessionReviewV2.filterFiles": "Bestanden filteren", + "ui.sessionReviewV2.toggleSidebar": "Bestandsboom tonen of verbergen", + "ui.sessionReviewV2.showAllLines": "Alle regels tonen", + "ui.sessionReviewV2.hideNonDiffLines": "Ongewijzigde regels verbergen", + "ui.sessionReviewV2.unifiedDiff": "Gecombineerde diff", + "ui.sessionReviewV2.splitDiff": "Gesplitste diff", + "ui.sessionReviewV2.previousFile": "Vorig bestand", + "ui.sessionReviewV2.nextFile": "Volgend bestand", + "ui.sessionReviewV2.diffView": "Diffweergave", + "ui.sessionReviewV2.empty.noGit.title": "Geen bijgehouden wijzigingen", + "ui.sessionReviewV2.empty.noGit.description": "Wijzigingen in dit project bijhouden, beoordelen en ongedaan maken", + "ui.sessionReviewV2.empty.noGit.action": "Git-repository maken", + "ui.sessionReviewV2.empty.noGit.actionLoading": "Git-repository maken...", + "ui.sessionReviewV2.empty.changes.title": "Nog geen bestandswijzigingen", + "ui.sessionReviewV2.empty.changes.description": "Projectwijzigingen verschijnen hier", "ui.sessionReview.openFile": "Bestand openen", "ui.sessionReview.selection.line": "regel {{line}}", "ui.sessionReview.selection.lines": "regels {{start}}-{{end}}", @@ -52,15 +52,15 @@ export const dict: Record = { "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "Opmerking toevoegen", "ui.lineComment.submit": "Reageren", - "ui.lineComment.cancel": "Cancel", + "ui.lineComment.cancel": "Annuleren", "ui.sessionTurn.steps.show": "Stappen tonen", "ui.sessionTurn.steps.hide": "Stappen verbergen", "ui.sessionTurn.summary.response": "Antwoord", "ui.sessionTurn.diff.showMore": "Toon meer wijzigingen ({{count}})", "ui.sessionTurn.diffs.changed": "Gewijzigd", - "ui.sessionTurn.diffs.changed.one": "{{count}} Changed file", - "ui.sessionTurn.diffs.changed.other": "{{count}} Changed files", + "ui.sessionTurn.diffs.changed.one": "{{count}} gewijzigd bestand", + "ui.sessionTurn.diffs.changed.other": "{{count}} gewijzigde bestanden", "ui.sessionTurn.diffs.showAll": "Alles tonen", "ui.sessionTurn.diffs.showLess": "Minder tonen", "ui.sessionTurn.diffs.more": "+{{count}} extra bestanden", @@ -184,7 +184,7 @@ export const dict: Record = { "ui.common.close": "Sluiten", "ui.common.next": "Volgende", "ui.common.submit": "Verzenden", - "ui.common.showMore": "Show more", + "ui.common.showMore": "Meer tonen", "ui.permission.deny": "Weigeren", "ui.permission.allowAlways": "Altijd toestaan", diff --git a/script/upstream/transforms/transform-i18n.test.ts b/script/upstream/transforms/transform-i18n.test.ts index 399f51c935..a604d8ec55 100644 --- a/script/upstream/transforms/transform-i18n.test.ts +++ b/script/upstream/transforms/transform-i18n.test.ts @@ -1,12 +1,26 @@ import { expect, test } from "bun:test" import { transformI18nContent } from "./transform-i18n" +import { translate } from "../utils/upstream" test("marks transformed Kilo branding and preserves legacy config names", () => { const result = transformI18nContent( ' "product": "OpenCode",\n "docs": "https://opencode.ai/docs",\n "legacy": ".opencode/opencode.json",', + false, + true, ) expect(result.result).toContain('"product": "Kilo", // kilocode_change') expect(result.result).toContain('"docs": "https://kilo.ai/docs", // kilocode_change') expect(result.result).toContain('"legacy": ".opencode/opencode.json",') expect(result.replacements).toBe(2) }) + +test("does not inject source markers into non-locale content", () => { + const result = transformI18nContent("OpenCode uses opencode serve") + expect(result.result).toBe("Kilo uses kilo serve") +}) + +test("generic upstream translation keeps prompt text marker-free", async () => { + const result = await translate("packages/opencode/src/session/prompt/meta.txt", "OpenCode uses opencode serve") + expect(result).toBe("Kilo uses kilo serve") + expect(result).not.toContain("kilocode_change") +}) diff --git a/script/upstream/transforms/transform-i18n.ts b/script/upstream/transforms/transform-i18n.ts index 4e2e63a183..ffc4b29ac7 100644 --- a/script/upstream/transforms/transform-i18n.ts +++ b/script/upstream/transforms/transform-i18n.ts @@ -148,6 +148,7 @@ function shouldPreserveLine(line: string): boolean { export function transformI18nContent( content: string, verbose = false, + markers = false, ): { result: string; replacements: number; preserved: number } { const lines = content.split("\n") const transformedLines: string[] = [] @@ -201,7 +202,7 @@ export function transformI18nContent( } // Kilo branding produced by this transform remains a Kilo-owned delta in shared locale files. - transformedLines.push(lineReplacements > 0 ? `${transformedLine} // kilocode_change` : transformedLine) + transformedLines.push(markers && lineReplacements > 0 ? `${transformedLine} // kilocode_change` : transformedLine) totalReplacements += lineReplacements } @@ -222,7 +223,7 @@ export async function transformI18nFile( const file = Bun.file(filePath) const content = await file.text() - const { result, replacements, preserved } = transformI18nContent(content, options.verbose) + const { result, replacements, preserved } = transformI18nContent(content, options.verbose, true) if (replacements > 0 && !options.dryRun) { await Bun.write(filePath, result) diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index 49e6524eaf..bd8bb288b4 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -10,6 +10,7 @@ import { mergeWithNewestVersions, prunePatchedDependencies, selectBunPackageManager, + transformDependencies, } from "./transform-package-json" test("fixScripts preserves Kilo-only root scripts from base", () => { @@ -99,7 +100,12 @@ test("fixScripts preserves dev:local and shared-package test:ci scripts", () => const junit = "mkdir -p .artifacts/unit && bun test --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" const root: Record = { scripts: { dev: "bun dev" } } const changes: string[] = [] - fixScripts(root, "package.json", { scripts: { "dev:local": "bun run packages/opencode/script/dev-local.ts" } }, changes) + fixScripts( + root, + "package.json", + { scripts: { "dev:local": "bun run packages/opencode/script/dev-local.ts" } }, + changes, + ) expect((root.scripts as Record)["dev:local"]).toBe("bun run packages/opencode/script/dev-local.ts") for (const path of [ @@ -158,12 +164,13 @@ test("fixScripts leaves unknown packages untouched", () => { expect(changes.length).toBe(0) }) -test("fixCatalog removes upstream-only desktop sentry entries", () => { +test("fixCatalog removes unsupported upstream entries", () => { const pkg: Record = { workspaces: { catalog: { "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", + "opentui-spinner": "0.0.7", "solid-js": "1.9.12", }, }, @@ -173,8 +180,9 @@ test("fixCatalog removes upstream-only desktop sentry entries", () => { const cat = (pkg.workspaces as { catalog: Record }).catalog expect(cat["@sentry/solid"]).toBeUndefined() expect(cat["@sentry/vite-plugin"]).toBeUndefined() + expect(cat["opentui-spinner"]).toBeUndefined() expect(cat["solid-js"]).toBe("1.9.12") - expect(changes.length).toBe(2) + expect(changes.length).toBe(3) }) test("fixCatalog is a no-op when catalog is absent", () => { @@ -184,6 +192,12 @@ test("fixCatalog is a no-op when catalog is absent", () => { expect(changes.length).toBe(0) }) +test("transformDependencies removes the incompatible spinner runtime", () => { + const result = transformDependencies({ "opentui-spinner": "catalog:", "solid-js": "catalog:" }) + expect(result.result).toEqual({ "solid-js": "catalog:" }) + expect(result.changes).toEqual(["opentui-spinner: removed (incompatible OpenTUI runtime)"]) +}) + test("fixMetadata preserves opencode publish metadata from base", () => { const ours = { keywords: ["cli", "kilo", "opencode"], private: false } const pkg: Record = { keywords: ["opencode"], private: true } diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 1532ac841f..ed3981a788 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -328,9 +328,11 @@ const DELETE_UPSTREAM_SCRIPTS: Record = { // in by upstream features (e.g. desktop Sentry integration) that Kilo doesn't // ship, so they add install weight with zero consumers in our tree. const DELETE_UPSTREAM_CATALOG: Record = { - "package.json": ["@sentry/solid", "@sentry/vite-plugin"], + "package.json": ["@sentry/solid", "@sentry/vite-plugin", "opentui-spinner"], } +const DELETE_UPSTREAM_DEPENDENCIES = new Set(["opentui-spinner"]) + /** * Re-apply Kilo-specific scripts on top of the upstream-shaped scripts block, * and prune upstream-only scripts that target packages Kilo doesn't ship. @@ -449,7 +451,7 @@ export function isPackageJson(file: string): boolean { /** * Transform dependencies in package.json */ -function transformDependencies(deps: Record | undefined): { +export function transformDependencies(deps: Record | undefined): { result: Record changes: string[] } { @@ -459,6 +461,10 @@ function transformDependencies(deps: Record | undefined): { const changes: string[] = [] for (const [name, version] of Object.entries(deps)) { + if (DELETE_UPSTREAM_DEPENDENCIES.has(name)) { + changes.push(`${name}: removed (incompatible OpenTUI runtime)`) + continue + } const newName = PACKAGE_NAME_MAP[name] if (newName) { result[newName] = version diff --git a/script/upstream/utils/upstream.ts b/script/upstream/utils/upstream.ts index ab4107c40d..9a40069de4 100644 --- a/script/upstream/utils/upstream.ts +++ b/script/upstream/utils/upstream.ts @@ -4,7 +4,7 @@ import { $ } from "bun" import path from "node:path" import { applyPackageNameTransforms } from "../transforms/package-names" import { applyExtensionTransforms } from "../transforms/transform-extensions" -import { transformI18nContent } from "../transforms/transform-i18n" +import { isI18nFile, transformI18nContent } from "../transforms/transform-i18n" import { applyScriptTransforms } from "../transforms/transform-scripts" import { applyBrandingTransforms } from "../transforms/transform-take-theirs" import { applyWebTransforms } from "../transforms/transform-web" @@ -191,7 +191,7 @@ export async function translate(file: string, text: string) { const names = applyPackageNameTransforms(text).result const script = applyScriptTransforms(names).result const branded = applyBrandingTransforms(script).result - const i18n = transformI18nContent(branded).result + const i18n = transformI18nContent(branded, false, isI18nFile(file)).result const ext = applyExtensionTransforms(i18n, file).result const web = applyWebTransforms(ext).result