From 6b8c736dc1c97544467f6edf8026d271149e4164 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Tue, 21 Jul 2026 14:14:06 -0600 Subject: [PATCH 01/78] 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 00000000000..9131915a7e9 --- /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 233f6238379..a2781b892f1 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 2d576c3bcea..486b7b3abed 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 58bb3e8de26..b8ffd081479 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 00000000000..f3d90021563 --- /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 cf605c78bf7..b7113eb5b80 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 fe259e24a0b..39f3b7453ab 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 ed1b1a4fd66..65ff805e56a 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/78] 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 a2b6331bbba..d028a830280 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 76077daeabb..16b65019b96 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/78] 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 00000000000..9ac86fcbf9d --- /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/78] fix(jetbrains): sharpen copy icon --- .changeset/smooth-copy-icon.md | 5 +++++ .../frontend/src/main/resources/icons/copy.svg | 4 ++-- .../frontend/src/main/resources/icons/copy_dark.svg | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/smooth-copy-icon.md diff --git a/.changeset/smooth-copy-icon.md b/.changeset/smooth-copy-icon.md new file mode 100644 index 00000000000..817db023967 --- /dev/null +++ b/.changeset/smooth-copy-icon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Sharpen the chat message copy icon in JetBrains. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg index 7ee8dc0f842..f73b6c541c5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg index fcbadd38270..b578567ed8a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg @@ -1,3 +1,3 @@ - - + + From d5e19d5b36e02cb6deb1575a682d6974f7e50017 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 2 Aug 2026 20:26:05 -0400 Subject: [PATCH 05/78] fix(jetbrains): update reasoning icon --- .changeset/bright-reasoning-icon.md | 5 +++++ .../frontend/src/main/resources/icons/views/brain.svg | 6 ++++-- .../frontend/src/main/resources/icons/views/brain_dark.svg | 6 ++++-- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/bright-reasoning-icon.md diff --git a/.changeset/bright-reasoning-icon.md b/.changeset/bright-reasoning-icon.md new file mode 100644 index 00000000000..7f04efb0fb2 --- /dev/null +++ b/.changeset/bright-reasoning-icon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Update the JetBrains reasoning block icon to a lightbulb shape. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg index 304a93ea9c5..72b3551622d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg index 894515662bf..38e3f872ec2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg @@ -1,3 +1,5 @@ - - + + + + From c47cfeceebcd6b2ae5c0d416bde00f7e57449df8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 14:53:48 -0400 Subject: [PATCH 06/78] fix(jetbrains): align reverted diff action --- .changeset/jetbrains-revert-diff-card.md | 5 + .../kilocode/backend/cli/KiloCliDataParser.kt | 59 +++++++- .../backend/cli/KiloCliDataParserTest.kt | 9 +- .../client/session/ui/RevertBanner.kt | 129 ++++++++++++++++-- .../session/ui/SessionMessageListPanel.kt | 1 + .../session/views/base/BaseQuestionView.kt | 16 ++- .../resources/messages/KiloBundle.properties | 1 + .../messages/KiloBundle_ar.properties | 1 + .../messages/KiloBundle_bs.properties | 1 + .../messages/KiloBundle_da.properties | 1 + .../messages/KiloBundle_de.properties | 1 + .../messages/KiloBundle_es.properties | 1 + .../messages/KiloBundle_fr.properties | 1 + .../messages/KiloBundle_ja.properties | 1 + .../messages/KiloBundle_ko.properties | 1 + .../messages/KiloBundle_nl.properties | 1 + .../messages/KiloBundle_no.properties | 1 + .../messages/KiloBundle_pl.properties | 1 + .../messages/KiloBundle_pt_BR.properties | 1 + .../messages/KiloBundle_ru.properties | 1 + .../messages/KiloBundle_th.properties | 1 + .../messages/KiloBundle_tr.properties | 1 + .../messages/KiloBundle_uk.properties | 1 + .../messages/KiloBundle_zh_CN.properties | 1 + .../messages/KiloBundle_zh_TW.properties | 1 + .../session/ui/SessionMessageListPanelTest.kt | 111 ++++++++++++++- .../views/base/BaseQuestionViewTest.kt | 9 +- .../kotlin/ai/kilocode/rpc/dto/SessionDto.kt | 1 + 28 files changed, 332 insertions(+), 27 deletions(-) create mode 100644 .changeset/jetbrains-revert-diff-card.md diff --git a/.changeset/jetbrains-revert-diff-card.md b/.changeset/jetbrains-revert-diff-card.md new file mode 100644 index 00000000000..7c779212745 --- /dev/null +++ b/.changeset/jetbrains-revert-diff-card.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show reverted-card diff actions inline with the session header and open rolled-back changes in the diff viewer. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index d18a8c0dee4..25c44b5d90b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -1516,14 +1516,71 @@ object KiloCliDataParser { private fun parseRevert(obj: JsonObject?): SessionRevertDto? { if (obj == null) return null val message = obj.str("messageID") ?: return null + val diff = obj.str("diff") return SessionRevertDto( messageID = message, partID = obj.str("partID"), snapshot = obj.str("snapshot"), - diff = obj.str("diff"), + diff = diff, + diffs = parseUnifiedDiff(diff), ) } + private fun parseUnifiedDiff(diff: String?): List { + if (diff.isNullOrBlank()) return emptyList() + val lines = diff.lines() + val starts = lines.mapIndexedNotNull { index, line -> if (line.startsWith("diff --git ")) index else null } + if (starts.isEmpty()) return emptyList() + return starts.mapIndexedNotNull { index, start -> + val end = starts.getOrNull(index + 1) ?: lines.size + parseUnifiedBlock(lines.subList(start, end).joinToString("\n")) + } + } + + private fun parseUnifiedBlock(block: String): DiffFileDto? { + val lines = block.lines() + val file = unifiedFile(lines) ?: return null + return DiffFileDto( + file = file, + additions = lines.count { it.startsWith("+") && !it.startsWith("+++") }, + deletions = lines.count { it.startsWith("-") && !it.startsWith("---") }, + patch = block, + status = unifiedStatus(lines), + ) + } + + private fun unifiedFile(lines: List): String? { + val next = lines.firstOrNull { it.startsWith("+++ ") }?.removePrefix("+++ ") + val prev = lines.firstOrNull { it.startsWith("--- ") }?.removePrefix("--- ") + val path = sequenceOf(next, prev) + .filterNotNull() + .firstOrNull { it != "/dev/null" } + ?: lines.firstOrNull()?.let(::gitDiffTarget) + return path?.let(::cleanDiffPath) + } + + private fun unifiedStatus(lines: List): String = when { + lines.any { it == "new file mode" || it.startsWith("new file mode ") } -> "added" + lines.any { it == "deleted file mode" || it.startsWith("deleted file mode ") } -> "deleted" + lines.any { it.startsWith("--- /dev/null") } -> "added" + lines.any { it.startsWith("+++ /dev/null") } -> "deleted" + else -> "modified" + } + + private fun gitDiffTarget(line: String): String? { + val match = Regex("^diff --git a/(.*) b/(.*)$").find(line) ?: return null + return match.groupValues.getOrNull(2) + } + + private fun cleanDiffPath(path: String): String { + val text = path.trim().trim('"') + return when { + text.startsWith("a/") -> text.removePrefix("a/") + text.startsWith("b/") -> text.removePrefix("b/") + else -> text + } + } + // ================================================================ // Internal — status parsing // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 93514a7a750..ab67406dbf2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -691,7 +691,7 @@ class KiloCliDataParserTest { "messageID": "msg_rollback", "partID": "prt_rollback", "snapshot": "snap_rollback", - "diff": "diff --git a/file b/file" + "diff": "diff --git a/src/A.kt b/src/A.kt\n--- a/src/A.kt\n+++ b/src/A.kt\n@@ -1 +1,2 @@\n-old\n+new\n+more\ndiff --git a/src/Old.kt b/src/Old.kt\ndeleted file mode 100644\n--- a/src/Old.kt\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone" } } } @@ -705,6 +705,13 @@ class KiloCliDataParserTest { assertEquals(2, result.session.summary?.files) assertEquals("msg_rollback", result.session.revert?.messageID) assertEquals("prt_rollback", result.session.revert?.partID) + assertEquals(2, result.session.revert?.diffs?.size) + assertEquals("src/A.kt", result.session.revert?.diffs?.get(0)?.file) + assertEquals(2, result.session.revert?.diffs?.get(0)?.additions) + assertEquals(1, result.session.revert?.diffs?.get(0)?.deletions) + assertEquals("modified", result.session.revert?.diffs?.get(0)?.status) + assertEquals("src/Old.kt", result.session.revert?.diffs?.get(1)?.file) + assertEquals("deleted", result.session.revert?.diffs?.get(1)?.status) } @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index 7eb0106b7c5..5579e96a468 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -1,22 +1,32 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Dimension import javax.swing.JPanel +import javax.swing.ScrollPaneConstants class RevertBanner( private val model: SessionModel, @@ -24,15 +34,54 @@ class RevertBanner( private val redoAllAction: () -> Unit, private val cancelAction: () -> Unit, focus: (() -> Unit)? = null, + private var openDiff: SessionDiffOpener = { _, _, _ -> }, + private var sessionId: String? = null, ) : BorderLayoutPanel(), SessionView, SessionEditorStyleTarget { override val sessionViewKind = SessionView.Kind.Default + companion object { + /** Cap the reverted-file list to this many rows before scrolling. */ + const val MAX_FILE_ROWS = 10 + } + private val card = BaseQuestionView(focus = focus) + private val title = JBLabel() + + private val diff = toolbarButton( + ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer), + ).apply { + isEnabled = false + isVisible = false + } + + private val header = PartHeader().apply { + leading(JBLabel(AllIcons.Actions.Back)) + left(title) + left(PartHeader.centered(diff)) + } + private val body = Stack.vertical(UiStyle.Gap.lg()) private val files = Stack.vertical(UiStyle.Gap.xs()) + private val scroll = object : JBScrollPane( + files, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, + ) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + val cap = rowCap() + return Dimension(size.width, if (cap > 0) minOf(size.height, cap) else size.height) + } + }.apply { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + isOpaque = false + viewport.isOpaque = false + } + private val rows = LinkedHashMap() private var progress: RevertProgress? = null @@ -46,8 +95,8 @@ class RevertBanner( init { isOpaque = false - card.setHeaderIcon(AllIcons.Actions.Back, KiloBundle.message("revert.message.rollback")) - body.next(files).next(hint).next(notice) + card.setTopPanel(header) + body.next(scroll).next(hint).next(notice) card.setContent(body) card.setActions(listOf( BaseQuestionView.Action("redo", KiloBundle.message("revert.banner.redo"), primary = false) { redoAction() }, @@ -58,22 +107,33 @@ class RevertBanner( update() } + @RequiresEdt + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { + this.openDiff = openDiff + this.sessionId = sessionId + } + @RequiresEdt fun update() { val revert = model.revert() isVisible = revert != null if (revert == null) return val total = model.revertedCount() - card.setHeader(KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total)) + title.text = KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total) card.setActionVisible("all", total > 1) notice.isVisible = revert.snapshot == null - val keep = model.diff.mapTo(LinkedHashSet()) { it.file } + val diffs = resolveDiffs(revert) + val names = disambiguate(diffs.map { it.file }) + diff.isVisible = diffs.isNotEmpty() + diff.isEnabled = diffs.isNotEmpty() + val keep = diffs.mapTo(LinkedHashSet()) { it.file } rows.entries.removeIf { it.key !in keep } - val order = model.diff.map { item -> + scroll.isVisible = diffs.isNotEmpty() + val order = diffs.map { item -> val row = rows.getOrPut(item.file) { - Row(item.file) + Row(item) } - row.update(item.file, item.additions, item.deletions) + row.update(item, names[item.file] ?: item.file) row.panel } if (files.components.toList() != order) { @@ -105,15 +165,42 @@ class RevertBanner( override fun applyStyle(style: SessionEditorStyle) { card.applyStyle(style) + title.font = style.headerFont + title.foreground = UiStyle.Colors.fg() progress?.applyStyle(style) hint.foreground = UIUtil.getLabelForeground() notice.foreground = UIUtil.getContextHelpForeground() rows.values.forEach { it.applyStyle() } } - private class Row(file: String) { - private val label = JBLabel(file) - private val badge = DiffStatBadge(0, 0) + /** + * The rolled-back diff to render. Prefer the snapshot diff the CLI attaches to the revert; fall + * back to the session's current file diff when the pinned CLI doesn't provide it. Empty when no + * snapshot exists (files were not restored, so there is nothing to diff). + */ + private fun resolveDiffs(revert: ai.kilocode.rpc.dto.SessionRevertDto): List = + if (revert.snapshot == null) emptyList() else revert.diffs.ifEmpty { model.diff } + + private fun openDiffViewer() { + val revert = model.revert() ?: return + val diffs = resolveDiffs(revert) + if (diffs.isEmpty()) return + openDiff(diffs, KiloBundle.message("revert.banner.openDiff.title"), "revert:${sessionId ?: "pending"}:${revert.messageID}") + } + + /** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */ + private fun rowCap(): Int { + val comps = files.components + if (comps.size <= MAX_FILE_ROWS) return 0 + val gap = UiStyle.Gap.xs() + return (0 until MAX_FILE_ROWS).sumOf { comps[it].preferredSize.height } + gap * (MAX_FILE_ROWS - 1) + } + + private class Row(item: DiffFileDto) { + private val label = JBLabel(item.file).apply { + toolTipText = item.file + } + private val badge = DiffStatBadge(item.additions, item.deletions) val panel: JPanel = Stack.horizontal(UiStyle.Gap.sm()) .next(label) .next(badge) @@ -122,9 +209,10 @@ class RevertBanner( applyStyle() } - fun update(file: String, additions: Int, deletions: Int) { - if (label.text != file) label.text = file - badge.update(additions, deletions) + fun update(item: DiffFileDto, text: String) { + if (label.text != text) label.text = text + if (label.toolTipText != item.file) label.toolTipText = item.file + badge.update(item.additions, item.deletions) } fun applyStyle() { @@ -132,3 +220,18 @@ class RevertBanner( } } } + +internal fun disambiguate(paths: List): Map { + val parts = paths.associateWith { split(it) } + return paths.groupBy { parts[it]?.lastOrNull().orEmpty() }.values.flatMap { group -> + if (group.size == 1) return@flatMap listOf(group.first() to (parts[group.first()]?.lastOrNull() ?: group.first())) + val depth = (1..(group.maxOf { parts[it]?.size ?: 1 })).firstOrNull { count -> + group.map { suffix(parts[it].orEmpty(), count) }.toSet().size == group.size + } ?: group.maxOf { parts[it]?.size ?: 1 } + group.map { it to suffix(parts[it].orEmpty(), depth) } + }.toMap() +} + +private fun split(path: String): List = path.split('/', '\\').filter { it.isNotEmpty() } + +private fun suffix(parts: List, depth: Int): String = parts.takeLast(depth).joinToString("/") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 5f60ef388b3..8e6edd1ed6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -214,6 +214,7 @@ class SessionMessageListPanel( fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { this.openDiff = openDiff this.sessionId = sessionId + banner?.setDiffOpener(openDiff, sessionId) turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 5d39e41feb1..113a1c7b924 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -93,7 +93,9 @@ class BaseQuestionView( } private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true) - private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false) + private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false).apply { + isVisible = false + } private var top: JComponent? = null private var content: JComponent? = null @@ -131,6 +133,7 @@ class BaseQuestionView( fun setHeader(text: String, description: String? = null) { headerText.text = text setDescription(description) + syncNorth() } /** @@ -141,6 +144,7 @@ class BaseQuestionView( fun setDescription(text: String?) { descriptionText.text = text ?: "" descriptionText.isVisible = !text.isNullOrBlank() + syncNorth() } // ---- public slot API ---- @@ -170,8 +174,7 @@ class BaseQuestionView( if (icon == null && attached) header.remove(this.icon) this.icon.revalidate() this.icon.repaint() - header.revalidate() - header.repaint() + syncNorth() } /** @@ -297,12 +300,14 @@ class BaseQuestionView( private fun syncNorth() { north.removeAll() top?.let { north.next(it) } - north.next(header) + if (hasHeader()) north.next(header) if (content != null) north.fill(gap) north.revalidate() north.repaint() } + private fun hasHeader() = icon.icon != null || headerText.text.isNotBlank() || descriptionText.isVisible + private fun syncFooter() { val layout = footer.layout as BorderLayout val west = layout.getLayoutComponent(BorderLayout.WEST) @@ -331,7 +336,8 @@ class BaseQuestionView( private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { val area = object : JBTextArea(value) { - override fun getPreferredSize() = withWidth(super.getPreferredSize().height) + override fun getPreferredSize() = + withWidth(super.getPreferredSize().height) override fun getMaximumSize(): Dimension { val size = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 56e2bbb3ef6..da8ff8a09ac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -44,6 +44,7 @@ revert.banner.redo=Redo revert.banner.redo.all=Redo All revert.banner.hint=You can redo these changes until you send a new message revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.message.rollback=Rollback to this message session.status.rollingback=Rolling back\u2026 session.status.redoing=Redoing\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 15944a645df..5d1fc08ba83 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=تعديل مسار الم settings.agentBehavior.skills.sources.editUrl.title=تعديل URL المهارات session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=تم التراجع عن رسالة واحدة revert.banner.count.other=تم التراجع عن {0} رسائل revert.banner.redo=إعادة diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index df5882b5000..d857e0f611d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Uredi putanju vještina settings.agentBehavior.skills.sources.editUrl.title=Uredi URL vještina session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} poruka vraćena revert.banner.count.other={0} poruka vraćeno revert.banner.redo=Ponovi diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 65d9d0909cd..a75a3b5239c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger færdighedssti settings.agentBehavior.skills.sources.editUrl.title=Rediger færdigheds-URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} besked rullet tilbage revert.banner.count.other={0} beskeder rullet tilbage revert.banner.redo=Gentag diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 9b0ca266323..3d4cd77b63a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Skill-Pfad bearbeiten settings.agentBehavior.skills.sources.editUrl.title=Skill-URL bearbeiten session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} Nachricht zurückgesetzt revert.banner.count.other={0} Nachrichten zurückgesetzt revert.banner.redo=Wiederholen diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 0d19e12cfd9..d9243cafa2f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar ruta de habilidades settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mensaje revertido revert.banner.count.other={0} mensajes revertidos revert.banner.redo=Rehacer diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 683bf55d439..ae0c82fff40 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Modifier le chemin des comp settings.agentBehavior.skills.sources.editUrl.title=Modifier l’URL des compétences session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} message annulé revert.banner.count.other={0} messages annulés revert.banner.redo=Rétablir diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index e9e1c52f3cb..04da2eb8463 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=スキルパスを編集 settings.agentBehavior.skills.sources.editUrl.title=スキル URL を編集 session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} 件のメッセージをロールバックしました revert.banner.count.other={0} 件のメッセージをロールバックしました revert.banner.redo=やり直し diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index c2e935a4ed7..42e0dcad68d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=스킬 경로 편집 settings.agentBehavior.skills.sources.editUrl.title=스킬 URL 편집 session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0}개 메시지가 롤백됨 revert.banner.count.other={0}개 메시지가 롤백됨 revert.banner.redo=다시 실행 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e43ba741bd8..193c71cc7ba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Vaardigheidspad bewerken settings.agentBehavior.skills.sources.editUrl.title=Vaardigheids-URL bewerken session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} bericht teruggedraaid revert.banner.count.other={0} berichten teruggedraaid revert.banner.redo=Opnieuw uitvoeren diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index d391bcc32a3..92b9a8f79e8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger ferdighetssti settings.agentBehavior.skills.sources.editUrl.title=Rediger ferdighets-URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} melding rullet tilbake revert.banner.count.other={0} meldinger rullet tilbake revert.banner.redo=Gjør om diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index ef4c5efe137..94a3cba2758 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Edytuj ścieżkę umiejętn settings.agentBehavior.skills.sources.editUrl.title=Edytuj URL umiejętności session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Cofnięto {0} wiadomość revert.banner.count.other=Cofnięto {0} wiadomości revert.banner.redo=Ponów diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 2bf9c4c4cce..3d31ef77333 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar caminho de habilidad settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mensagem revertida revert.banner.count.other={0} mensagens revertidas revert.banner.redo=Refazer diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index aef654244b3..31af59b74c3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Изменить путь settings.agentBehavior.skills.sources.editUrl.title=Изменить URL навыков session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Отменено сообщений: {0} revert.banner.count.other=Отменено сообщений: {0} revert.banner.redo=Повторить diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 8959598aa7a..49dedd7bcbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=แก้ไขเส้น settings.agentBehavior.skills.sources.editUrl.title=แก้ไข URL ทักษะ session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=ย้อนกลับข้อความ {0} รายการแล้ว revert.banner.count.other=ย้อนกลับข้อความ {0} รายการแล้ว revert.banner.redo=ทำซ้ำ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index b954188a5ca..221755be455 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Beceri Yolunu Düzenle settings.agentBehavior.skills.sources.editUrl.title=Beceri URL'sini Düzenle session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mesaj geri alındı revert.banner.count.other={0} mesaj geri alındı revert.banner.redo=Yinele diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 77ea64c7752..bbe9ae6b190 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=Редагувати шля settings.agentBehavior.skills.sources.editUrl.title=Редагувати URL навичок session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Відкочено повідомлень: {0} revert.banner.count.other=Відкочено повідомлень: {0} revert.banner.redo=Повторити diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 909073cae3e..bb29932afc0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=编辑技能路径 settings.agentBehavior.skills.sources.editUrl.title=编辑技能 URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=已回滚 {0} 条消息 revert.banner.count.other=已回滚 {0} 条消息 revert.banner.redo=重做 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index bc74d81c93a..dc251b70f45 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -427,6 +427,7 @@ settings.agentBehavior.skills.sources.editPath.title=編輯技能路徑 settings.agentBehavior.skills.sources.editUrl.title=編輯技能 URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=已回復 {0} 則訊息 revert.banner.count.other=已回復 {0} 則訊息 revert.banner.redo=重做 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index ab7f800934b..3e23202907b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -15,6 +15,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView @@ -29,6 +30,7 @@ import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto @@ -60,6 +62,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.RepaintManager +import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities import javax.swing.border.Border @@ -1080,8 +1083,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { banner.update() assertNotNull(find(banner)) + assertNotNull(components(banner).filterIsInstance().singleOrNull()) - val buttons = components(banner).filterIsInstance() + val buttons = components(banner).filterIsInstance().filter { it.text.isNotEmpty() } assertEquals( listOf(KiloBundle.message("revert.banner.redo"), KiloBundle.message("revert.banner.redo.all")), buttons.map { it.text }, @@ -1098,7 +1102,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { fun `test rollback banner reuses file rows across updates`() { val banner = RevertBanner(model, {}, {}, {}) model.upsertMessage(msg("u1", "user")) - model.setRevert(SessionRevertDto("u1")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0), DiffFileDto("src/B.kt", 2, 1))) banner.update() val rows = components(banner).filterIsInstance().filter { stack -> @@ -1122,6 +1126,102 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("-2", badges[1].removedLabelForTest().text) } + fun `test rollback banner caps file list with scroll pane`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff((1..80).map { DiffFileDto("src/file-$it.kt", it, 0) }) + + banner.update() + + val scroll = components(banner).filterIsInstance().single() + assertTrue(scroll.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED) + assertTrue(scroll.horizontalScrollBarPolicy == ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED) + val rows = rowLabels(banner).mapNotNull { it.parent } + val rowHeight = rows.first().preferredSize.height + val cap = rowHeight * RevertBanner.MAX_FILE_ROWS + UiStyle.Gap.xs() * (RevertBanner.MAX_FILE_ROWS - 1) + assertEquals(cap, scroll.preferredSize.height) + } + + fun `test rollback banner shortens duplicate file names with parents`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf( + DiffFileDto("apps/main/src/App.kt", 1, 0), + DiffFileDto("packages/ui/src/App.kt", 2, 1), + DiffFileDto("packages/ui/src/Button.kt", 3, 0), + )) + + banner.update() + + val labels = rowLabels(banner).map { it.text to it.toolTipText } + assertTrue(labels.contains("main/src/App.kt" to "apps/main/src/App.kt")) + assertTrue(labels.contains("ui/src/App.kt" to "packages/ui/src/App.kt")) + assertTrue(labels.contains("Button.kt" to "packages/ui/src/Button.kt")) + } + + fun `test rollback banner opens rolled back diff`() { + val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") + val opened = mutableListOf>() + val titles = mutableListOf() + val keys = mutableListOf() + val banner = RevertBanner(model, {}, {}, {}) + banner.setDiffOpener({ files, title, key -> + opened.add(files) + titles.add(title) + keys.add(key) + }, "ses_1") + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1", diffs = listOf(diff))) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertTrue(button.isVisible) + assertTrue(button.isEnabled) + button.doClick() + + assertEquals(listOf(diff), opened.single()) + assertEquals(KiloBundle.message("revert.banner.openDiff.title"), titles.single()) + assertEquals("revert:ses_1:u1", keys.single()) + } + + fun `test rollback banner hides open diff without a snapshot`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = null)) + model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0, PATCH))) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertFalse(button.isVisible) + assertFalse(button.isEnabled) + } + + fun `test rollback banner opens session diff when revert diff is absent`() { + val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") + val opened = mutableListOf>() + val banner = RevertBanner(model, {}, {}, {}) + banner.setDiffOpener({ files, _, _ -> opened.add(files) }, "ses_1") + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf(diff)) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertTrue(button.isVisible) + assertTrue(button.isEnabled) + button.doClick() + + assertEquals(listOf(diff), opened.single()) + } + fun `test rollback banner shows redo all only for multiple reverted messages`() { val banner = RevertBanner(model, {}, {}, {}) model.upsertMessage(msg("u1", "user")) @@ -1168,7 +1268,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { banner.setReverting(SessionState.Reverting("Rolling back...", SessionState.Reverting.Kind.ROLLBACK, "u1")) - val buttons = components(banner).filterIsInstance() + val buttons = components(banner).filterIsInstance().filter { it.text.isNotEmpty() } assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo") }.all { !it.isEnabled }) assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo.all") }.all { !it.isEnabled }) val progress = components(banner).filterIsInstance().single() @@ -1521,6 +1621,11 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { return out } + private fun rowLabels(root: Component): List = components(root) + .filterIsInstance() + .filter { stack -> stack.components.any { it is DiffStatBadge } } + .mapNotNull { stack -> components(stack).filterIsInstance().firstOrNull() } + private fun taskText(view: TaskToolView): List { val scroll = components(view).filterIsInstance().single() val stack = components(scroll.viewport.view).filterIsInstance().single() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 6018178065f..9f38bca3202 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -21,12 +21,11 @@ class BaseQuestionViewTest : BasePlatformTestCase() { // ------ initial state ------ - fun `test header and description text areas are in the component tree by default`() { + fun `test empty card does not render a header row by default`() { edt { val panel = BaseQuestionView() assertTrue("Root layout should be BorderLayout", panel.layout is BorderLayout) - val areas = findAll(panel) - assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2) + assertNull("Header row should be omitted until header content exists", headerRow(panel)) } } @@ -87,6 +86,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { fun `test setTopPanel adds component before header`() { edt { val panel = BaseQuestionView() + panel.setHeader("Title") val top = JLabel("top") panel.setTopPanel(top) @@ -280,9 +280,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } - fun `test header row has no west icon gap by default`() { + fun `test header row has no west icon gap without icon`() { edt { val panel = BaseQuestionView() + panel.setHeader("Title") val header = headerRow(panel)!! val west = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) assertNull("header should not reserve icon space when icon is absent", west) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt index 82818fccb49..26c54b7d080 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt @@ -21,6 +21,7 @@ data class SessionRevertDto( val partID: String? = null, val snapshot: String? = null, val diff: String? = null, + val diffs: List = emptyList(), ) @Serializable From 9b4738ee55fc2c84ee2f8c7bbeb0ebd98bb69dbf Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:26:01 -0400 Subject: [PATCH 07/78] fix(jetbrains): load reverted session diffs --- .../jetbrains-reverted-session-diff-list.md | 5 +++ .../session/controller/SessionController.kt | 21 ++++++++++ .../controller/RevertDiffLoadingTest.kt | 38 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 .changeset/jetbrains-reverted-session-diff-list.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt diff --git a/.changeset/jetbrains-reverted-session-diff-list.md b/.changeset/jetbrains-reverted-session-diff-list.md new file mode 100644 index 00000000000..4f9ce88e550 --- /dev/null +++ b/.changeset/jetbrains-reverted-session-diff-list.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show the rolled-back file list when reopening a session whose last message was reverted. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 1370c72aa16..22bea5de8e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1011,6 +1011,7 @@ class SessionController( } } recoverPending(id) + seedRevertDiff(id) runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -1060,6 +1061,7 @@ class SessionController( } } recoverPending(session.id) + seedRevertDiff(session.id) runEdt { if (disposed) return@runEdt subscribeEvents() @@ -1091,6 +1093,25 @@ class SessionController( } } + /** + * Seed [SessionModel.diff] when opening a reverted session. The rolled-back file list in + * [ai.kilocode.client.session.ui.RevertBanner] falls back to `model.diff` when the CLI does not + * attach a diff to the revert marker. On a live revert a `session.diff` event seeds that; on + * reload nothing does, so fetch the persisted session diff once here. Skipped for sessions + * without a revert or once a diff is already present (e.g. a concurrent `session.diff` event). + */ + private suspend fun seedRevertDiff(id: String) { + var fetch = false + runEdt { fetch = !disposed && sid == id && model.revert() != null && model.diff.isEmpty() } + if (!fetch) return + val diffs = runCatching { sessions.diff(id, directory) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: return + runEdt { + if (disposed || sid != id) return@runEdt + if (model.revert() == null || model.diff.isNotEmpty()) return@runEdt + updateModel { model.setDiff(diffs) } + } + } + private fun startSessionLoading(token: SessionLoadState.Loading) { assertEdt() setSessionLoadState(token) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt new file mode 100644 index 00000000000..e2d831baa19 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt @@ -0,0 +1,38 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.SessionRevertDto + +/** + * A session reverted in a previous run has no `session.diff` event to replay on open, and the + * pinned CLI may not attach a diff to the revert marker. [SessionController.seedRevertDiff] fetches + * the persisted session diff so the reverted-files banner has something to render. + */ +class RevertDiffLoadingTest : SessionControllerTestBase() { + + fun `test opening a reverted session seeds model diff from the diff rpc`() { + rpc.session = session("ses_test").copy(revert = SessionRevertDto(messageID = "msg1", snapshot = "snap")) + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + rpc.history.add(MessageWithPartsDto(msg("msg2", "ses_test", "assistant"), emptyList())) + rpc.diffs["ses_test"] = mutableListOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")) + + val c = controller("ses_test") + flush() + + assertEquals("msg1", c.model.revert()?.messageID) + assertEquals(listOf("src/A.kt"), c.model.diff.map { it.file }) + } + + fun `test opening a session without a revert does not fetch the diff`() { + rpc.session = session("ses_test") + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + rpc.diffs["ses_test"] = mutableListOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")) + + val c = controller("ses_test") + flush() + + assertNull(c.model.revert()) + assertTrue(c.model.diff.isEmpty()) + } +} From 6eba81785718bfb3af579877fb332336f3a6eae3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:44:25 -0400 Subject: [PATCH 08/78] fix(jetbrains): show absolute reverted file tooltips --- .../client/session/ui/RevertBanner.kt | 29 +++++++++++++++++-- .../session/ui/SessionMessageListPanelTest.kt | 27 +++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index 5579e96a468..06ca8f68f04 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -24,7 +24,11 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Component +import java.awt.Container import java.awt.Dimension +import java.nio.file.Path +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -133,7 +137,7 @@ class RevertBanner( val row = rows.getOrPut(item.file) { Row(item) } - row.update(item, names[item.file] ?: item.file) + row.update(item, names[item.file] ?: item.file, absolute(item.file)) row.panel } if (files.components.toList() != order) { @@ -188,6 +192,15 @@ class RevertBanner( openDiff(diffs, KiloBundle.message("revert.banner.openDiff.title"), "revert:${sessionId ?: "pending"}:${revert.messageID}") } + private fun absolute(file: String): String { + val path = runCatching { Path.of(file) }.getOrNull() ?: return file + if (path.isAbsolute) return path.normalize().toString() + val root = model.session?.directory + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { Path.of(it) }.getOrNull() } + return (root?.resolve(path) ?: path.toAbsolutePath()).normalize().toString() + } + /** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */ private fun rowCap(): Int { val comps = files.components @@ -207,17 +220,27 @@ class RevertBanner( init { applyStyle() + tip(item.file) } - fun update(item: DiffFileDto, text: String) { + fun update(item: DiffFileDto, text: String, tip: String) { if (label.text != text) label.text = text - if (label.toolTipText != item.file) label.toolTipText = item.file + if (panel.toolTipText != tip) tip(tip) badge.update(item.additions, item.deletions) } fun applyStyle() { label.foreground = UIUtil.getLabelForeground() } + + private fun tip(text: String) { + tip(panel, text) + } + + private fun tip(node: Component, text: String) { + if (node is JComponent && node.toolTipText != text) node.toolTipText = text + if (node is Container) node.components.forEach { tip(it, text) } + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 3e23202907b..95ff55bf948 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -39,6 +39,8 @@ import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.SessionRevertDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.Disposable @@ -1161,6 +1163,31 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(labels.contains("Button.kt" to "packages/ui/src/Button.kt")) } + fun `test rollback banner uses full path tooltip for entire file row`() { + val banner = RevertBanner(model, {}, {}, {}) + model.setSession(SessionDto( + id = "ses", + projectID = "proj", + directory = "/workspace/root", + title = "Session", + version = "1", + time = SessionTimeDto(0.0, 0.0), + )) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf( + DiffFileDto("project/dir1/shared-alpha.txt", 0, 4), + DiffFileDto("project/dir2/shared-alpha.txt", 0, 4), + )) + + banner.update() + + val label = rowLabels(banner).first { it.text == "dir1/shared-alpha.txt" } + val row = label.parent as JComponent + assertEquals("/workspace/root/project/dir1/shared-alpha.txt", row.toolTipText) + assertTrue(components(row).filterIsInstance().all { it.toolTipText == "/workspace/root/project/dir1/shared-alpha.txt" }) + } + fun `test rollback banner opens rolled back diff`() { val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") val opened = mutableListOf>() From 4cdbe9d32551dd97c9d3e6067abfac0e04e2a580 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 16:55:50 -0400 Subject: [PATCH 09/78] fix(jetbrains): reconcile session layout cache --- .../client/session/ui/SessionLayout.kt | 27 +++++++++++ .../client/session/ui/SessionLayoutTest.kt | 48 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt index 94d57811264..c4b3d8e6f41 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -152,6 +152,33 @@ open class SessionLayoutPanel( layout = SessionLayout(gap, pad) } + override fun doLayout() { + super.doLayout() + reconcileValidateRoot() + } + + /** + * Keep the parent [SessionLayout]'s cached height honest across validate-root boundaries. + * + * A validate root, such as a settled [ai.kilocode.client.session.views.TurnView], can be laid out + * independently by `RepaintManager`. Its `isValid` flag can flip back to `true` before the parent + * transcript remeasures it, so [SessionLayout] may otherwise keep stacking it at a stale cached + * height until some unrelated resize changes the cache key. + * + * Once this root has laid out its own content, its rendered height should match its preferred + * height. If it does not, the parent cache is stale: drop this entry and revalidate the parent so + * the outer transcript geometry follows the content. Non-roots are skipped because their + * invalidation already propagates to the parent and keeps `isValid` an honest cache signal. + */ + private fun reconcileValidateRoot() { + if (!isValidateRoot()) return + val host = parent ?: return + val layout = host.layout as? SessionLayout ?: return + if (preferredSize.height == height) return + layout.forget(this) + (host as? javax.swing.JComponent)?.revalidate() + } + override fun getScrollableTracksViewportWidth() = true override fun getScrollableTracksViewportHeight() = false override fun getPreferredScrollableViewportSize(): Dimension = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt index 6e39e42c605..ced62e863ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt @@ -361,6 +361,34 @@ class SessionLayoutTest : BasePlatformTestCase() { assertEquals(sCount + 1, second.count) } + fun `test validate root child reconciles stale parent cache after self layout`() { + val p = panel(width = 300) + val child = rootProbe(root = true) + p.add(child) + p.doLayout() + child.markValid() + child.preferred = 80 + + child.doLayout() + p.doLayout() + + assertEquals(80, child.height) + } + + fun `test non validate root child keeps parent cache until it invalidates upward`() { + val p = panel(width = 300) + val child = rootProbe(root = false) + p.add(child) + p.doLayout() + child.markValid() + child.preferred = 80 + + child.doLayout() + p.doLayout() + + assertEquals(20, child.height) + } + // ---- helpers ------ /** A fixed-height JLabel. The width is reported as 0 until layout sets it. */ @@ -394,4 +422,24 @@ class SessionLayoutTest : BasePlatformTestCase() { return Dimension(0, height) } } + + private fun rootProbe(root: Boolean) = object : SessionLayoutPanel() { + var preferred = 20 + private var valid = false + + override fun isValid() = valid + + override fun invalidate() { + valid = false + super.invalidate() + } + + fun markValid() { + valid = true + } + + override fun isValidateRoot() = root + + override fun getPreferredSize(): Dimension = Dimension(0, preferred) + } } From 6414e64b291619457c13d089102059e4e0f29b06 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 09:38:14 -0400 Subject: [PATCH 10/78] fix(jetbrains): render multi-hunk diffs --- .changeset/jetbrains-multihunk-diff-viewer.md | 5 +++ .../client/diff/DiffPatchReconstruct.kt | 13 ++++--- .../client/diff/DiffPatchReconstructTest.kt | 37 ++++++++++++++++++- .../client/diff/KiloDiffEditorContentTest.kt | 25 +++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 .changeset/jetbrains-multihunk-diff-viewer.md diff --git a/.changeset/jetbrains-multihunk-diff-viewer.md b/.changeset/jetbrains-multihunk-diff-viewer.md new file mode 100644 index 00000000000..b4a871eebb9 --- /dev/null +++ b/.changeset/jetbrains-multihunk-diff-viewer.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render multi-hunk modified-file diffs correctly in the JetBrains diff viewer. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt index 654ef86da03..ca2c92cf46e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -53,11 +53,14 @@ internal object DiffPatchReconstruct { } } } - // Both producers (CLI snapshot and branchDiff) emit a single full-context hunk. A patch with - // several hunks, or one whose header lengths don't match the reconstructed body, has elided - // context: reconstructing would place every line at the wrong number, so fall back to the - // raw-patch view (renderable = false) instead of showing a misaligned side-by-side diff. - if (hunks != 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false) + // A patch may carry several hunks (limited-context git output) or a single full-context hunk. + // We concatenate every hunk body into contiguous before/after text: unchanged context lines + // anchor each region so the resulting side-by-side still colors adds/removes correctly. The + // elided gaps between hunks collapse (line numbers restart at 1), which is acceptable for a + // "what changed" view and far better than the all-green raw-patch fallback. We still bail when + // there is no hunk, or when the header lengths don't match the reconstructed body (truncated + // context), because that would place lines against the wrong side. + if (hunks < 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false) val left = if (added(patch)) "" else before.toString().removeSuffix("\n") val right = if (deleted(patch)) "" else after.toString().removeSuffix("\n") return DiffSides(left, right, true) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt index 24ab5b67c5a..0215144879a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -133,7 +133,42 @@ class DiffPatchReconstructTest { } @Test - fun `multi hunk partial context patch is not renderable`() { + fun `multi hunk patch reconstructs concatenated changed regions`() { + // Turn/tool diffs are ordinary limited-context git output with several hunks. The reconstruction + // stitches each hunk body into contiguous before/after text so the diff editor colors the + // changes instead of dumping the raw patch as all-added lines. Inter-hunk gaps collapse. + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 2, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + three + @@ -20,3 +20,3 @@ + twenty + -x + +X + z + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertTrue(sides.renderable) + assertEquals("one\ntwo\nthree\ntwenty\nx\nz", sides.before) + assertEquals("one\nTWO\nthree\ntwenty\nX\nz", sides.after) + } + + @Test + fun `multi hunk patch with truncated context is not renderable`() { + // header claims 3 old / 3 new lines per hunk but the body carries only 2 of each: reconstructing + // would misalign the sides, so fall back to the raw-patch view. val dto = DiffFileDto( file = "src/A.kt", additions = 2, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 7bf4d3c12bd..de63af8b033 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -291,6 +291,31 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { assertEquals("hello\nworld", contents[1]) } + fun `test diff request reconstructs multi hunk modified patch`() { + // Regression: multi-hunk modified diffs used to fall back to an empty original + raw patch on + // the right, rendering every line as added. They now reconstruct into a real side-by-side diff. + val patch = """ + diff --git a/src/App.kt b/src/App.kt + --- a/src/App.kt + +++ b/src/App.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + three + @@ -20,3 +20,3 @@ + twenty + -x + +X + z + """.trimIndent() + val request = diffRequest(project, file("src/App.kt", 2, 2, patch = patch)) as SimpleDiffRequest + val contents = request.contents.map(::content) + + assertEquals("one\ntwo\nthree\ntwenty\nx\nz", contents[0]) + assertEquals("one\nTWO\nthree\ntwenty\nX\nz", contents[1]) + } + fun `test tree displays absolute files relative to workspace`() { val parent = Disposer.newDisposable() try { From bfab19c047d142b24e5b0a4d9f4a5785b7d1cae8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 10:50:03 -0400 Subject: [PATCH 11/78] fix(jetbrains): resolve revert tooltip fallback --- .changeset/bright-reasoning-icon.md | 5 ----- .changeset/jetbrains-multihunk-diff-viewer.md | 5 ----- .changeset/jetbrains-revert-diff-card.md | 2 +- .changeset/jetbrains-reverted-session-diff-list.md | 5 ----- .changeset/smooth-copy-icon.md | 5 ----- .../kotlin/ai/kilocode/client/session/ui/RevertBanner.kt | 2 +- 6 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 .changeset/bright-reasoning-icon.md delete mode 100644 .changeset/jetbrains-multihunk-diff-viewer.md delete mode 100644 .changeset/jetbrains-reverted-session-diff-list.md delete mode 100644 .changeset/smooth-copy-icon.md diff --git a/.changeset/bright-reasoning-icon.md b/.changeset/bright-reasoning-icon.md deleted file mode 100644 index 7f04efb0fb2..00000000000 --- a/.changeset/bright-reasoning-icon.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Update the JetBrains reasoning block icon to a lightbulb shape. diff --git a/.changeset/jetbrains-multihunk-diff-viewer.md b/.changeset/jetbrains-multihunk-diff-viewer.md deleted file mode 100644 index b4a871eebb9..00000000000 --- a/.changeset/jetbrains-multihunk-diff-viewer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Render multi-hunk modified-file diffs correctly in the JetBrains diff viewer. diff --git a/.changeset/jetbrains-revert-diff-card.md b/.changeset/jetbrains-revert-diff-card.md index 7c779212745..fe8f75e3d3b 100644 --- a/.changeset/jetbrains-revert-diff-card.md +++ b/.changeset/jetbrains-revert-diff-card.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Show reverted-card diff actions inline with the session header and open rolled-back changes in the diff viewer. +Improve JetBrains session transcript layout, icons, reverted-change summaries, and multi-hunk diff rendering. diff --git a/.changeset/jetbrains-reverted-session-diff-list.md b/.changeset/jetbrains-reverted-session-diff-list.md deleted file mode 100644 index 4f9ce88e550..00000000000 --- a/.changeset/jetbrains-reverted-session-diff-list.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Show the rolled-back file list when reopening a session whose last message was reverted. diff --git a/.changeset/smooth-copy-icon.md b/.changeset/smooth-copy-icon.md deleted file mode 100644 index 817db023967..00000000000 --- a/.changeset/smooth-copy-icon.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Sharpen the chat message copy icon in JetBrains. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index 06ca8f68f04..e1273bdfc11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -198,7 +198,7 @@ class RevertBanner( val root = model.session?.directory ?.takeIf { it.isNotBlank() } ?.let { runCatching { Path.of(it) }.getOrNull() } - return (root?.resolve(path) ?: path.toAbsolutePath()).normalize().toString() + return root?.resolve(path)?.normalize()?.toString() ?: file } /** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */ From 5a955778daec603e11495343a432555736b4778f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 18:14:20 +0200 Subject: [PATCH 12/78] fix(vscode): speed snapshots and scope project state --- .changeset/fix-multi-project-session-scope.md | 5 + packages/kilo-vscode/.vscodeignore | 1 + packages/kilo-vscode/script/dev-snapshot.ts | 59 ++++--- packages/kilo-vscode/script/ffmpeg-helper.ts | 4 +- packages/kilo-vscode/script/local-bin.ts | 153 +++++++++++++----- packages/kilo-vscode/src/KiloProvider.ts | 106 ++++++++++-- .../src/agent-manager/AgentManagerProvider.ts | 6 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 4 + .../kilo-vscode/src/agent-manager/host.ts | 2 + .../src/agent-manager/vscode-host.ts | 1 + .../kilo-vscode/src/kilo-provider-utils.ts | 10 +- .../src/kilo-provider/git-status.ts | 4 +- .../kilo-vscode/tests/unit/git-ops.test.ts | 17 ++ .../tests/unit/kilo-provider-followup.test.ts | 107 ++++++++++++ .../kilo-provider-session-refresh.test.ts | 43 +++++ 15 files changed, 442 insertions(+), 80 deletions(-) create mode 100644 .changeset/fix-multi-project-session-scope.md diff --git a/.changeset/fix-multi-project-session-scope.md b/.changeset/fix-multi-project-session-scope.md new file mode 100644 index 00000000000..0f32e3327ac --- /dev/null +++ b/.changeset/fix-multi-project-session-scope.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Speed up local VS Code snapshot installs and scope Agent Manager session events and Git status to the active project, including edits inside nested repositories. diff --git a/packages/kilo-vscode/.vscodeignore b/packages/kilo-vscode/.vscodeignore index 157f9841d78..4f0ac692047 100644 --- a/packages/kilo-vscode/.vscodeignore +++ b/packages/kilo-vscode/.vscodeignore @@ -29,6 +29,7 @@ AGENTS.md # not ship in the VSIX, otherwise production installs are detected as local # builds and may inject a dev-only bwrap fallback (see ServerManager.localCli). bin/.cli-version +bin/.ffmpeg-target # Include WAV assets used by extension-host notification playback !audio-wav/** diff --git a/packages/kilo-vscode/script/dev-snapshot.ts b/packages/kilo-vscode/script/dev-snapshot.ts index edbe590573e..dcf5b5d9b77 100755 --- a/packages/kilo-vscode/script/dev-snapshot.ts +++ b/packages/kilo-vscode/script/dev-snapshot.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" +import { createRequire } from "node:module" import { join, dirname } from "node:path" import { tmpdir } from "node:os" import { rmSync, mkdirSync, existsSync } from "node:fs" @@ -26,37 +27,51 @@ console.log(`Commit: ${sha}`) console.log(`Mode: ${mode}\n`) console.log("🧹 Cleaning build directories...") -for (const dir of ["bin", "dist"]) { - const dirPath = join(root, dir) - if (existsSync(dirPath)) { - rmSync(dirPath, { recursive: true, force: true }) - console.log(` ✓ Cleaned ${dir}/`) - } +const dist = join(root, "dist") +if (existsSync(dist)) { + rmSync(dist, { recursive: true, force: true }) + console.log(" ✓ Cleaned dist/") } const outDir = join(tmpdir(), "kilo-vscode-snapshots") mkdirSync(outDir, { recursive: true }) -console.log("\n📦 Rebuilding SDK...") -await $`bun run --cwd ../sdk/js build`.cwd(root) +console.log("\n📦 Preparing SDK...") +await $`bun run prepare:sdk`.cwd(root) -console.log("\n🔧 Preparing CLI binary...") -await $`bun script/local-bin.ts --force`.cwd(root) - -console.log("\n✅ Type-checking...") -await $`bun run typecheck`.cwd(root) - -console.log("\n🔍 Linting...") -await $`bun run lint`.cwd(root) - -console.log("\n🏗️ Building extension...") -await $`node ${join(root, "esbuild.js")} --production`.cwd(root) +console.log("\n🔧 Preparing CLI binary and validating extension...") +await $`bun script/local-bin.ts --compiled`.cwd(root) +await $`bun run build:check:production`.cwd(root) console.log("\n📦 Packaging VSIX...") const vsixPath = join(outDir, `kilo-vscode-snapshot-${sha}-${user}.vsix`) -await $`bunx vsce package ${snapshotVersion} --no-update-package-json --no-dependencies --skip-license -o ${vsixPath}`.cwd( - root, -) +const require = createRequire(import.meta.url) +const zlib = require("node:zlib") as typeof import("node:zlib") +const DeflateRaw = zlib.DeflateRaw +if (shouldInstall) { + // Local installs favor fast packaging and extraction over archive size. + Object.defineProperty(zlib, "DeflateRaw", { + value: function (options?: ConstructorParameters[0]) { + return new DeflateRaw({ ...options, level: 0 }) + }, + }) +} +const { createVSIX } = await import("@vscode/vsce") +const marker = join(root, "bin", ".cli-version") +const cache = existsSync(marker) ? await Bun.file(marker).text() : undefined +if (cache !== undefined) rmSync(marker) +try { + await createVSIX({ + cwd: root, + packagePath: vsixPath, + version: snapshotVersion, + updatePackageJson: false, + dependencies: false, + skipLicense: true, + }) +} finally { + if (cache !== undefined) await Bun.write(marker, cache) +} if (shouldInstall) { const execPath = process.env.VSCODE_EXEC_PATH ?? "" diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts index c0b5e440ef4..1ee04fd78da 100644 --- a/packages/kilo-vscode/script/ffmpeg-helper.ts +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -23,7 +23,8 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" const dest = join(bin, exe) - if (existsSync(dest)) return + const marker = join(bin, ".ffmpeg-target") + if (existsSync(dest) && existsSync(marker) && (await Bun.file(marker).text()).trim() === target) return const tmp = join(bin, ".ffmpeg-tmp") rmSync(tmp, { recursive: true, force: true }) @@ -37,6 +38,7 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis await $`tar -xzf ${join(tmp, name)} -C ${tmp}`.quiet() copyFileSync(join(tmp, "package", exe), dest) if (!target.startsWith("win32")) chmodSync(dest, 0o755) + await Bun.write(marker, `${target}\n`) } finally { rmSync(tmp, { recursive: true, force: true }) } diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 9a2f11f550b..1de6ab95c23 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" +import { createHash } from "node:crypto" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" import { @@ -15,6 +16,7 @@ import { currentBwrapTarget, ensureBwrapForTarget } from "./bwrap-helper" import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") +const compiledOnly = process.argv.includes("--compiled") /** * Ensures the VS Code extension has a CLI binary at `packages/kilo-vscode/bin/kilo`. @@ -32,10 +34,8 @@ const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const repoDir = join(packagesDir, "..") const opencodeDir = join(packagesDir, "opencode") -const coreDir = join(packagesDir, "core") -const gatewayDir = join(packagesDir, "kilo-gateway") -const indexingDir = join(packagesDir, "kilo-indexing") const sandboxDir = join(packagesDir, "kilo-sandbox") +const rootFile = join(repoDir, "package.json") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" @@ -46,50 +46,123 @@ function log(msg: string) { console.log(`[local-bin] ${msg}`) } -async function cliSourceHash(): Promise { - try { - const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() - const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet() - const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() - const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() - const sandboxResult = await $`git log -1 --format=%H -- .`.cwd(sandboxDir).quiet() - return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}-${sandboxResult.text().trim()}` - } catch { - return null - } +type Package = { + name?: string + workspaces?: string[] | { packages?: string[] } + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record } -async function isDirty(): Promise { - try { - const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() - const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet() - const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() - const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() - const sandboxResult = await $`git status --porcelain -- .`.cwd(sandboxDir).quiet() - return ( - opencodeResult.text().trim().length > 0 || - coreResult.text().trim().length > 0 || - gatewayResult.text().trim().length > 0 || - indexingResult.text().trim().length > 0 || - sandboxResult.text().trim().length > 0 +async function cliInputs() { + const root: Package = await Bun.file(rootFile).json() + const workspaces = Array.isArray(root.workspaces) ? root.workspaces : (root.workspaces?.packages ?? []) + const files = ( + await Promise.all( + workspaces.map((pattern) => + Array.fromAsync(new Bun.Glob(`${pattern}/package.json`).scan({ cwd: repoDir, onlyFiles: true })), + ), ) - } catch { - return false + ).flat() + const entries = await Promise.all( + files.map(async (file) => ({ file, pkg: (await Bun.file(join(repoDir, file)).json()) as Package })), + ) + const packages = new Map(entries.flatMap((entry) => (entry.pkg.name ? [[entry.pkg.name, entry] as const] : []))) + const dirs = new Set() + + function visit(name: string) { + const entry = packages.get(name) + if (!entry) return + const dir = dirname(entry.file) + if (dirs.has(dir)) return + dirs.add(dir) + const deps = { + ...entry.pkg.dependencies, + ...entry.pkg.devDependencies, + ...entry.pkg.optionalDependencies, + ...entry.pkg.peerDependencies, + } + for (const dep of Object.keys(deps)) visit(dep) } + + // The CLI build embeds the console even though it is not a package dependency. + for (const dir of [opencodeDir, join(packagesDir, "kilo-console")]) { + const pkg: Package = await Bun.file(join(dir, "package.json")).json() + if (!pkg.name) throw new Error(`Workspace package at ${dir} has no name`) + visit(pkg.name) + } + + return [ + relative(repoDir, rootFile), + "bun.lock", + "patches", + ...[...dirs].sort(), + "packages/kilo-vscode/script/bwrap-helper.ts", + "packages/kilo-vscode/script/ffmpeg-helper.ts", + "packages/kilo-vscode/script/local-bin.ts", + "packages/kilo-vscode/src/services/cli-backend/cli-resources.ts", + ] } -async function isStale(): Promise { - if (await isDirty()) return true +async function cliSourceHash() { + const inputs = await cliInputs() + const [tree, diff, extra, branch] = await Promise.all([ + $`git ls-tree -r HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git diff --binary HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git ls-files --others --exclude-standard -z -- ${inputs}`.cwd(repoDir).quiet(), + $`git branch --show-current`.cwd(repoDir).quiet(), + ]) + const env = Object.fromEntries( + [ + "GH_REPO", + "KILO_BUMP", + "KILO_BWRAP_CACHE", + "KILO_CHANNEL", + "KILO_MODELS_URL", + "KILO_PRE_RELEASE", + "KILO_RELEASE", + "KILO_SKIP_BUNDLED_BWRAP", + "KILO_VERSION", + "MODELS_DEV_API_JSON", + "ZIG", + ].map((key) => [key, process.env[key] ?? ""]), + ) + const hash = createHash("sha256") + .update(tree.text()) + .update(diff.text()) + .update(branch.text()) + .update(JSON.stringify(env)) + const files = extra.text().split("\0").filter(Boolean).sort() + + for (const file of files) { + hash.update(file) + hash.update(new Uint8Array(await Bun.file(join(repoDir, file)).arrayBuffer())) + } + + const models = process.env.MODELS_DEV_API_JSON + if (models) hash.update(new Uint8Array(await Bun.file(models).arrayBuffer())) + return hash.digest("hex") +} + +async function isStale() { const hash = await cliSourceHash() - if (!hash) return false // can't determine — assume fresh try { - const stored = (await Bun.file(versionFile).text()).trim() - return stored !== hash + const stored: unknown = await Bun.file(versionFile).json() + if (!stored || typeof stored !== "object") return true + const input = Reflect.get(stored, "input") + const target = Reflect.get(stored, "target") + const kind = Reflect.get(stored, "kind") + return input !== hash || target !== platformTag() || (compiledOnly && kind !== "compiled") } catch { return true // no version file — treat as stale } } +async function writeVersion(kind: "compiled" | "wrapper") { + await Bun.write(versionFile, JSON.stringify({ input: await cliSourceHash(), target: platformTag(), kind }) + "\n") +} + function platformTag(): string { const os = process.platform === "win32" ? "windows" : process.platform return `cli-${os}-${process.arch}` @@ -115,6 +188,8 @@ async function findKiloBinaryInOpencodeDist(): Promise { // fall through to generic search } + if (compiledOnly) return null + // Fallback: find any dist/**/bin/kilo or kilo.exe const queue = [distDir] while (queue.length) { @@ -214,8 +289,7 @@ async function writeSourceWrapper() { await bundleKiloSandboxWorker() await ensureLocalHelpers() - const hash = await cliSourceHash() - if (hash) await Bun.write(versionFile, hash + "\n") + await writeVersion("wrapper") log( `Compiled CLI build failed; wrote source wrapper at ${relative(kiloVscodeDir, targetBinPath)} for local development.`, ) @@ -238,7 +312,7 @@ async function main() { return } - if (forceRebuild && !exists) { + if ((forceRebuild || compiledOnly) && !ready) { removeDist() } @@ -256,7 +330,7 @@ async function main() { } const sourceBinPath = await ensureBuiltBinary().catch(async (err) => { - if (forceRebuild) throw err + if (forceRebuild || compiledOnly) throw err await writeSourceWrapper() log(`Wrapper fallback reason: ${err instanceof Error ? err.message : String(err)}`) return null @@ -270,8 +344,7 @@ async function main() { chmodSync(targetBinPath, 0o755) await ensureLocalHelpers() - const hash = await cliSourceHash() - if (hash) await Bun.write(versionFile, hash + "\n") + await writeVersion("compiled") log(`Copied CLI binary from ${relative(packagesDir, sourceBinPath)} -> ${relative(kiloVscodeDir, targetBinPath)}`) } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index be4377e46ff..4c61c085d5f 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -50,7 +50,6 @@ import { import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" -import { getWorkspaceRoot } from "./review-utils" import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item" import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller" import type { RemoteStatusService } from "./services/RemoteStatusService" @@ -441,6 +440,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private statsGitOps: GitOps | null = null private cachedStats: unknown = null private cachedGitRepo = false + private cachedGitDirectory: string | undefined + private gitStatusRevision = 0 + private sessionRefreshRevision = 0 private onBeforeMessage: ((msg: Record) => Promise | null>) | null = null @@ -1664,6 +1666,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to SSE events for this webview (filtered by tracked sessions) this.unsubscribeEvent = this.connectionService.onEventFiltered( (payload, directory) => { + if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return false + if (!directory && isEventFromForeignProject(payload, this.projectID)) return false const event = unwrapSyncEvent(payload) if (!event) return false @@ -1675,6 +1679,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // message.part.* events are always session-scoped; drop if session unknown. if (!sessionId) return !isSessionScopedPartEvent(event.type) + if (!directory && !this.isCurrentProjectSession(sessionId)) return false if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) { return true @@ -1821,15 +1826,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.memory.fetch(), this.seedSessionStatusMap(), ]) - this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory()) - this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo }) + await this.refreshGitStatus(this.getWorkspaceDirectory()) this.sendNotificationSettings() this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) this.postMessage({ type: "extensionDataReady" }) - if (this.cachedGitRepo) this.startStatsPolling() - console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error) @@ -1890,6 +1892,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Non-blocking: refresh session metadata + status for the webview after switching. */ private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void { if (!this.client) return + void this.refreshGitStatus(dir) const revision = this.revisions.get(sessionID) const refresh = (this.refreshes.get(sessionID) ?? 0) + 1 this.refreshes.set(sessionID, refresh) @@ -2083,7 +2086,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** * Build the context object used by the extracted session-refresh helpers. */ - private get sessionRefreshContext(): SessionRefreshContext { + private getSessionRefreshContext(revision: number): SessionRefreshContext { const client = this.client return { pendingSessionRefresh: this.pendingSessionRefresh, @@ -2095,6 +2098,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper sessionDirectories: this.sessionDirectories, worktreeDirectories: this.opts.worktreeDirectories, workspaceDirectory: this.getWorkspaceDirectory(), + isCurrent: () => revision === this.sessionRefreshRevision, postMessage: (msg: unknown) => this.postMessage(msg), } } @@ -2105,10 +2109,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async flushPendingSessionRefresh(reason: string): Promise { if (!this.pendingSessionRefresh) return console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason }) - const ctx = this.sessionRefreshContext + const revision = ++this.sessionRefreshRevision + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined + const ctx = this.getSessionRefreshContext(revision) try { const resolved = await flushPendingSessionRefreshUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error) } @@ -2119,10 +2126,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Handle loading all sessions. */ private async handleLoadSessions(): Promise { - const ctx = this.sessionRefreshContext + const revision = ++this.sessionRefreshRevision + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined + const ctx = this.getSessionRefreshContext(revision) try { const resolved = await loadSessionsUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to load sessions:", error) this.postMessage({ @@ -4306,9 +4316,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return + if (directory && !this.isCurrentProjectDirectory(directory)) return if ( this.projectID && + (!this.opts.projectQualifier || !directory) && (event.type === "session.created" || event.type === "session.updated") && event.properties.info.projectID !== undefined && event.properties.info.projectID !== null && @@ -4367,6 +4378,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) return + if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID) + if (event.type === "session.updated" && typeof event.properties.info.cost === "number") { const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost) this.requestCostAlert(event.properties.sessionID, cost) @@ -4737,6 +4750,75 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return undefined } + private isCurrentProjectDirectory(directory: string): boolean { + if (!this.opts.projectQualifier?.()) return true + const dirs = [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])] + return dirs.some((dir) => sameDirectory(dir, directory)) + } + + private isCurrentProjectSession(sessionID: string): boolean { + if (!this.opts.projectQualifier || !this.opts.routeService) return true + if (this.isSessionRouteAmbiguous(sessionID)) return false + const directory = this.opts.routeService.trySessionDirectory(sessionID) + return !directory || this.isCurrentProjectDirectory(directory) + } + + private refreshGitStatusFromPart( + event: Extract, + sessionID?: string, + ) { + const part = event.properties.part as { + type?: string + metadata?: Record + state?: { input?: Record; metadata?: Record } + } + if (part.type !== "tool") return + const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath] + const file = values.find((value): value is string => typeof value === "string" && value.length > 0) + if (!file) return + const base = this.getWorkspaceDirectory(sessionID) + const value = file.split(",")[0].trim() + const pathName = path.isAbsolute(value) ? value : path.resolve(base, value) + void this.refreshGitStatus(path.dirname(pathName)) + } + + public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise { + const client = this.client + if (!client) return + const revision = ++this.gitStatusRevision + const direct = await hasGit(client, directory) + const root = await this.resolveGitRoot(directory) + if (revision !== this.gitStatusRevision) return + const repo = direct || root !== undefined + const target = root ?? directory + const changed = !this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target) + if (changed) { + this.cachedStats = null + this.statsPoller?.stop() + this.statsPoller = null + this.statsGitOps?.dispose() + this.statsGitOps = null + } + this.cachedGitDirectory = target + this.cachedGitRepo = repo + this.postMessage({ type: "gitStatus", repo }) + if (repo) { + if (!this.statsPoller) this.startStatsPolling() + return + } + this.statsPoller?.stop() + this.statsGitOps?.dispose() + this.statsPoller = null + this.statsGitOps = null + } + + private async resolveGitRoot(directory: string): Promise { + const git = this.statsGitOps ?? new GitOps({ log: () => {} }) + const root = await git.root(directory) + if (!this.statsGitOps) git.dispose() + return root + } + private getContextDirectory(): string { return resolveContextDirectory({ currentSessionID: this.currentSession?.id, @@ -4846,7 +4928,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsGitOps = git this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], - getWorkspaceRoot: () => getWorkspaceRoot(), + getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id), localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 39b844f9894..5453637a159 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1555,7 +1555,11 @@ export class AgentManagerProvider implements Disposable { void this.sendRepoInfo() if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c))) this.stateReady = this.initializeState() - else this.projectPollers.sync(this.contexts) + else { + this.panel?.sessions.refreshSessions() + this.projectPollers.sync(this.contexts) + } + this.panel?.sessions.refreshGitStatus?.() } private onWorkspaceChanged(): void { if (this.contexts.syncPinned()) { diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 92a2984b5a9..ea26906b893 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -171,6 +171,10 @@ export class GitOps { return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "") } + async root(cwd: string): Promise { + return this.raw(["rev-parse", "--show-toplevel"], cwd).catch(() => undefined) + } + /** * Resolve the remote name for a branch. Checks (in order): * 1. The configured upstream's remote (e.g. upstream from `upstream/main`) diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index f8e8cee3a64..296cb70caf3 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -66,6 +66,8 @@ export interface SessionProvider { isSessionRouteAmbiguous?(sessionId: string): boolean /** Exact directory for a project-qualified session ref, or undefined. */ routeSessionDirectoryFor?(ref: SessionRef): string | undefined + /** Re-check Git capability for the active project/session directory. */ + refreshGitStatus?(): void dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 4e1528f8a9b..a98f679d89e 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -151,6 +151,7 @@ export class VscodeHost implements Host { unregisterSessionRoute: (ref) => provider.unregisterSessionRoute(ref), isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId), routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref), + refreshGitStatus: () => void provider.refreshGitStatus(), dispose: () => provider.dispose(), } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c9825cb7713..c4bd2f9f74e 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -234,6 +234,7 @@ export interface SessionRefreshContext { sessionDirectories: Map worktreeDirectories?: () => string[] workspaceDirectory: string + isCurrent?: () => boolean postMessage(message: unknown): void } @@ -256,7 +257,7 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise() const extra = await Promise.all( [...worktreeDirs].map((dir) => @@ -276,6 +277,8 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise { - return client.project - .current({ directory }) + return Promise.resolve() + .then(() => client.project.current({ directory })) .then((r) => r.data?.vcs === "git") .catch(() => false) } diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index bd56efa8274..563620705d5 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -58,6 +58,23 @@ describe("GitOps", () => { }) }) + describe("root", () => { + it("resolves the nearest enclosing repository", async () => { + const git = ops(async (args) => { + if (args[0] === "rev-parse" && args[1] === "--show-toplevel") return "/workspace/frontend" + return "" + }) + expect(await git.root("/workspace/frontend/src")).toBe("/workspace/frontend") + }) + + it("returns undefined outside a repository", async () => { + const git = ops(async () => { + throw new Error("not a git repo") + }) + expect(await git.root("/workspace")).toBeUndefined() + }) + }) + describe("resolveRemote", () => { it("uses upstream remote when upstream is configured", async () => { const git = ops(async (args) => { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 1965da54521..732a08a92c5 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -8,8 +8,12 @@ type Internals = { webview: { postMessage: (message: unknown) => Promise } | null trackedSessionIds: Set currentSession: Session | null + projectID: string | undefined + isWebviewReady: boolean pendingFollowup: { dir: string; time: number } | null handleLoadMessages: (sessionID: string) => Promise + handleEvent: (event: Event, directory?: string) => void + refreshGitStatus: (directory?: string) => Promise initializeConnection: () => Promise syncWebviewState: () => Promise flushPendingSessionRefresh: () => Promise @@ -43,6 +47,18 @@ function created(input: { id: string; directory: string; parentID?: string }): E } as Event } +function info(input: { id: string; projectID: string; directory: string }): Session { + return { + id: input.id, + slug: `${input.id}-slug`, + projectID: input.projectID, + directory: input.directory, + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + } +} + function connection() { let filter: ((event: Event) => boolean) | undefined let listener: ((event: Event) => void) | undefined @@ -80,6 +96,97 @@ function connection() { } describe("KiloProvider follow-up sessions", () => { + it("scopes shared session events to the active project directory", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/repo/project-b", + projectQualifier: () => ({ projectId: "project-b" }), + }) + const internal = provider as unknown as Internals + const sent: unknown[] = [] + const sharedID = "ses-shared" + + internal.webview = { + postMessage: async (message: unknown) => { + sent.push(message) + return true + }, + } + internal.isWebviewReady = true + internal.currentSession = info({ id: sharedID, projectID: "backend-project-b", directory: "/repo/project-b" }) + internal.projectID = "backend-project-a" + internal.trackedSessionIds.add(sharedID) + + internal.handleEvent( + { + type: "message.updated", + properties: { + sessionID: sharedID, + info: { + id: "msg-project-a", + sessionID: sharedID, + role: "assistant", + time: { created: 1 }, + }, + }, + } as Event, + "/repo/project-a", + ) + expect(sent).toEqual([]) + + internal.handleEvent( + { + type: "session.created", + properties: { sessionID: sharedID, info: internal.currentSession }, + } as Event, + "/repo/project-b", + ) + expect(sent).toContainEqual({ + type: "sessionCreated", + session: { + id: sharedID, + title: "Session", + createdAt: new Date(1).toISOString(), + updatedAt: new Date(1).toISOString(), + parentID: null, + revert: null, + summary: null, + }, + }) + }) + + it("refreshes Git from the file path in a completed edit tool part", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/workspace", + projectQualifier: () => ({ projectId: "workspace" }), + }) + const internal = provider as unknown as Internals + const dirs: string[] = [] + const sessionID = "ses-edit" + internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" }) + internal.trackedSessionIds.add(sessionID) + internal.refreshGitStatus = async (directory) => { + if (directory) dirs.push(directory) + } + + internal.handleEvent( + { + type: "message.part.updated", + properties: { + sessionID, + part: { + type: "tool", + metadata: { filepath: "/workspace/frontend/src/app.ts" }, + }, + }, + } as Event, + "/workspace", + ) + + expect(dirs).toEqual(["/workspace/frontend/src"]) + }) + it("ignores subagents before adopting pending follow-up sessions", async () => { const service = connection() const provider = new KiloProvider({} as never, service as never) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 3cb7d11ed69..e96a67d6b71 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -9,11 +9,20 @@ type State = "connecting" | "connected" | "disconnected" | "error" type ProviderInternals = { connectionState: State pendingSessionRefresh: boolean + projectID: string | undefined webview: { postMessage: (message: unknown) => Promise } | null initializeConnection: () => Promise handleLoadSessions: () => Promise } +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + function createContext(overrides?: Partial): SessionRefreshContext & { sent: unknown[] } { const sent: unknown[] = [] return { @@ -100,6 +109,40 @@ function createConnection(client: ReturnType) { } describe("KiloProvider pending session refresh", () => { + it("does not let a late listing restore the previous project's identity", async () => { + const client = createClient() + const pending = new Map>>() + client.session.list = async (params: { directory: string }) => { + const next = deferred<{ data: unknown[] }>() + pending.set(params.directory, next) + return next.promise as never + } + const connection = createConnection(client) + await connection.connect() + let active = "a" + const provider = new KiloProvider({} as never, connection as never, undefined, { + rootDirectory: () => `/repo/${active}`, + projectQualifier: () => ({ projectId: active }), + }) + const internal = provider as unknown as ProviderInternals + internal.connectionState = "connected" + + const first = internal.handleLoadSessions() + active = "b" + const second = internal.handleLoadSessions() + + pending.get("/repo/b")!.resolve({ + data: [{ id: "ses-b", projectID: "backend-b", time: { created: 1, updated: 1 } }], + }) + await second + pending.get("/repo/a")!.resolve({ + data: [{ id: "ses-a", projectID: "backend-a", time: { created: 1, updated: 1 } }], + }) + await first + + expect(internal.projectID).toBe("backend-b") + }) + it("keeps worktree sessions with legacy project ids", async () => { const sent: unknown[] = [] const ctx = createContext({ From 6255cf815ddc5ae6f7a17022d8633e3bc64348c4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 18:49:26 +0200 Subject: [PATCH 13/78] fix(vscode): address snapshot review feedback --- packages/kilo-vscode/script/dev-snapshot.ts | 46 ++++----- packages/kilo-vscode/script/ffmpeg-helper.ts | 2 +- packages/kilo-vscode/script/local-bin.ts | 96 ++++++++++++------- packages/kilo-vscode/src/KiloProvider.ts | 5 +- .../tests/unit/kilo-provider-followup.test.ts | 1 + 5 files changed, 88 insertions(+), 62 deletions(-) diff --git a/packages/kilo-vscode/script/dev-snapshot.ts b/packages/kilo-vscode/script/dev-snapshot.ts index dcf5b5d9b77..14ed0ee64c3 100755 --- a/packages/kilo-vscode/script/dev-snapshot.ts +++ b/packages/kilo-vscode/script/dev-snapshot.ts @@ -46,32 +46,34 @@ await $`bun run build:check:production`.cwd(root) console.log("\n📦 Packaging VSIX...") const vsixPath = join(outDir, `kilo-vscode-snapshot-${sha}-${user}.vsix`) const require = createRequire(import.meta.url) -const zlib = require("node:zlib") as typeof import("node:zlib") -const DeflateRaw = zlib.DeflateRaw +const vsceRequire = createRequire(require.resolve("@vscode/vsce")) if (shouldInstall) { // Local installs favor fast packaging and extraction over archive size. - Object.defineProperty(zlib, "DeflateRaw", { - value: function (options?: ConstructorParameters[0]) { - return new DeflateRaw({ ...options, level: 0 }) - }, - }) + type Options = Record + type Zip = { + addFile(path: string, name: string, options?: Options): void + addBuffer(data: Uint8Array, name: string, options?: Options): void + } + const yazl = vsceRequire("yazl") as { ZipFile: { prototype: Zip } } + const zip = yazl.ZipFile.prototype + const file = zip.addFile + const buffer = zip.addBuffer + zip.addFile = function (this: Zip, path, name, options) { + return file.call(this, path, name, { ...options, compress: false }) + } + zip.addBuffer = function (this: Zip, data, name, options) { + return buffer.call(this, data, name, { ...options, compress: false }) + } } const { createVSIX } = await import("@vscode/vsce") -const marker = join(root, "bin", ".cli-version") -const cache = existsSync(marker) ? await Bun.file(marker).text() : undefined -if (cache !== undefined) rmSync(marker) -try { - await createVSIX({ - cwd: root, - packagePath: vsixPath, - version: snapshotVersion, - updatePackageJson: false, - dependencies: false, - skipLicense: true, - }) -} finally { - if (cache !== undefined) await Bun.write(marker, cache) -} +await createVSIX({ + cwd: root, + packagePath: vsixPath, + version: snapshotVersion, + updatePackageJson: false, + dependencies: false, + skipLicense: true, +}) if (shouldInstall) { const execPath = process.env.VSCODE_EXEC_PATH ?? "" diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts index 1ee04fd78da..eaf604a9d0e 100644 --- a/packages/kilo-vscode/script/ffmpeg-helper.ts +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -23,7 +23,7 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" const dest = join(bin, exe) - const marker = join(bin, ".ffmpeg-target") + const marker = join(bin, "..", "node_modules", ".kilo-ffmpeg-target") if (existsSync(dest) && existsSync(marker) && (await Bun.file(marker).text()).trim() === target) return const tmp = join(bin, ".ffmpeg-tmp") diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 1de6ab95c23..4b3c78e7498 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -40,7 +40,7 @@ const rootFile = join(repoDir, "package.json") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" const targetBinPath = join(targetBinDir, binName) -const versionFile = join(targetBinDir, ".cli-version") +const versionFile = join(kiloVscodeDir, "node_modules", ".kilo-cli-version") function log(msg: string) { console.log(`[local-bin] ${msg}`) @@ -106,47 +106,61 @@ async function cliInputs() { } async function cliSourceHash() { - const inputs = await cliInputs() - const [tree, diff, extra, branch] = await Promise.all([ - $`git ls-tree -r HEAD -- ${inputs}`.cwd(repoDir).quiet(), - $`git diff --binary HEAD -- ${inputs}`.cwd(repoDir).quiet(), - $`git ls-files --others --exclude-standard -z -- ${inputs}`.cwd(repoDir).quiet(), - $`git branch --show-current`.cwd(repoDir).quiet(), - ]) - const env = Object.fromEntries( - [ - "GH_REPO", - "KILO_BUMP", - "KILO_BWRAP_CACHE", - "KILO_CHANNEL", - "KILO_MODELS_URL", - "KILO_PRE_RELEASE", - "KILO_RELEASE", - "KILO_SKIP_BUNDLED_BWRAP", - "KILO_VERSION", - "MODELS_DEV_API_JSON", - "ZIG", - ].map((key) => [key, process.env[key] ?? ""]), - ) - const hash = createHash("sha256") - .update(tree.text()) - .update(diff.text()) - .update(branch.text()) - .update(JSON.stringify(env)) - const files = extra.text().split("\0").filter(Boolean).sort() + try { + const inputs = await cliInputs() + const [tree, diff, extra, branch] = await Promise.all([ + $`git ls-tree -r HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git diff --binary HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git ls-files --others --exclude-standard -z -- ${inputs}`.cwd(repoDir).quiet(), + $`git branch --show-current`.cwd(repoDir).quiet(), + ]) + const env = Object.fromEntries( + [ + "GH_REPO", + "KILO_BUMP", + "KILO_BWRAP_CACHE", + "KILO_CHANNEL", + "KILO_MODELS_URL", + "KILO_PRE_RELEASE", + "KILO_RELEASE", + "KILO_SKIP_BUNDLED_BWRAP", + "KILO_VERSION", + "MODELS_DEV_API_JSON", + "ZIG", + ].map((key) => [key, process.env[key] ?? ""]), + ) + const hash = createHash("sha256") + .update(tree.text()) + .update(diff.text()) + .update(branch.text()) + .update(JSON.stringify(env)) + const files = extra.text().split("\0").filter(Boolean).sort() - for (const file of files) { - hash.update(file) - hash.update(new Uint8Array(await Bun.file(join(repoDir, file)).arrayBuffer())) + for (const file of files) { + hash.update(file) + hash.update(new Uint8Array(await Bun.file(join(repoDir, file)).arrayBuffer())) + } + + const models = process.env.MODELS_DEV_API_JSON + if (models) hash.update(new Uint8Array(await Bun.file(models).arrayBuffer())) + return hash.digest("hex") + } catch (err) { + log(`Could not determine CLI source hash: ${err instanceof Error ? err.message : String(err)}`) + return null } - - const models = process.env.MODELS_DEV_API_JSON - if (models) hash.update(new Uint8Array(await Bun.file(models).arrayBuffer())) - return hash.digest("hex") } async function isStale() { const hash = await cliSourceHash() + if (!hash) { + if (!compiledOnly) return false + try { + const stored: unknown = await Bun.file(versionFile).json() + return Reflect.get(stored, "kind") !== "compiled" + } catch { + return true + } + } try { const stored: unknown = await Bun.file(versionFile).json() if (!stored || typeof stored !== "object") return true @@ -160,7 +174,12 @@ async function isStale() { } async function writeVersion(kind: "compiled" | "wrapper") { - await Bun.write(versionFile, JSON.stringify({ input: await cliSourceHash(), target: platformTag(), kind }) + "\n") + const input = await cliSourceHash() + if (!input) { + rmSync(versionFile, { force: true }) + return + } + await Bun.write(versionFile, JSON.stringify({ input, target: platformTag(), kind }) + "\n") } function platformTag(): string { @@ -296,6 +315,9 @@ async function writeSourceWrapper() { } async function main() { + for (const file of [join(targetBinDir, ".cli-version"), join(targetBinDir, ".ffmpeg-target")]) { + rmSync(file, { force: true }) + } const targetFile = Bun.file(targetBinPath) const exists = await targetFile.exists() const ready = exists && hasTreeSitterResources(targetBinPath) && hasKiloSandboxWorker(targetBinPath) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4c61c085d5f..76736609bfd 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -4316,7 +4316,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (directory && !this.isCurrentProjectDirectory(directory)) return + if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return if ( this.projectID && (!this.opts.projectQualifier || !directory) && @@ -4770,9 +4770,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const part = event.properties.part as { type?: string metadata?: Record - state?: { input?: Record; metadata?: Record } + state?: { status?: string; input?: Record; metadata?: Record } } if (part.type !== "tool") return + if (part.state?.status !== "completed") return const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath] const file = values.find((value): value is string => typeof value === "string" && value.length > 0) if (!file) return diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 732a08a92c5..b3af1f80eb2 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -178,6 +178,7 @@ describe("KiloProvider follow-up sessions", () => { part: { type: "tool", metadata: { filepath: "/workspace/frontend/src/app.ts" }, + state: { status: "completed" }, }, }, } as Event, From 3d079d29f067b323a61fb897cc711ae3068437cb Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 18:51:00 +0200 Subject: [PATCH 14/78] fix(vscode): keep snapshot cache markers out of package --- packages/kilo-vscode/.vscodeignore | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kilo-vscode/.vscodeignore b/packages/kilo-vscode/.vscodeignore index 4f0ac692047..157f9841d78 100644 --- a/packages/kilo-vscode/.vscodeignore +++ b/packages/kilo-vscode/.vscodeignore @@ -29,7 +29,6 @@ AGENTS.md # not ship in the VSIX, otherwise production installs are detected as local # builds and may inject a dev-only bwrap fallback (see ServerManager.localCli). bin/.cli-version -bin/.ffmpeg-target # Include WAV assets used by extension-host notification playback !audio-wav/** From 0983e07d3336c9e8e3bb10598c451676a587bf35 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 12:51:57 -0400 Subject: [PATCH 15/78] feat(jetbrains): full-file diff detail for editor tabs Add on-demand full-content diff loading for the JetBrains diff editor tab while keeping inline turn cards hunk-bounded. Adds a full/file query path to the CLI session diff endpoint and a diffFile RPC that matches the requested path so multi-file turns are not collapsed to a single entry. --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 44 ++++++++++ .../backend/rpc/KiloSessionRpcApiImplTest.kt | 83 +++++++++++++++++++ .../kilocode/backend/testing/MockCliServer.kt | 6 ++ .../kilocode/client/app/KiloSessionService.kt | 3 + .../ai/kilocode/client/diff/DiffBlocks.kt | 3 + .../client/diff/KiloDiffEditorKind.kt | 28 ++++++- .../ai/kilocode/client/diff/DiffBlocksTest.kt | 26 ++++++ .../client/testing/FakeSessionRpcApi.kt | 7 ++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 3 + .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 2 + .../routes/instance/httpapi/groups/session.ts | 3 +- .../instance/httpapi/handlers/session.ts | 9 +- packages/opencode/src/session/summary.ts | 43 +++++++++- packages/opencode/src/snapshot/index.ts | 82 ++++++++++++++++++ 14 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 82442564a8c..c29e6e6c62b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -33,6 +33,12 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request /** * Backend implementation of [KiloSessionRpcApi]. @@ -155,6 +161,44 @@ class KiloSessionRpcApiImpl internal constructor( } } + override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? = ready { + val api = app.api ?: throw IllegalStateException("Kilo API is unavailable") + withContext(Dispatchers.IO) { + val url = (api.baseUrl.trimEnd('/') + "/").toHttpUrlOrNull() + ?.newBuilder() + ?.addPathSegment("session") + ?.addPathSegment(id) + ?.addPathSegment("diff") + ?.addQueryParameter("directory", directory) + ?.addQueryParameter("full", "true") + ?.addQueryParameter("file", file) + ?.apply { if (!messageId.isNullOrBlank()) addQueryParameter("messageID", messageId) } + ?.build() + ?: throw IllegalStateException("Kilo API URL is invalid") + api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> + if (!response.isSuccessful) throw IllegalStateException("Kilo API diff detail failed: ${response.code}") + val body = response.body?.string().orEmpty() + // A CLI without full/file support ignores those query params and returns the whole + // diff array, so match the requested path instead of taking the first entry — + // otherwise every file would resolve to the same first diff. + Json.parseToJsonElement(body).jsonArray + .mapNotNull { it.jsonObject.takeIf { obj -> obj["file"]?.jsonPrimitive?.content == file } } + .firstOrNull() + ?.let { item -> + DiffFileDto( + file, + item["additions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, + item["deletions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, + item["patch"]?.jsonPrimitive?.content, + item["status"]?.jsonPrimitive?.content, + item["before"]?.jsonPrimitive?.content, + item["after"]?.jsonPrimitive?.content, + ) + } + } + } + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt index c5f1f98158a..eb239a1f6f5 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt @@ -1,19 +1,41 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.AfterTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertTrue class KiloSessionRpcApiImplTest { + private val apps = mutableListOf() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() = runBlocking { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + } @Test fun `events logs normal completion`() = runBlocking(Dispatchers.Default) { @@ -49,4 +71,65 @@ class KiloSessionRpcApiImplTest { assertTrue(log.messages.any { it.contains("route=rpc-events stop=true failed message=stream failed") }, log.messages.joinToString("\n")) } + + @Test + fun `diffFile loads full detail with message scope`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + try { + mock.sessionDiff = """ + [{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"@@ -1 +1 @@\n-old\n+new\n","before":"old\nkeep\n","after":"new\nkeep\n"}] + """.trimIndent() + val api = KiloSessionRpcApiImpl(app(mock)) + + val diff = api.diffFile("ses_test", "/work", "src/Main.kt", "msg1") + + assertNotNull(diff) + assertEquals("old\nkeep\n", diff.before) + assertEquals("new\nkeep\n", diff.after) + val path = assertNotNull(mock.lastSessionDiffPath) + assertTrue(path.contains("full=true"), path) + assertTrue(path.contains("file=src%2FMain.kt"), path) + assertTrue(path.contains("messageID=msg1"), path) + } finally { + mock.close() + } + } + + @Test + fun `diffFile matches the requested file when server returns the whole diff`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + try { + // A CLI without full/file support ignores the params and returns every changed file; + // diffFile must select the requested path, not the first entry. + mock.sessionDiff = """ + [{"file":"src/A.kt","additions":1,"deletions":0,"status":"modified","patch":"a"}, + {"file":"src/B.kt","additions":2,"deletions":0,"status":"modified","patch":"b"}] + """.trimIndent() + val api = KiloSessionRpcApiImpl(app(mock)) + + val diff = api.diffFile("ses_test", "/work", "src/B.kt", null) + + assertNotNull(diff) + assertEquals("src/B.kt", diff.file) + assertEquals(2, diff.additions) + } finally { + mock.close() + } + } + + private suspend fun app(mock: MockCliServer): KiloBackendAppService { + val log = TestLog() + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } + app.connect() + val state = assertNotNull( + withTimeoutOrNull(35_000) { + app.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${app.appState.value}; logs=${log.messages}", + ) + assertIs(state, "App startup failed; logs=${log.messages}") + return app + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index d016709a685..fae9829a5e9 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -101,6 +101,7 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" + @Volatile var sessionDiff = "[]" @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @@ -140,6 +141,7 @@ class MockCliServer : AutoCloseable { @Volatile var lastSessionRenamePath: String? = null @Volatile var lastSessionRenameBody: String? = null @Volatile var lastSessionRenameMethod: String? = null + @Volatile var lastSessionDiffPath: String? = null @Volatile var pendingPermissions = "[]" @Volatile var pendingQuestions = "[]" @@ -431,6 +433,10 @@ class MockCliServer : AutoCloseable { lastSessionRenameMethod = method respond(output, sessionRenameStatus, sessionRenameResponse) } + bare.matches(Regex("/session/ses_[^/]+/diff")) && method == "GET" -> { + lastSessionDiffPath = path + respond(output, 200, sessionDiff) + } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 5a36f3f1905..dd39aaa22f1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -219,6 +219,9 @@ class KiloSessionService internal constructor( suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } + suspend fun diffFile(id: String, dir: String, file: String, messageId: String?): DiffFileDto? = + call { diffFile(id, dir, file, messageId) } + suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index 538a1686ace..c7a3c921e23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -22,13 +22,16 @@ internal fun diffRequest( val status = fileStatus(dto) val patch = dto.patch?.takeIf { it.isNotBlank() } val fallback = patch ?: KiloBundle.message("diff.editor.patch.unavailable") + val full = dto.before != null && dto.after != null val left = when { + full -> factory.create(project, dto.before.orEmpty(), type) DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty() sides.renderable -> factory.create(project, sides.before, type) status == FileStatus.DELETED -> factory.create(project, fallback, type) else -> factory.createEmpty() } val right = when { + full -> factory.create(project, dto.after.orEmpty(), type) DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty() sides.renderable -> factory.create(project, sides.after, type) status == FileStatus.DELETED -> factory.createEmpty() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index f8d61745f8e..6973eb01b9f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -150,17 +150,41 @@ internal class KiloDiffEditorService( val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty val workspace = service() val store = project.service() + val session = project.service() val files = when (params["source"]) { // branch is authoritative here (no store seeding): recompute on every load/refresh so a // re-open or Refresh always reflects the current worktree instead of a stale click seed. "branch" -> workspace.branchDiff(dir) "inline" -> store.get(params["token"].orEmpty()).orEmpty() - else -> project.service().diff(params["sessionId"].orEmpty(), dir) + else -> session.diff(params["sessionId"].orEmpty(), dir) } if (files.isEmpty()) return DiffEditorData.Empty val branch = params["branch"].takeIfPresent() ?: if (params["source"] == "branch") workspace.branchName(dir) else null - return DiffEditorData.Files(files, branch) + return DiffEditorData.Files(detail(params, dir, files, session), branch) + } + + private suspend fun detail( + params: Map, + dir: String, + files: List, + session: KiloSessionService, + ): List { + if (params["source"] == "branch") return files + val id = params["sessionId"].takeIfPresent() ?: return files + val message = message(params) + return files.map { file -> + runCatching { session.diffFile(id, dir, file.file, message) } + .getOrNull() + ?: file + } + } + + private fun message(params: Map): String? { + val token = params["token"].takeIfPresent() ?: return null + val parts = token.split(":", limit = 3) + if (parts.size != 3 || parts[0] != "turn") return null + return parts[2].takeIfPresent() } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt new file mode 100644 index 00000000000..01307f892b1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.diff.contents.DocumentContent +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class DiffBlocksTest : BasePlatformTestCase() { + fun `test diffRequest uses full sides when available`() { + val request = diffRequest( + project, + DiffFileDto( + file = "src/Main.kt", + additions = 1, + deletions = 1, + patch = "@@ -1 +1 @@\n-old\n+new\n", + status = "modified", + before = "old\nkeep\n", + after = "new\nkeep\n", + ), + ) as SimpleDiffRequest + + assertEquals("old\nkeep\n", (request.contents[0] as DocumentContent).document.text) + assertEquals("new\nkeep\n", (request.contents[1] as DocumentContent).document.text) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index ce540a25d48..d6f1a0ee651 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -49,6 +49,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val history = mutableListOf() val histories = mutableMapOf>() val diffs = mutableMapOf>() + val diffFiles = mutableMapOf() var historyGate: CompletableDeferred? = null var historyCalls = 0 private set @@ -259,6 +260,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return diffs[id]?.toList().orEmpty() } + override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? { + assertNotEdt("diffFile") + return diffFiles["$id\u0000$directory\u0000$file\u0000${messageId.orEmpty()}"] + ?: diffs[id]?.firstOrNull { it.file == file } + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { assertNotEdt("attachmentPart") attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey)) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 0eaa3c5c01c..dccff6811cd 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -103,6 +103,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Load cumulative file changes for a session. */ suspend fun diff(id: String, directory: String): List + /** Load one full-content diff entry for a session or turn editor tab. */ + suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? + /** Load one attachment part from a session without returning full history to the frontend. */ suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 4d31c3a5517..ecf0da6a410 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -410,6 +410,8 @@ data class DiffFileDto( val deletions: Int, val patch: String? = null, val status: String? = null, + val before: String? = null, + val after: String? = null, ) // --- Config Update --- diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 02837849be9..afbb61bcd0a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -38,7 +38,8 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID", "full"]), // kilocode_change + full: Schema.optional(QueryBoolean), // kilocode_change }) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index eba9b764589..805df07f275 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -105,7 +105,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) + // kilocode_change start - pass full-file detail query fields through to summary service + return yield* summary.diff({ + sessionID: ctx.params.sessionID, + messageID: ctx.query.messageID, + full: ctx.query.full, + file: ctx.query.file, + }) + // kilocode_change end }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d540de86173..2dc3ea21064 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -67,7 +67,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly diff: (input: DiffInput) => Effect.Effect // kilocode_change readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect } @@ -82,10 +82,11 @@ export const layer = Layer.effect( const config = yield* Config.Service const storage = yield* Storage.Service // kilocode_change - const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { + // kilocode_change start - share snapshot ref extraction with lazy diff detail + const refs = (messages: SessionV1.WithParts[]) => { let from: string | undefined let to: string | undefined - for (const item of input.messages) { + for (const item of messages) { if (!from) { for (const part of item.parts) { if (part.type === "step-start" && part.snapshot) { @@ -98,9 +99,29 @@ export const layer = Layer.effect( if (part.type === "step-finish" && part.snapshot) to = part.snapshot } } + return { from, to } + } + // kilocode_change end + + // kilocode_change start - reuse snapshot refs for lazy diff detail + const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { + const { from, to } = refs(input.messages) // kilocode_change if (from && to) return yield* snapshot.diffFull(from, to) return [] }) + // kilocode_change end + + // kilocode_change start - lazy full-content detail for editor diff tabs + const computeFile = Effect.fn("SessionSummary.computeFile")(function* (input: { + messages: SessionV1.WithParts[] + file: string + }) { + const { from, to } = refs(input.messages) + if (!from || !to) return [] + const diff = yield* snapshot.diffFile(from, to, input.file) + return diff ? [diff] : [] + }) + // kilocode_change end const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { sessionID: SessionID @@ -144,7 +165,19 @@ export const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) - const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const diff = Effect.fn("SessionSummary.diff")(function* (input: DiffInput) { // kilocode_change + // kilocode_change start - compute on-demand full-file detail from turn/session snapshots + if (input.full && input.file) { + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const messages = input.messageID + ? all.filter( + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), + ) + : all + return yield* computeFile({ messages, file: input.file }) + } + // kilocode_change end + // kilocode_change start - retain cumulative diffs for legacy TUI and VS Code consumers if (!input.messageID) { const diffs = yield* storage @@ -192,6 +225,8 @@ export const defaultLayer = Layer.suspend(() => export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), + full: Schema.optional(Schema.Boolean), // kilocode_change + file: Schema.optional(Schema.String), // kilocode_change }) export type DiffInput = Schema.Schema.Type diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a1d74809783..38a10054c3a 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,6 +34,8 @@ export const FileDiff = Schema.Struct({ // session response and broke session loading on Desktop. file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), + before: Schema.optional(Schema.String), // kilocode_change + after: Schema.optional(Schema.String), // kilocode_change additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), @@ -64,6 +66,7 @@ interface GitResult { } export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change +export const MAX_DIFF_DETAIL_SIZE = 20 * 1024 * 1024 // kilocode_change type State = Omit @@ -82,6 +85,7 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect + readonly diffFile: (from: string, to: string, file: string) => Effect.Effect // kilocode_change } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -883,6 +887,78 @@ export const layer: Layer.Layer = ) }) + // kilocode_change start - lazy full-content detail for editor diff tabs + const diffFile = Effect.fnUntraced(function* (from: string, to: string, file: string) { + return yield* locked( + Effect.gen(function* () { + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", file])], + { cwd: state.directory }, + ) + const row = statuses.text.trim().split("\n").find(Boolean) + if (!row) return + const [code] = row.split("\t") + const status = code?.startsWith("A") ? "added" : code?.startsWith("D") ? "deleted" : "modified" + + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", file])], + { cwd: state.directory }, + ) + const stat = numstat.text.trim().split("\n").find(Boolean) + const [adds, dels] = stat?.split("\t") ?? [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : Number.parseInt(adds ?? "0", 10) + const deletions = binary ? 0 : Number.parseInt(dels ?? "0", 10) + + const patch = binary + ? "" + : ((yield* DiffFull.batch( + (cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }), + from, + to, + [file], + )).get(file) ?? "") + + if (binary) { + return { + file, + patch, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status, + } + } + + const content = yield* Effect.all( + { + before: + status === "added" + ? Effect.succeed("") + : git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), + after: + status === "deleted" + ? Effect.succeed("") + : git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), + }, + { concurrency: 2 }, + ) + const before = Buffer.byteLength(content.before) <= MAX_DIFF_DETAIL_SIZE ? content.before : undefined + const after = Buffer.byteLength(content.after) <= MAX_DIFF_DETAIL_SIZE ? content.after : undefined + + return { + file, + patch, + before, + after, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status, + } + }), + ) + }) + // kilocode_change end + yield* materialize() // kilocode_change - resume interrupted snapshot object materialization yield* cleanup().pipe( @@ -968,6 +1044,12 @@ export const layer: Layer.Layer = return yield* Effect.promise(() => pending) // kilocode_change end }), + // kilocode_change start - lazy full-content detail for editor diff tabs + diffFile: Effect.fn("Snapshot.diffFile")(function* (from: string, to: string, file: string) { + if (from === to) return + return yield* InstanceState.useEffect(state, (s) => s.diffFile(from, to, file)) + }), + // kilocode_change end }) }), ) From 8650fdd0c486fb2c0cd3cc05c931ee9c3f55dabc Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 13:15:08 -0400 Subject: [PATCH 16/78] refactor(jetbrains): reconstruct full-file diffs locally Roll back the CLI diff full/file server changes and rebuild whole-file diffs entirely in the JetBrains plugin: the backend reads the working-tree file and reverse-applies the existing hunk patch to recover the full before side, falling back to the hunk view when the tree has drifted. Works against any pinned CLI with no server change. --- .../backend/diff/DiffFullReconstruct.kt | 80 ++++++++++++++ .../backend/rpc/KiloSessionRpcApiImpl.kt | 53 ++------- .../backend/diff/DiffFullReconstructTest.kt | 55 ++++++++++ .../backend/rpc/KiloSessionRpcApiImplTest.kt | 103 +++++++----------- .../kilocode/backend/testing/MockCliServer.kt | 6 - .../kilocode/client/app/KiloSessionService.kt | 4 +- .../client/diff/KiloDiffEditorKind.kt | 26 ++--- .../client/testing/FakeSessionRpcApi.kt | 9 +- .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 8 +- .../routes/instance/httpapi/groups/session.ts | 3 +- .../instance/httpapi/handlers/session.ts | 9 +- packages/opencode/src/session/summary.ts | 43 +------- packages/opencode/src/snapshot/index.ts | 82 -------------- 13 files changed, 213 insertions(+), 268 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt new file mode 100644 index 00000000000..cd7e51379bc --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt @@ -0,0 +1,80 @@ +package ai.kilocode.backend.diff + +/** + * Rebuilds the full "before" content of a modified file by reverse-applying a unified diff hunk + * patch to the current working-tree content. This lets the JetBrains diff editor show a whole-file + * diff (with collapsible unchanged regions) from the limited-context patch the CLI already returns — + * no CLI change required. + * + * Added and deleted files are intentionally rejected: their patches already carry every line, so the + * frontend reconstructs those full sides directly. Binary patches and any drift between the patch's + * after side and the real file (a stale/historical turn) also return null so the caller can fall back + * to the hunk-only view instead of rendering a wrong diff. + */ +internal object DiffFullReconstruct { + private val HUNK = Regex("^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@") + + fun before(after: String, patch: String?): String? { + if (patch.isNullOrBlank() || binary(patch) || added(patch) || deleted(patch)) return null + + val lines = if (after.isEmpty()) emptyList() else after.split("\n") + val out = ArrayList(lines.size) + var cursor = 0 // next after-line index still to emit (0-based) + var open = false + var start = 0 // 0-based after index where the current hunk begins + val afterBody = ArrayList() + val beforeBody = ArrayList() + + fun flush(): Boolean { + if (!open) return true + if (start < cursor) return false // overlapping or out-of-order hunks + while (cursor < start) { + if (cursor >= lines.size) return false + out.add(lines[cursor]); cursor++ + } + for (i in afterBody.indices) { + val idx = start + i + if (idx >= lines.size || lines[idx] != afterBody[i]) return false // working tree drifted + } + out.addAll(beforeBody) + cursor = start + afterBody.size + afterBody.clear(); beforeBody.clear() + open = false + return true + } + + // git patches are newline-terminated; drop the trailing split artifact so it is not read as a + // blank context line. Real blank context lines are " " (space-prefixed), never "". + for (raw in patch.split("\n").dropLastWhile { it.isEmpty() }) { + if (raw.startsWith("@@")) { + if (!flush()) return null + val match = HUNK.find(raw) ?: return null + val newStart = match.groupValues[1].toIntOrNull() ?: return null + val newLen = match.groupValues[2].ifEmpty { "1" }.toInt() + // For a zero-length new range git reports the line preceding the removed block, so the + // removed lines are reinserted at `newStart`; otherwise the region starts at newStart-1. + start = if (newLen == 0) newStart else newStart - 1 + open = true + continue + } + if (!open) continue // skip file headers (diff/index/---/+++) + if (raw.startsWith("\\")) continue // "\ No newline at end of file" + when (raw.firstOrNull()) { + ' ' -> { val body = raw.substring(1); afterBody.add(body); beforeBody.add(body) } + '+' -> afterBody.add(raw.substring(1)) + '-' -> beforeBody.add(raw.substring(1)) + null -> { afterBody.add(""); beforeBody.add("") } + else -> return null + } + } + if (!flush()) return null + while (cursor < lines.size) { out.add(lines[cursor]); cursor++ } + return out.joinToString("\n") + } + + fun added(patch: String): Boolean = patch.lineSequence().any { it == "--- /dev/null" } + + fun deleted(patch: String): Boolean = patch.lineSequence().any { it == "+++ /dev/null" } + + private fun binary(patch: String): Boolean = patch.lineSequence().any { it.startsWith("Binary files ") } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index c29e6e6c62b..97dad25d056 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -33,12 +33,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.Request +import ai.kilocode.backend.diff.DiffFullReconstruct +import java.nio.file.Files +import java.nio.file.Path /** * Backend implementation of [KiloSessionRpcApi]. @@ -161,41 +158,15 @@ class KiloSessionRpcApiImpl internal constructor( } } - override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? = ready { - val api = app.api ?: throw IllegalStateException("Kilo API is unavailable") - withContext(Dispatchers.IO) { - val url = (api.baseUrl.trimEnd('/') + "/").toHttpUrlOrNull() - ?.newBuilder() - ?.addPathSegment("session") - ?.addPathSegment(id) - ?.addPathSegment("diff") - ?.addQueryParameter("directory", directory) - ?.addQueryParameter("full", "true") - ?.addQueryParameter("file", file) - ?.apply { if (!messageId.isNullOrBlank()) addQueryParameter("messageID", messageId) } - ?.build() - ?: throw IllegalStateException("Kilo API URL is invalid") - api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> - if (!response.isSuccessful) throw IllegalStateException("Kilo API diff detail failed: ${response.code}") - val body = response.body?.string().orEmpty() - // A CLI without full/file support ignores those query params and returns the whole - // diff array, so match the requested path instead of taking the first entry — - // otherwise every file would resolve to the same first diff. - Json.parseToJsonElement(body).jsonArray - .mapNotNull { it.jsonObject.takeIf { obj -> obj["file"]?.jsonPrimitive?.content == file } } - .firstOrNull() - ?.let { item -> - DiffFileDto( - file, - item["additions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, - item["deletions"]?.jsonPrimitive?.content?.toDoubleOrNull()?.toInt() ?: 0, - item["patch"]?.jsonPrimitive?.content, - item["status"]?.jsonPrimitive?.content, - item["before"]?.jsonPrimitive?.content, - item["after"]?.jsonPrimitive?.content, - ) - } - } + override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + val patch = file.patch + if (patch.isNullOrBlank()) return null + // Full-file diffs are rebuilt locally: read the working-tree file and reverse-apply the hunk + // patch to recover the whole "before". No CLI round-trip, so this works against any pinned CLI. + return withContext(Dispatchers.IO) { + val after = runCatching { Files.readString(Path.of(directory).resolve(file.file)) }.getOrNull() + val before = after?.let { DiffFullReconstruct.before(it, patch) } + if (after != null && before != null) file.copy(before = before, after = after) else null } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt new file mode 100644 index 00000000000..993ec8928e4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt @@ -0,0 +1,55 @@ +package ai.kilocode.backend.diff + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DiffFullReconstructTest { + + @Test + fun `reconstructs before across multiple hunks keeping unchanged regions`() { + // Ten-line file; two separated single-line edits. The patch only carries 3-context hunks, so + // the unchanged gap between them must come from the working-tree content. + val after = (1..10).joinToString("\n") { if (it == 2) "TWO" else if (it == 9) "NINE" else "l$it" } + "\n" + val patch = buildString { + append("--- a/f\n+++ b/f\n") + append("@@ -1,4 +1,4 @@\n l1\n-l2\n+TWO\n l3\n l4\n") + append("@@ -7,4 +7,4 @@\n l7\n l8\n-l9\n+NINE\n l10\n") + } + + val before = DiffFullReconstruct.before(after, patch) + + assertEquals((1..10).joinToString("\n") { "l$it" } + "\n", before) + } + + @Test + fun `reconstructs before for a deletion-only hunk`() { + val after = "a\nc\n" + val patch = "--- a/f\n+++ b/f\n@@ -1,3 +1,2 @@\n a\n-b\n c\n" + + assertEquals("a\nb\nc\n", DiffFullReconstruct.before(after, patch)) + } + + @Test + fun `preserves files without a trailing newline`() { + val after = "a\nB" + val patch = "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n\\ No newline at end of file\n" + + assertEquals("a\nb", DiffFullReconstruct.before(after, patch)) + } + + @Test + fun `returns null when context does not match the working tree`() { + val patch = "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n" + + assertNull(DiffFullReconstruct.before("x\nB\n", patch)) + } + + @Test + fun `returns null for added deleted binary and blank patches`() { + assertNull(DiffFullReconstruct.before("hello\n", "--- /dev/null\n+++ b/f\n@@ -0,0 +1 @@\n+hello\n")) + assertNull(DiffFullReconstruct.before("", "--- a/f\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone\n")) + assertNull(DiffFullReconstruct.before("x", "Binary files a/f and b/f differ\n")) + assertNull(DiffFullReconstruct.before("x", "")) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt index eb239a1f6f5..2c6413d750a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt @@ -1,41 +1,25 @@ package ai.kilocode.backend.rpc -import ai.kilocode.backend.app.KiloAppState -import ai.kilocode.backend.app.KiloBackendAppService -import ai.kilocode.backend.testing.FakeCliServer -import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto -import kotlinx.coroutines.CoroutineScope +import ai.kilocode.rpc.dto.DiffFileDto import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.test.AfterTest +import java.nio.file.Files +import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class KiloSessionRpcApiImplTest { - private val apps = mutableListOf() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - @AfterTest - fun tearDown() = runBlocking { - apps.forEach { it.dispose() } - apps.clear() - scope.cancel() - } @Test fun `events logs normal completion`() = runBlocking(Dispatchers.Default) { @@ -73,63 +57,56 @@ class KiloSessionRpcApiImplTest { } @Test - fun `diffFile loads full detail with message scope`() = runBlocking(Dispatchers.Default) { - val mock = MockCliServer() + fun `diffSides rebuilds full before by reverse-applying the patch to the working tree`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") try { - mock.sessionDiff = """ - [{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"@@ -1 +1 @@\n-old\n+new\n","before":"old\nkeep\n","after":"new\nkeep\n"}] - """.trimIndent() - val api = KiloSessionRpcApiImpl(app(mock)) + val file = "src/Main.kt" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve(file), "a\nB2\nc\n") + val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - val diff = api.diffFile("ses_test", "/work", "src/Main.kt", "msg1") + val diff = KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified")) assertNotNull(diff) - assertEquals("old\nkeep\n", diff.before) - assertEquals("new\nkeep\n", diff.after) - val path = assertNotNull(mock.lastSessionDiffPath) - assertTrue(path.contains("full=true"), path) - assertTrue(path.contains("file=src%2FMain.kt"), path) - assertTrue(path.contains("messageID=msg1"), path) + assertEquals("a\nb2\nc\n", diff.before) + assertEquals("a\nB2\nc\n", diff.after) } finally { - mock.close() + delete(dir) } } @Test - fun `diffFile matches the requested file when server returns the whole diff`() = runBlocking(Dispatchers.Default) { - val mock = MockCliServer() + fun `diffSides returns null when the working tree drifted from the patch`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") try { - // A CLI without full/file support ignores the params and returns every changed file; - // diffFile must select the requested path, not the first entry. - mock.sessionDiff = """ - [{"file":"src/A.kt","additions":1,"deletions":0,"status":"modified","patch":"a"}, - {"file":"src/B.kt","additions":2,"deletions":0,"status":"modified","patch":"b"}] - """.trimIndent() - val api = KiloSessionRpcApiImpl(app(mock)) + val file = "src/Main.kt" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve(file), "a\nUNRELATED\nc\n") + val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - val diff = api.diffFile("ses_test", "/work", "src/B.kt", null) - - assertNotNull(diff) - assertEquals("src/B.kt", diff.file) - assertEquals(2, diff.additions) + assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"))) } finally { - mock.close() + delete(dir) } } - private suspend fun app(mock: MockCliServer): KiloBackendAppService { - val log = TestLog() - val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } - app.connect() - val state = assertNotNull( - withTimeoutOrNull(35_000) { - app.appState.first { - it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired - } - }, - "App startup timed out in ${app.appState.value}; logs=${log.messages}", - ) - assertIs(state, "App startup failed; logs=${log.messages}") - return app + @Test + fun `diffSides returns null for added files and missing patches`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") + try { + Files.writeString(dir.resolve("new.kt"), "hello\n") + val added = "--- /dev/null\n+++ b/new.kt\n@@ -0,0 +1 @@\n+hello\n" + + assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, added, "added"))) + assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, null, "added"))) + } finally { + delete(dir) + } + } + + private fun delete(dir: java.nio.file.Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index fae9829a5e9..d016709a685 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -101,7 +101,6 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" - @Volatile var sessionDiff = "[]" @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @@ -141,7 +140,6 @@ class MockCliServer : AutoCloseable { @Volatile var lastSessionRenamePath: String? = null @Volatile var lastSessionRenameBody: String? = null @Volatile var lastSessionRenameMethod: String? = null - @Volatile var lastSessionDiffPath: String? = null @Volatile var pendingPermissions = "[]" @Volatile var pendingQuestions = "[]" @@ -433,10 +431,6 @@ class MockCliServer : AutoCloseable { lastSessionRenameMethod = method respond(output, sessionRenameStatus, sessionRenameResponse) } - bare.matches(Regex("/session/ses_[^/]+/diff")) && method == "GET" -> { - lastSessionDiffPath = path - respond(output, 200, sessionDiff) - } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index dd39aaa22f1..f2925161a4b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -219,8 +219,8 @@ class KiloSessionService internal constructor( suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } - suspend fun diffFile(id: String, dir: String, file: String, messageId: String?): DiffFileDto? = - call { diffFile(id, dir, file, messageId) } + suspend fun diffSides(dir: String, file: DiffFileDto): DiffFileDto? = + call { diffSides(dir, file) } suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index 6973eb01b9f..0c4fde54f11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -161,30 +161,20 @@ internal class KiloDiffEditorService( if (files.isEmpty()) return DiffEditorData.Empty val branch = params["branch"].takeIfPresent() ?: if (params["source"] == "branch") workspace.branchName(dir) else null - return DiffEditorData.Files(detail(params, dir, files, session), branch) + return DiffEditorData.Files(detail(dir, files, session), branch) } + // Enrich modified files with full before/after content so the editor shows whole-file diffs. + // Added/deleted/binary files already render fully from their patch, so they skip the round-trip; + // a null result (working tree drifted from the patch) falls back to the hunk view. private suspend fun detail( - params: Map, dir: String, files: List, session: KiloSessionService, - ): List { - if (params["source"] == "branch") return files - val id = params["sessionId"].takeIfPresent() ?: return files - val message = message(params) - return files.map { file -> - runCatching { session.diffFile(id, dir, file.file, message) } - .getOrNull() - ?: file - } - } - - private fun message(params: Map): String? { - val token = params["token"].takeIfPresent() ?: return null - val parts = token.split(":", limit = 3) - if (parts.size != 3 || parts[0] != "turn") return null - return parts[2].takeIfPresent() + ): List = files.map { file -> + val patch = file.patch + if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file + else runCatching { session.diffSides(dir, file) }.getOrNull() ?: file } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index d6f1a0ee651..1087cf75313 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -49,7 +49,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val history = mutableListOf() val histories = mutableMapOf>() val diffs = mutableMapOf>() - val diffFiles = mutableMapOf() + val diffSides = mutableMapOf() var historyGate: CompletableDeferred? = null var historyCalls = 0 private set @@ -260,10 +260,9 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return diffs[id]?.toList().orEmpty() } - override suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? { - assertNotEdt("diffFile") - return diffFiles["$id\u0000$directory\u0000$file\u0000${messageId.orEmpty()}"] - ?: diffs[id]?.firstOrNull { it.file == file } + override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + assertNotEdt("diffSides") + return diffSides[file.file] } override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index dccff6811cd..e9752ae0be9 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -103,8 +103,12 @@ interface KiloSessionRpcApi : RemoteApi { /** Load cumulative file changes for a session. */ suspend fun diff(id: String, directory: String): List - /** Load one full-content diff entry for a session or turn editor tab. */ - suspend fun diffFile(id: String, directory: String, file: String, messageId: String?): DiffFileDto? + /** + * Rebuild full before/after content for one changed file so the diff editor can show a whole-file + * diff. Returns null when the file's working-tree content no longer matches the patch (fall back to + * the hunk view). Added/deleted files return null because the frontend reconstructs those directly. + */ + suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? /** Load one attachment part from a session without returning full history to the frontend. */ suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index afbb61bcd0a..02837849be9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -38,8 +38,7 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID", "full"]), // kilocode_change - full: Schema.optional(QueryBoolean), // kilocode_change + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), }) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 805df07f275..eba9b764589 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -105,14 +105,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - // kilocode_change start - pass full-file detail query fields through to summary service - return yield* summary.diff({ - sessionID: ctx.params.sessionID, - messageID: ctx.query.messageID, - full: ctx.query.full, - file: ctx.query.file, - }) - // kilocode_change end + return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 2dc3ea21064..d540de86173 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -67,7 +67,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly diff: (input: DiffInput) => Effect.Effect // kilocode_change + readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect } @@ -82,11 +82,10 @@ export const layer = Layer.effect( const config = yield* Config.Service const storage = yield* Storage.Service // kilocode_change - // kilocode_change start - share snapshot ref extraction with lazy diff detail - const refs = (messages: SessionV1.WithParts[]) => { + const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { let from: string | undefined let to: string | undefined - for (const item of messages) { + for (const item of input.messages) { if (!from) { for (const part of item.parts) { if (part.type === "step-start" && part.snapshot) { @@ -99,29 +98,9 @@ export const layer = Layer.effect( if (part.type === "step-finish" && part.snapshot) to = part.snapshot } } - return { from, to } - } - // kilocode_change end - - // kilocode_change start - reuse snapshot refs for lazy diff detail - const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) { - const { from, to } = refs(input.messages) // kilocode_change if (from && to) return yield* snapshot.diffFull(from, to) return [] }) - // kilocode_change end - - // kilocode_change start - lazy full-content detail for editor diff tabs - const computeFile = Effect.fn("SessionSummary.computeFile")(function* (input: { - messages: SessionV1.WithParts[] - file: string - }) { - const { from, to } = refs(input.messages) - if (!from || !to) return [] - const diff = yield* snapshot.diffFile(from, to, input.file) - return diff ? [diff] : [] - }) - // kilocode_change end const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { sessionID: SessionID @@ -165,19 +144,7 @@ export const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) - const diff = Effect.fn("SessionSummary.diff")(function* (input: DiffInput) { // kilocode_change - // kilocode_change start - compute on-demand full-file detail from turn/session snapshots - if (input.full && input.file) { - const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) - const messages = input.messageID - ? all.filter( - (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), - ) - : all - return yield* computeFile({ messages, file: input.file }) - } - // kilocode_change end - + const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { // kilocode_change start - retain cumulative diffs for legacy TUI and VS Code consumers if (!input.messageID) { const diffs = yield* storage @@ -225,8 +192,6 @@ export const defaultLayer = Layer.suspend(() => export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), - full: Schema.optional(Schema.Boolean), // kilocode_change - file: Schema.optional(Schema.String), // kilocode_change }) export type DiffInput = Schema.Schema.Type diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 38a10054c3a..a1d74809783 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,8 +34,6 @@ export const FileDiff = Schema.Struct({ // session response and broke session loading on Desktop. file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), - before: Schema.optional(Schema.String), // kilocode_change - after: Schema.optional(Schema.String), // kilocode_change additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), @@ -66,7 +64,6 @@ interface GitResult { } export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change -export const MAX_DIFF_DETAIL_SIZE = 20 * 1024 * 1024 // kilocode_change type State = Omit @@ -85,7 +82,6 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect - readonly diffFile: (from: string, to: string, file: string) => Effect.Effect // kilocode_change } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -887,78 +883,6 @@ export const layer: Layer.Layer = ) }) - // kilocode_change start - lazy full-content detail for editor diff tabs - const diffFile = Effect.fnUntraced(function* (from: string, to: string, file: string) { - return yield* locked( - Effect.gen(function* () { - const statuses = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", file])], - { cwd: state.directory }, - ) - const row = statuses.text.trim().split("\n").find(Boolean) - if (!row) return - const [code] = row.split("\t") - const status = code?.startsWith("A") ? "added" : code?.startsWith("D") ? "deleted" : "modified" - - const numstat = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", file])], - { cwd: state.directory }, - ) - const stat = numstat.text.trim().split("\n").find(Boolean) - const [adds, dels] = stat?.split("\t") ?? [] - const binary = adds === "-" && dels === "-" - const additions = binary ? 0 : Number.parseInt(adds ?? "0", 10) - const deletions = binary ? 0 : Number.parseInt(dels ?? "0", 10) - - const patch = binary - ? "" - : ((yield* DiffFull.batch( - (cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }), - from, - to, - [file], - )).get(file) ?? "") - - if (binary) { - return { - file, - patch, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - status, - } - } - - const content = yield* Effect.all( - { - before: - status === "added" - ? Effect.succeed("") - : git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), - after: - status === "deleted" - ? Effect.succeed("") - : git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), - }, - { concurrency: 2 }, - ) - const before = Buffer.byteLength(content.before) <= MAX_DIFF_DETAIL_SIZE ? content.before : undefined - const after = Buffer.byteLength(content.after) <= MAX_DIFF_DETAIL_SIZE ? content.after : undefined - - return { - file, - patch, - before, - after, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - status, - } - }), - ) - }) - // kilocode_change end - yield* materialize() // kilocode_change - resume interrupted snapshot object materialization yield* cleanup().pipe( @@ -1044,12 +968,6 @@ export const layer: Layer.Layer = return yield* Effect.promise(() => pending) // kilocode_change end }), - // kilocode_change start - lazy full-content detail for editor diff tabs - diffFile: Effect.fn("Snapshot.diffFile")(function* (from: string, to: string, file: string) { - if (from === to) return - return yield* InstanceState.useEffect(state, (s) => s.diffFile(from, to, file)) - }), - // kilocode_change end }) }), ) From 6948a170fe73616ade6a2ad21b4028199d935b59 Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 14:55:01 -0700 Subject: [PATCH 17/78] feat(charts-telemetry): Add telemetry for charting tool --- packages/opencode/src/kilocode/tool/chart.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/kilocode/tool/chart.ts b/packages/opencode/src/kilocode/tool/chart.ts index c928c46ea79..11253134f2e 100644 --- a/packages/opencode/src/kilocode/tool/chart.ts +++ b/packages/opencode/src/kilocode/tool/chart.ts @@ -1,6 +1,7 @@ // kilocode_change - new file import { Effect, Schema } from "effect" import * as Tool from "../../tool/tool" +import { Telemetry } from "@kilocode/kilo-telemetry" const Parameters = Schema.Struct({ title: Schema.String.annotate({ @@ -66,6 +67,8 @@ export const ChartTool = Tool.define( } } + Telemetry.trackToolUsed("chart", ctx.sessionID) + return { title: params.title, output: JSON.stringify(spec), From 1d103bfa56b4d52648166f206a0d7292577f3642 Mon Sep 17 00:00:00 2001 From: cmanu Date: Tue, 4 Aug 2026 15:05:46 -0700 Subject: [PATCH 18/78] feat(charts-telemetry): Add telemetry unit test for tracking tool --- .../kilo-telemetry/src/__tests__/telemetry.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index c86a249f51f..9246facb48c 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -126,4 +126,15 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackSuggestionShown).toBe("function") expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") }) + + test("trackToolUsed sends Tool Used event with tool name and sessionId", () => { + const capture = spyOn(Client, "capture").mockImplementation(() => {}) + + try { + Telemetry.trackToolUsed("chart", "session-123") + expect(capture).toHaveBeenCalledWith(TelemetryEvent.TOOL_USED, expect.objectContaining({ tool: "chart", sessionId: "session-123" })) + } finally { + capture.mockRestore() + } + }) }) From ebebad02e71c5771ccd791ae483b34db7b461d79 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 18:13:55 -0400 Subject: [PATCH 19/78] feat(jetbrains): authoritative full-file editor diffs with local fallback The diff editor tab now shows whole-file diffs (with collapsible unchanged regions) for modified files, while inline session cards stay hunk-bounded. diffSides resolves full before/after with a safe precedence: 1. Authoritative: query the CLI's snapshot diff (full=true&file=...), which reads the exact turn's before/after from snapshot commits via git show and is correct even for historical/reverted turns. 2. Fallback: reverse-apply the hunk patch onto the working-tree file locally, so it works against the current pinned CLI until the new one is released. 3. Hunk view: if neither yields full content (drift/binary/added/deleted), render the existing patch. The CLI change is minimal and additive: full-content logic lives in the kilo-owned diff-full.ts (DiffFull.detail); shared files only gain thin kilocode_change hooks (FileDiff before/after, Snapshot.diffFile, the summary full/file branch, and the diff query fields). --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 48 +++++++++- .../backend/rpc/KiloSessionRpcApiImplTest.kt | 90 ++++++++++++++++++- .../kilocode/backend/testing/MockCliServer.kt | 6 ++ .../kilocode/client/app/KiloSessionService.kt | 4 +- .../client/diff/KiloDiffEditorKind.kt | 22 +++-- .../client/testing/FakeSessionRpcApi.kt | 2 +- .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 10 ++- .../src/kilocode/snapshot/diff-full.ts | 58 ++++++++++++ .../routes/instance/httpapi/groups/session.ts | 3 +- .../instance/httpapi/handlers/session.ts | 9 +- packages/opencode/src/session/summary.ts | 25 +++++- packages/opencode/src/snapshot/index.ts | 27 +++++- .../opencode/test/kilocode/diff-full.test.ts | 64 +++++++++++++ .../opencode/test/session/compaction.test.ts | 1 + 14 files changed, 345 insertions(+), 24 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 97dad25d056..28c8117b2a6 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -33,6 +33,13 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request import ai.kilocode.backend.diff.DiffFullReconstruct import java.nio.file.Files import java.nio.file.Path @@ -158,11 +165,15 @@ class KiloSessionRpcApiImpl internal constructor( } } - override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { val patch = file.patch if (patch.isNullOrBlank()) return null - // Full-file diffs are rebuilt locally: read the working-tree file and reverse-apply the hunk - // patch to recover the whole "before". No CLI round-trip, so this works against any pinned CLI. + // 1) Authoritative: a CLI with full/file support returns whole before/after from the snapshot, + // correct even for historical turns. Older CLIs ignore the params, so we detect the missing + // content and fall through to local reconstruction. + if (!sessionId.isNullOrBlank()) authoritative(sessionId, directory, file, messageId)?.let { return it } + // 2) Fallback: read the working-tree file and reverse-apply the hunk patch to recover the whole + // "before". No CLI round-trip, so this works against any pinned CLI. return withContext(Dispatchers.IO) { val after = runCatching { Files.readString(Path.of(directory).resolve(file.file)) }.getOrNull() val before = after?.let { DiffFullReconstruct.before(it, patch) } @@ -170,6 +181,37 @@ class KiloSessionRpcApiImpl internal constructor( } } + // Ask the CLI for full before/after via GET /session/:id/diff?full=true&file=...; returns null when + // the pinned CLI lacks full/file support (it omits before/after) so the caller falls back locally. + private suspend fun authoritative(sessionId: String, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { + val api = app.api ?: return null + return withContext(Dispatchers.IO) { + runCatching { + val url = (api.baseUrl.trimEnd('/') + "/").toHttpUrlOrNull() + ?.newBuilder() + ?.addPathSegment("session") + ?.addPathSegment(sessionId) + ?.addPathSegment("diff") + ?.addQueryParameter("directory", directory) + ?.addQueryParameter("full", "true") + ?.addQueryParameter("file", file.file) + ?.apply { if (!messageId.isNullOrBlank()) addQueryParameter("messageID", messageId) } + ?.build() + ?: return@runCatching null + api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> + if (!response.isSuccessful) return@runCatching null + val item = Json.parseToJsonElement(response.body?.string().orEmpty()).jsonArray + .firstOrNull { it.jsonObject["file"]?.jsonPrimitive?.contentOrNull == file.file } + ?.jsonObject + ?: return@runCatching null + val before = item["before"]?.jsonPrimitive?.contentOrNull + val after = item["after"]?.jsonPrimitive?.contentOrNull + if (before != null && after != null) file.copy(before = before, after = after) else null + } + }.getOrNull() + } + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt index 2c6413d750a..a883eebc5b7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt @@ -1,25 +1,46 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import java.nio.file.Files import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue class KiloSessionRpcApiImplTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val apps = mutableListOf() + + @AfterTest + fun tearDown() = runBlocking { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + } + @Test fun `events logs normal completion`() = runBlocking(Dispatchers.Default) { @@ -65,7 +86,7 @@ class KiloSessionRpcApiImplTest { Files.writeString(dir.resolve(file), "a\nB2\nc\n") val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - val diff = KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified")) + val diff = KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"), null) assertNotNull(diff) assertEquals("a\nb2\nc\n", diff.before) @@ -84,7 +105,7 @@ class KiloSessionRpcApiImplTest { Files.writeString(dir.resolve(file), "a\nUNRELATED\nc\n") val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" - assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"))) + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"), null)) } finally { delete(dir) } @@ -97,13 +118,74 @@ class KiloSessionRpcApiImplTest { Files.writeString(dir.resolve("new.kt"), "hello\n") val added = "--- /dev/null\n+++ b/new.kt\n@@ -0,0 +1 @@\n+hello\n" - assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, added, "added"))) - assertNull(KiloSessionRpcApiImpl().diffSides(dir.toString(), DiffFileDto("new.kt", 1, 0, null, "added"))) + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto("new.kt", 1, 0, added, "added"), null)) + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto("new.kt", 1, 0, null, "added"), null)) } finally { delete(dir) } } + @Test + fun `diffSides prefers authoritative CLI content over local reconstruction`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + try { + mock.sessionDiff = + """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p","before":"OLD\n","after":"NEW\n"}]""" + val api = KiloSessionRpcApiImpl(app(mock)) + + // No working-tree file exists here, so a non-null result can only come from the CLI path. + val diff = api.diffSides("ses_test", "/does-not-exist", DiffFileDto("src/Main.kt", 1, 1, "p", "modified"), "msg1") + + assertNotNull(diff) + assertEquals("OLD\n", diff.before) + assertEquals("NEW\n", diff.after) + val path = assertNotNull(mock.lastSessionDiffPath) + assertTrue(path.contains("full=true"), path) + assertTrue(path.contains("file=src%2FMain.kt"), path) + assertTrue(path.contains("messageID=msg1"), path) + } finally { + mock.close() + } + } + + @Test + fun `diffSides falls back to local reconstruction when the CLI omits full content`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + val dir = createTempDirectory("kilo-diff") + try { + // A CLI without full/file support returns the file entry but no before/after. + mock.sessionDiff = """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p"}]""" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve("src/Main.kt"), "a\nB2\nc\n") + val patch = "--- a/src/Main.kt\n+++ b/src/Main.kt\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" + val api = KiloSessionRpcApiImpl(app(mock)) + + val diff = api.diffSides("ses_test", dir.toString(), DiffFileDto("src/Main.kt", 1, 1, patch, "modified"), "msg1") + + assertNotNull(diff) + assertEquals("a\nb2\nc\n", diff.before) + assertEquals("a\nB2\nc\n", diff.after) + } finally { + delete(dir) + mock.close() + } + } + + private suspend fun app(mock: MockCliServer): KiloBackendAppService { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog()).also { apps.add(it) } + app.connect() + val state = assertNotNull( + withTimeoutOrNull(35_000) { + app.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${app.appState.value}", + ) + assertIs(state, "App startup failed") + return app + } + private fun delete(dir: java.nio.file.Path) { Files.walk(dir).use { paths -> paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index d016709a685..910df5eefa4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -101,6 +101,8 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" + @Volatile var sessionDiff = "[]" + @Volatile var lastSessionDiffPath: String? = null @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @@ -431,6 +433,10 @@ class MockCliServer : AutoCloseable { lastSessionRenameMethod = method respond(output, sessionRenameStatus, sessionRenameResponse) } + bare.matches(Regex("/session/ses_[^/]+/diff")) && method == "GET" -> { + lastSessionDiffPath = path + respond(output, 200, sessionDiff) + } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index f2925161a4b..35df48eaa59 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -219,8 +219,8 @@ class KiloSessionService internal constructor( suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } - suspend fun diffSides(dir: String, file: DiffFileDto): DiffFileDto? = - call { diffSides(dir, file) } + suspend fun diffSides(sessionId: String?, dir: String, file: DiffFileDto, messageId: String?): DiffFileDto? = + call { diffSides(sessionId, dir, file, messageId) } suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index 0c4fde54f11..43ed3b89491 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -161,20 +161,32 @@ internal class KiloDiffEditorService( if (files.isEmpty()) return DiffEditorData.Empty val branch = params["branch"].takeIfPresent() ?: if (params["source"] == "branch") workspace.branchName(dir) else null - return DiffEditorData.Files(detail(dir, files, session), branch) + return DiffEditorData.Files(detail(params, dir, files, session), branch) } // Enrich modified files with full before/after content so the editor shows whole-file diffs. // Added/deleted/binary files already render fully from their patch, so they skip the round-trip; // a null result (working tree drifted from the patch) falls back to the hunk view. private suspend fun detail( + params: Map, dir: String, files: List, session: KiloSessionService, - ): List = files.map { file -> - val patch = file.patch - if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file - else runCatching { session.diffSides(dir, file) }.getOrNull() ?: file + ): List { + val sessionId = params["sessionId"].takeIfPresent() + val message = message(params) + return files.map { file -> + val patch = file.patch + if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file + else runCatching { session.diffSides(sessionId, dir, file, message) }.getOrNull() ?: file + } + } + + // Turn diffs carry a "turn::" token; the turn id is the message the CLI scopes + // the authoritative snapshot diff to. Other sources (session, branch) have no per-turn message. + private fun message(params: Map): String? { + val parts = params["token"].takeIfPresent()?.split(":", limit = 3) ?: return null + return if (parts.size == 3 && parts[0] == "turn") parts[2].takeIfPresent() else null } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 1087cf75313..b3951bfab33 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -260,7 +260,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return diffs[id]?.toList().orEmpty() } - override suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? { + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { assertNotEdt("diffSides") return diffSides[file.file] } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index e9752ae0be9..9c5773a8961 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -104,11 +104,13 @@ interface KiloSessionRpcApi : RemoteApi { suspend fun diff(id: String, directory: String): List /** - * Rebuild full before/after content for one changed file so the diff editor can show a whole-file - * diff. Returns null when the file's working-tree content no longer matches the patch (fall back to - * the hunk view). Added/deleted files return null because the frontend reconstructs those directly. + * Full before/after content for one changed file so the diff editor can show a whole-file diff. + * Prefers authoritative snapshot content from a CLI that supports it (correct even for historical + * turns); falls back to rebuilding locally from the working tree + hunk patch against any pinned + * CLI. Returns null when neither is available (fall back to the hunk view). Added/deleted files + * return null because the frontend reconstructs those directly. */ - suspend fun diffSides(directory: String, file: DiffFileDto): DiffFileDto? + suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? /** Load one attachment part from a session without returning full history to the frontend. */ suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? diff --git a/packages/opencode/src/kilocode/snapshot/diff-full.ts b/packages/opencode/src/kilocode/snapshot/diff-full.ts index 82d2b6789cf..532f6c713dd 100644 --- a/packages/opencode/src/kilocode/snapshot/diff-full.ts +++ b/packages/opencode/src/kilocode/snapshot/diff-full.ts @@ -84,6 +84,64 @@ export namespace DiffFull { return map }) + // Cap the full-content sides we return for a single editor diff tab. Anything larger falls back + // to the hunk-only view rather than shipping tens of MB of text over RPC. + export const MAX_DETAIL_SIZE = 20 * 1024 * 1024 + + /** + * Authoritative full-content detail for one file between two snapshot refs, for the editor diff + * tab. Returns status, additions/deletions, the hunk patch, and the whole before/after file + * contents (read from the snapshot objects via `git show`), so the client can render a whole-file + * diff regardless of the current working tree. `before`/`after` are omitted when a side exceeds + * [MAX_DETAIL_SIZE]; binary files carry no content. `run.diff` wraps quote+args, `run.show` wraps + * the plain config (no quotepath) so `git show ref:path` resolves correctly. + */ + export const detail = Effect.fn("DiffFull.detail")(function* ( + run: { + diff: (cmd: string[]) => Effect.Effect + show: (cmd: string[]) => Effect.Effect + }, + from: string, + to: string, + path: string, + ) { + const names = yield* run.diff(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", path]) + const row = names.text.trim().split("\n").find(Boolean) + if (!row) return undefined + const code = row.split("\t")[0] ?? "" + const status: "added" | "deleted" | "modified" = + code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + + const numstat = yield* run.diff(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", path]) + const [adds, dels] = numstat.text.trim().split("\n").find(Boolean)?.split("\t") ?? [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : Number.parseInt(adds ?? "0", 10) + const deletions = binary ? 0 : Number.parseInt(dels ?? "0", 10) + + const patch = binary ? "" : ((yield* batch(run.diff, from, to, [path])).get(path) ?? "") + const base = { + file: path, + patch, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status, + } + if (binary) return base + + const content = yield* Effect.all( + { + before: status === "added" ? Effect.succeed("") : run.show(["show", `${from}:${path}`]).pipe(Effect.map((r) => r.text)), + after: status === "deleted" ? Effect.succeed("") : run.show(["show", `${to}:${path}`]).pipe(Effect.map((r) => r.text)), + }, + { concurrency: 2 }, + ) + return { + ...base, + before: Buffer.byteLength(content.before) <= MAX_DETAIL_SIZE ? content.before : undefined, + after: Buffer.byteLength(content.after) <= MAX_DETAIL_SIZE ? content.after : undefined, + } + }) + /** * Generate a structured + unified diff for a single file in the working * tree vs HEAD using `git diff --ignore-all-space --unified=3`. diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 02837849be9..a7f5464a689 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -38,7 +38,8 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID", "full"]), // kilocode_change - full is a query boolean + full: Schema.optional(QueryBoolean), // kilocode_change - request full-content detail }) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index eba9b764589..885a3f98bdf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -105,7 +105,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) + // kilocode_change start - pass full-content detail query fields through to the summary service + return yield* summary.diff({ + sessionID: ctx.params.sessionID, + messageID: ctx.query.messageID, + full: ctx.query.full, + file: ctx.query.file, + }) + // kilocode_change end }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d540de86173..1b058c7b779 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -67,7 +67,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly diff: (input: DiffInput) => Effect.Effect // kilocode_change - full-content detail input readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect } @@ -144,7 +144,26 @@ export const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) - const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const diff = Effect.fn("SessionSummary.diff")(function* (input: DiffInput) { // kilocode_change - full-content detail input + // kilocode_change start - authoritative full-content detail for one file (editor diff tabs) + if (input.full && input.file) { + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const messages = input.messageID + ? all.filter( + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), + ) + : all + let from: string | undefined + let to: string | undefined + for (const item of messages) { + if (!from) for (const part of item.parts) if (part.type === "step-start" && part.snapshot) { from = part.snapshot; break } + for (const part of item.parts) if (part.type === "step-finish" && part.snapshot) to = part.snapshot + } + if (!from || !to) return [] + const detail = yield* snapshot.diffFile(from, to, input.file) + return detail ? [detail] : [] + } + // kilocode_change end // kilocode_change start - retain cumulative diffs for legacy TUI and VS Code consumers if (!input.messageID) { const diffs = yield* storage @@ -192,6 +211,8 @@ export const defaultLayer = Layer.suspend(() => export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), + full: Schema.optional(Schema.Boolean), // kilocode_change - request full-content detail + file: Schema.optional(Schema.String), // kilocode_change - scope full detail to one file }) export type DiffInput = Schema.Schema.Type diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a1d74809783..173861141e6 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,6 +34,8 @@ export const FileDiff = Schema.Struct({ // session response and broke session loading on Desktop. file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), + before: Schema.optional(Schema.String), // kilocode_change - full-content sides for editor diff tabs + after: Schema.optional(Schema.String), // kilocode_change - full-content sides for editor diff tabs additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), @@ -82,6 +84,7 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect + readonly diffFile: (from: string, to: string, file: string) => Effect.Effect // kilocode_change - authoritative full-content detail } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -892,7 +895,23 @@ export const layer: Layer.Layer = Effect.forkScoped, ) - return { cleanup, track, patch, restore, revert, diff, diffFull } + // kilocode_change start - authoritative full-content detail for editor diff tabs + const diffFile = Effect.fnUntraced(function* (from: string, to: string, file: string) { + return yield* locked( + DiffFull.detail( + { + diff: (cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }), + show: (cmd) => git([...cfg, ...args(cmd)], { cwd: state.directory }), + }, + from, + to, + file, + ), + ) + }) + // kilocode_change end + + return { cleanup, track, patch, restore, revert, diff, diffFull, diffFile } // kilocode_change - diffFile }), ) @@ -968,6 +987,12 @@ export const layer: Layer.Layer = return yield* Effect.promise(() => pending) // kilocode_change end }), + // kilocode_change start - authoritative full-content detail for editor diff tabs + diffFile: Effect.fn("Snapshot.diffFile")(function* (from: string, to: string, file: string) { + if (from === to) return undefined + return yield* InstanceState.useEffect(state, (s) => s.diffFile(from, to, file)) + }), + // kilocode_change end }) }), ) diff --git a/packages/opencode/test/kilocode/diff-full.test.ts b/packages/opencode/test/kilocode/diff-full.test.ts index 3f67cee9237..90838910b02 100644 --- a/packages/opencode/test/kilocode/diff-full.test.ts +++ b/packages/opencode/test/kilocode/diff-full.test.ts @@ -183,6 +183,70 @@ describe("DiffFull.batch", () => { ) }) +describe("DiffFull.detail", () => { + const runners = (dir: string) => ({ diff: gitResult(dir), show: gitResult(dir) }) + + it.live("returns full before/after plus hunk patch for a modified file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "a.txt"), "keep\nold\ntail\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "a.txt"), "keep\nnew\ntail\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "a.txt") + expect(got?.status).toBe("modified") + expect(got?.before).toBe("keep\nold\ntail\n") + expect(got?.after).toBe("keep\nnew\ntail\n") + expect(got?.patch).toContain("-old") + expect(got?.patch).toContain("+new") + }), + ) + + it.live("returns an empty before for an added file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "base.txt"), "x\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "added.txt"), "hi\nthere\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "added.txt") + expect(got?.status).toBe("added") + expect(got?.before).toBe("") + expect(got?.after).toBe("hi\nthere\n") + }), + ) + + it.live("returns an empty after for a deleted file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "gone.txt"), "bye\nnow\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => $`git rm gone.txt`.cwd(dir).quiet()) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "gone.txt") + expect(got?.status).toBe("deleted") + expect(got?.before).toBe("bye\nnow\n") + expect(got?.after).toBe("") + }), + ) + + it.live("returns undefined for a file unchanged between refs", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "same.txt"), "same\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "other.txt"), "changed\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "same.txt") + expect(got).toBeUndefined() + }), + ) +}) + describe("DiffFull.file", () => { it.live("returns a structured + unified diff for a modified working-tree file", () => Effect.gen(function* () { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 91a0d0b6507..4bce7c4fca9 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -309,6 +309,7 @@ const snap = Layer.succeed( revert: () => Effect.void, diff: () => Effect.succeed(""), diffFull: () => Effect.succeed([]), + diffFile: () => Effect.succeed(undefined), }), ) // kilocode_change end From 41e2bf5643738b6d5e4a85c5244c42e19b50a6d0 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 18:26:46 -0400 Subject: [PATCH 20/78] fix(jetbrains): correct gutter line numbers in hunk-fallback diff editor When the diff editor tab falls back to reconstructing sides from the hunk patch, the content is just the concatenated hunk bodies, so IntelliJ numbered the gutter from 1 instead of the real file positions. DiffPatchReconstruct now records, in lockstep with the content it builds, the 0-based source-file line for each reconstructed document line (following the @@ headers and jumping across elided inter-hunk gaps). diffRequest attaches these via the public DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR so the gutter matches the file, mirroring what the inline card already shows. Full authoritative content is untouched since it already starts at line 1. --- .../ai/kilocode/client/diff/DiffBlocks.kt | 14 ++++- .../client/diff/DiffPatchReconstruct.kt | 42 ++++++++++---- .../ai/kilocode/client/diff/DiffBlocksTest.kt | 27 +++++++++ .../client/diff/DiffPatchReconstructTest.kt | 56 +++++++++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index c7a3c921e23..6b8b8d0f0f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -3,12 +3,15 @@ package ai.kilocode.client.diff import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.diff.DiffContentFactory +import com.intellij.diff.contents.DocumentContent import com.intellij.diff.requests.DiffRequest import com.intellij.diff.requests.SimpleDiffRequest import com.intellij.diff.util.DiffUserDataKeys +import com.intellij.diff.util.DiffUserDataKeysEx import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.FileStatus +import java.util.function.IntUnaryOperator internal fun diffRequest( project: Project, @@ -26,14 +29,14 @@ internal fun diffRequest( val left = when { full -> factory.create(project, dto.before.orEmpty(), type) DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty() - sides.renderable -> factory.create(project, sides.before, type) + sides.renderable -> factory.create(project, sides.before, type).numbered(sides.leftLines) status == FileStatus.DELETED -> factory.create(project, fallback, type) else -> factory.createEmpty() } val right = when { full -> factory.create(project, dto.after.orEmpty(), type) DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty() - sides.renderable -> factory.create(project, sides.after, type) + sides.renderable -> factory.create(project, sides.after, type).numbered(sides.rightLines) status == FileStatus.DELETED -> factory.createEmpty() else -> factory.create(project, fallback, type) } @@ -46,3 +49,10 @@ internal fun diffTitle(file: String, branch: String?): String { val name = branch.takeIf { !it.isNullOrBlank() } ?: return file return KiloBundle.message("diff.editor.file.title", file, name) } + +// The hunk-fallback content is a concatenation of hunk bodies, so its own document lines restart at 1. +// Remap each document line to its real source-file line (0-based; the platform adds 1) so the gutter +// matches the file instead of showing 1..N. Lines with no mapping return -1 and the platform hides them. +private fun DocumentContent.numbered(lines: List): DocumentContent = apply { + if (lines.isNotEmpty()) putUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR, IntUnaryOperator { lines.getOrElse(it) { -1 } }) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt index ca2c92cf46e..b29812ca12d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -6,21 +6,29 @@ internal data class DiffSides( val before: String, val after: String, val renderable: Boolean, + // 0-based source-file line for each reconstructed document line, so the diff editor gutter shows + // real file positions (and jumps across the elided inter-hunk gaps) instead of restarting at 1. + val leftLines: List = emptyList(), + val rightLines: List = emptyList(), ) internal object DiffPatchReconstruct { - private val HUNK = Regex("^@@ -\\d+(?:,(\\d+))? \\+\\d+(?:,(\\d+))? @@") + private val HUNK = Regex("^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@") fun sides(dto: DiffFileDto): DiffSides { val patch = dto.patch if (patch.isNullOrBlank() || binary(patch)) return DiffSides("", "", false) val before = StringBuilder() val after = StringBuilder() + val leftLines = mutableListOf() + val rightLines = mutableListOf() var hunks = 0 var oldLen = 0 var newLen = 0 var oldSeen = 0 var newSeen = 0 + var oldLine = 1 + var newLine = 1 // Drop the trailing empty element that split('\n') yields for a newline-terminated patch (the // usual case for git output). Counting it as a body line would inflate oldSeen/newSeen past the // header lengths and wrongly reject every full-context diff. Mirrors DiffLineNumbers' edge trim; @@ -29,8 +37,10 @@ internal object DiffPatchReconstruct { if (line.startsWith("@@")) { hunks += 1 HUNK.find(line)?.let { match -> - oldLen += match.groupValues[1].ifEmpty { "1" }.toInt() - newLen += match.groupValues[2].ifEmpty { "1" }.toInt() + oldLine = match.groupValues[1].toInt() + newLine = match.groupValues[3].toInt() + oldLen += match.groupValues[2].ifEmpty { "1" }.toInt() + newLen += match.groupValues[4].ifEmpty { "1" }.toInt() } continue } @@ -38,16 +48,16 @@ internal object DiffPatchReconstruct { if (line.startsWith("\\")) continue when (line.firstOrNull()) { ' ' -> { - before.appendLine(line.substring(1)) - after.appendLine(line.substring(1)) + before.appendLine(line.substring(1)); leftLines.add(oldLine++ - 1) + after.appendLine(line.substring(1)); rightLines.add(newLine++ - 1) oldSeen += 1 newSeen += 1 } - '-' -> { before.appendLine(line.substring(1)); oldSeen += 1 } - '+' -> { after.appendLine(line.substring(1)); newSeen += 1 } + '-' -> { before.appendLine(line.substring(1)); leftLines.add(oldLine++ - 1); oldSeen += 1 } + '+' -> { after.appendLine(line.substring(1)); rightLines.add(newLine++ - 1); newSeen += 1 } else -> { - before.appendLine("") - after.appendLine("") + before.appendLine(""); leftLines.add(oldLine++ - 1) + after.appendLine(""); rightLines.add(newLine++ - 1) oldSeen += 1 newSeen += 1 } @@ -61,9 +71,17 @@ internal object DiffPatchReconstruct { // there is no hunk, or when the header lengths don't match the reconstructed body (truncated // context), because that would place lines against the wrong side. if (hunks < 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false) - val left = if (added(patch)) "" else before.toString().removeSuffix("\n") - val right = if (deleted(patch)) "" else after.toString().removeSuffix("\n") - return DiffSides(left, right, true) + val added = added(patch) + val deleted = deleted(patch) + val left = if (added) "" else before.toString().removeSuffix("\n") + val right = if (deleted) "" else after.toString().removeSuffix("\n") + return DiffSides( + left, + right, + true, + if (added) emptyList() else leftLines, + if (deleted) emptyList() else rightLines, + ) } fun added(patch: String?): Boolean = patch?.lineSequence()?.any { it == "--- /dev/null" } == true diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt index 01307f892b1..2d68628ea9f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.diff import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.diff.contents.DocumentContent import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.util.DiffUserDataKeysEx import com.intellij.testFramework.fixtures.BasePlatformTestCase class DiffBlocksTest : BasePlatformTestCase() { @@ -22,5 +23,31 @@ class DiffBlocksTest : BasePlatformTestCase() { assertEquals("old\nkeep\n", (request.contents[0] as DocumentContent).document.text) assertEquals("new\nkeep\n", (request.contents[1] as DocumentContent).document.text) + // Full content already starts at line 1, so no gutter remap is installed. + assertNull((request.contents[0] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)) + assertNull((request.contents[1] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)) + } + + fun `test diffRequest remaps hunk fallback gutter to real file lines`() { + val request = diffRequest( + project, + DiffFileDto( + file = "src/Main.kt", + additions = 1, + deletions = 1, + patch = "--- a/src/Main.kt\n+++ b/src/Main.kt\n@@ -126,3 +126,3 @@\n one\n-two\n+TWO\n three\n", + status = "modified", + ), + ) as SimpleDiffRequest + + val left = (request.contents[0] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)!! + val right = (request.contents[1] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)!! + // Document line 0 (0-based) maps to file line 125 (0-based); the platform renders it as 126. + assertEquals(125, left.applyAsInt(0)) + assertEquals(127, left.applyAsInt(2)) + assertEquals(125, right.applyAsInt(0)) + assertEquals(127, right.applyAsInt(2)) + // Out-of-range document lines are hidden. + assertEquals(-1, left.applyAsInt(9)) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt index 0215144879a..b55ef75ae72 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -165,6 +165,62 @@ class DiffPatchReconstructTest { assertEquals("one\nTWO\nthree\ntwenty\nX\nz", sides.after) } + @Test + fun `line maps carry real file positions across hunk gaps`() { + // Regression: the fallback content restarts at line 1, so the diff editor gutter showed 1..N + // instead of real file positions. The per-side maps (0-based; platform adds 1) must follow the + // @@ headers and jump across the elided gap between the two hunks. + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 2, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -126,3 +126,3 @@ + one + -two + +TWO + three + @@ -220,3 +221,3 @@ + twenty + -x + +X + z + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + // before doc lines: one/two/three (126,127,128) then twenty/x/z (220,221,222) -> 0-based + assertEquals(listOf(125, 126, 127, 219, 220, 221), sides.leftLines) + // after doc lines: one/TWO/three (126,127,128) then twenty/X/z (221,222,223) -> 0-based + assertEquals(listOf(125, 126, 127, 220, 221, 222), sides.rightLines) + } + + @Test + fun `added file has no left line map and after side maps from one`() { + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 0, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- /dev/null + +++ b/src/A.kt + @@ -0,0 +1,2 @@ + +one + +two + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertEquals(emptyList(), sides.leftLines) + assertEquals(listOf(0, 1), sides.rightLines) + } + @Test fun `multi hunk patch with truncated context is not renderable`() { // header claims 3 old / 3 new lines per hunk but the body carries only 2 of each: reconstructing From f09e628a4ee334eeef6b87145d558b5988417125 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 18:29:09 -0400 Subject: [PATCH 21/78] fix(cli): give DiffFull.detail a single return shape The binary branch returned an object without before/after while the normal branch included them, so the result was a union and callers could not read before/after without narrowing (the diff-full test failed typecheck). Return before/after as undefined for binary files so the shape is uniform; JSON still omits the undefined keys, so serialized output is unchanged. --- packages/opencode/src/kilocode/snapshot/diff-full.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/snapshot/diff-full.ts b/packages/opencode/src/kilocode/snapshot/diff-full.ts index 532f6c713dd..6beb5f97c8c 100644 --- a/packages/opencode/src/kilocode/snapshot/diff-full.ts +++ b/packages/opencode/src/kilocode/snapshot/diff-full.ts @@ -126,7 +126,9 @@ export namespace DiffFull { deletions: Number.isFinite(deletions) ? deletions : 0, status, } - if (binary) return base + // Uniform shape across branches: binary files carry no content, but keep the keys present + // (undefined) so the return type is a single object, not a union missing before/after. + if (binary) return { ...base, before: undefined, after: undefined } const content = yield* Effect.all( { From 6a8302881daf12bb2de5c9296e085bc8e2aafdaf Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:28:55 -0400 Subject: [PATCH 22/78] 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 00000000000..ecd59d67da5 --- /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 00000000000..a58ddcc532a --- /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 00000000000..ae293b6b1ff --- /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 29012410381..254d66698f3 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/78] fix(jetbrains): route full diffs to session directory and scope single edits Two correctness fixes for the diff editor's full-file view plus diagnostics: - Open inline/turn/edit diffs against the session's own directory (SessionController.sessionDirectory) instead of the active workspace directory, so authoritative snapshot lookup and local reconstruction resolve against the repo where the session actually ran. - Scope single Edit/Patch diffs to their message: message() now parses the "tool::" token in addition to "turn:", so the authoritative snapshot query is scoped to that edit. A single edit's intermediate state cannot be rebuilt from the final working tree, so it requires the authoritative per-message snapshot. Also adds a dev-only working-tree resolver: when kilo.dev.worktree.root is set, resolve() re-roots a stored diff's file path onto the running worktree by matching the longest existing path suffix, so cross-worktree dev sessions can still reconstruct locally. In production resolve() returns the direct path only. Adds info logging around diffSides/authoritative/detail to make full-vs-hunk decisions observable. --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 43 ++++++++++++++----- .../client/diff/KiloDiffEditorKind.kt | 20 ++++++--- .../ai/kilocode/client/session/SessionUi.kt | 6 ++- .../session/controller/SessionController.kt | 1 + 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 28c8117b2a6..3861abeb3d3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -168,19 +168,40 @@ class KiloSessionRpcApiImpl internal constructor( override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { val patch = file.patch if (patch.isNullOrBlank()) return null + log.info("diffSides start file=${file.file} session=${!sessionId.isNullOrBlank()} message=${!messageId.isNullOrBlank()} patch=${patch.length}") // 1) Authoritative: a CLI with full/file support returns whole before/after from the snapshot, // correct even for historical turns. Older CLIs ignore the params, so we detect the missing // content and fall through to local reconstruction. - if (!sessionId.isNullOrBlank()) authoritative(sessionId, directory, file, messageId)?.let { return it } + if (!sessionId.isNullOrBlank()) authoritative(sessionId, directory, file, messageId)?.let { + log.info("diffSides authoritative file=${file.file} before=${it.before?.length ?: 0} after=${it.after?.length ?: 0}") + return it + } // 2) Fallback: read the working-tree file and reverse-apply the hunk patch to recover the whole // "before". No CLI round-trip, so this works against any pinned CLI. return withContext(Dispatchers.IO) { - val after = runCatching { Files.readString(Path.of(directory).resolve(file.file)) }.getOrNull() + val path = resolve(directory, file.file) + val after = path?.let { runCatching { Files.readString(it) }.getOrNull() } val before = after?.let { DiffFullReconstruct.before(it, patch) } + log.info("diffSides fallback file=${file.file} path=${path ?: ""} after=${after?.length ?: 0} before=${before?.length ?: 0}") if (after != null && before != null) file.copy(before = before, after = after) else null } } + private fun resolve(directory: String, file: String): Path? { + val direct = Path.of(directory).resolve(file).normalize() + if (Files.isRegularFile(direct)) return direct + // dev-only: a stored diff may reference another worktree (relative, or absolute into a sibling + // worktree that isn't checked out here). Re-root onto the running worktree by trying progressively + // shorter path suffixes until one exists, so full-file diffs work across dev worktrees. + val root = System.getProperty("kilo.dev.worktree.root")?.takeIf { it.isNotBlank() }?.let(Path::of) ?: return null + val segs = Path.of(file).toList() + for (i in segs.indices) { + val candidate = segs.drop(i).fold(root) { acc, seg -> acc.resolve(seg) }.normalize() + if (Files.isRegularFile(candidate)) return candidate + } + return null + } + // Ask the CLI for full before/after via GET /session/:id/diff?full=true&file=...; returns null when // the pinned CLI lacks full/file support (it omits before/after) so the caller falls back locally. private suspend fun authoritative(sessionId: String, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { @@ -199,16 +220,18 @@ class KiloSessionRpcApiImpl internal constructor( ?.build() ?: return@runCatching null api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> - if (!response.isSuccessful) return@runCatching null - val item = Json.parseToJsonElement(response.body?.string().orEmpty()).jsonArray - .firstOrNull { it.jsonObject["file"]?.jsonPrimitive?.contentOrNull == file.file } - ?.jsonObject - ?: return@runCatching null - val before = item["before"]?.jsonPrimitive?.contentOrNull - val after = item["after"]?.jsonPrimitive?.contentOrNull + if (!response.isSuccessful) { + log.info("diffSides authoritative file=${file.file} http=${response.code} messageID=${messageId ?: "none"}") + return@runCatching null + } + val arr = Json.parseToJsonElement(response.body?.string().orEmpty()).jsonArray + val item = arr.firstOrNull { it.jsonObject["file"]?.jsonPrimitive?.contentOrNull == file.file }?.jsonObject + val before = item?.get("before")?.jsonPrimitive?.contentOrNull + val after = item?.get("after")?.jsonPrimitive?.contentOrNull + log.info("diffSides authoritative file=${file.file} items=${arr.size} matched=${item != null} before=${before?.length ?: 0} after=${after?.length ?: 0}") if (before != null && after != null) file.copy(before = before, after = after) else null } - }.getOrNull() + }.onFailure { log.info("diffSides authoritative file=${file.file} error=${it.message}") }.getOrNull() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index 43ed3b89491..38e5b51d77c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -175,18 +175,28 @@ internal class KiloDiffEditorService( ): List { val sessionId = params["sessionId"].takeIfPresent() val message = message(params) + LOG.info("diff editor detail source=${params["source"]} files=${files.size} session=${!sessionId.isNullOrBlank()} message=${!message.isNullOrBlank()}") return files.map { file -> val patch = file.patch - if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) file - else runCatching { session.diffSides(sessionId, dir, file, message) }.getOrNull() ?: file + if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) { + LOG.info("diff editor detail skip file=${file.file} patch=${!patch.isNullOrBlank()} status=${file.status}") + file + } else { + val detail = runCatching { session.diffSides(sessionId, dir, file, message) } + .onFailure { LOG.warn("diff editor detail failed file=${file.file}", it) } + .getOrNull() + LOG.info("diff editor detail file=${file.file} full=${detail?.before != null && detail?.after != null} before=${detail?.before?.length ?: 0} after=${detail?.after?.length ?: 0}") + detail ?: file + } } } - // Turn diffs carry a "turn::" token; the turn id is the message the CLI scopes - // the authoritative snapshot diff to. Other sources (session, branch) have no per-turn message. + // Turn diffs carry "turn::" and single-edit diffs carry "tool::"; + // the third segment is the message the CLI scopes the authoritative snapshot diff to. Other sources + // (session, branch) have no per-message scope. private fun message(params: Map): String? { val parts = params["token"].takeIfPresent()?.split(":", limit = 3) ?: return null - return if (parts.size == 3 && parts[0] == "turn") parts[2].takeIfPresent() else null + return if (parts.size == 3 && (parts[0] == "turn" || parts[0] == "tool")) parts[2].takeIfPresent() else null } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9b635e1bf4b..a7b30a796c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -822,15 +822,17 @@ class SessionUi( } private fun openInlineDiff(files: List, title: String, key: String) { + val dir = controller.sessionDirectory cs.launch { - val branch = workspaces.branchName(workspace.directory) + val branch = workspaces.branchName(dir) val label = branch?.let { KiloBundle.message("diff.editor.inline.title.named", title, it) } ?: title + LOG.info("open inline diff session=${controller.id ?: "pending"} dir=${ChatLogSummary.dir(dir)} files=${files.size}") withContext(Dispatchers.Main) { ensureDiffEditorKind() project.service().put(key, files) project.service().open( KiloDiffEditorKind.ID, - diffParams("inline", workspace.directory, controller.id, label, token = key), + diffParams("inline", dir, controller.id, label, token = key), ) Telemetry.send("Diff Editor Opened", mapOf("source" to "inline")) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 22bea5de8e3..a0f0013b98a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -189,6 +189,7 @@ class SessionController( val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession internal val id: String? get() = sid + internal val sessionDirectory: String get() = model.session?.directory ?: (ref as? SessionRef.Local)?.session?.directory ?: directory internal val refKey: String? get() = ref?.key internal val refType: SessionRef.Type? get() = ref?.type From 967126c1ee1506dc0ece0326da1ef0c00c936496 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:44:39 -0400 Subject: [PATCH 24/78] chore(jetbrains): fix repo-CLI dev build after upstream merge - Add a revertDto overload for the regenerated GlobalSessionRevert model so the backend compiles against the post-merge OpenAPI client. - Resolve the bun executable from BUN_BINARY/BUN, PATH, ~/.bun/bin, and common Homebrew locations in GenerateOpenApiSpecTask, so IDE-launched Gradle (which runs with a stripped PATH) can generate the OpenAPI spec in repo-CLI mode. --- .../backend/app/KiloBackendSessionManager.kt | 5 +++++ .../src/main/kotlin/GenerateOpenApiSpecTask.kt | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index f48dbf90387..004bc837b52 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -5,6 +5,7 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.GlobalSession +import ai.kilocode.jetbrains.api.model.GlobalSessionRevert import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.SessionDto @@ -348,6 +349,10 @@ class KiloBackendSessionManager( revertDto(it.messageID, it.partID, it.snapshot, it.diff) } + private fun revertDto(s: GlobalSessionRevert?) = s?.let { + revertDto(it.messageID, it.partID, it.snapshot, it.diff) + } + private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) = SessionRevertDto( messageID = message, diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index 5482dfbd492..115fb1000aa 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -76,7 +76,7 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { val err = ByteArrayOutputStream() val result = exec.exec { workingDir = root - commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate") + commandLine(bun(), "run", "--conditions=browser", "./src/index.ts", "generate") standardOutput = out errorOutput = err isIgnoreExitValue = true @@ -96,6 +96,22 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { writeSpec(result.exitValue, out, err) } + private fun bun(): String { + val env = listOfNotNull(System.getenv("BUN_BINARY"), System.getenv("BUN")) + val path = System.getenv("PATH") + ?.split(File.pathSeparator) + ?.map { File(it, if (windows()) "bun.exe" else "bun") } + .orEmpty() + val home = System.getProperty("user.home") + val common = listOf( + File(home, ".bun/bin/${if (windows()) "bun.exe" else "bun"}"), + File("/opt/homebrew/bin/bun"), + File("/usr/local/bin/bun"), + ) + return (env.map(::File) + path + common).firstOrNull { it.isFile && it.canExecute() }?.absolutePath + ?: "bun" + } + private fun writeSpec(code: Int, out: ByteArrayOutputStream, err: ByteArrayOutputStream) { if (code != 0) { throw GradleException( From 4c6bcf5b174ac77f6bcf29616fe0258753088685 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 4 Aug 2026 19:54:53 -0400 Subject: [PATCH 25/78] fix(jetbrains): support pinned and repo CLI revert models Pinned CLI generation exposes GlobalSession.revert as SessionRevert, while the local repo CLI client can generate a separate GlobalSessionRevert model. Avoid importing the repo-only type so pinned builds keep compiling, while still mapping the regenerated repo-CLI shape through reflective getters when present. --- .../backend/app/KiloBackendSessionManager.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 004bc837b52..d6555546ee2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -5,7 +5,6 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.GlobalSession -import ai.kilocode.jetbrains.api.model.GlobalSessionRevert import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.SessionDto @@ -345,12 +344,15 @@ class KiloBackendSessionManager( files = count(files), ) - private fun revertDto(s: ai.kilocode.jetbrains.api.model.SessionRevert?) = s?.let { - revertDto(it.messageID, it.partID, it.snapshot, it.diff) - } - - private fun revertDto(s: GlobalSessionRevert?) = s?.let { - revertDto(it.messageID, it.partID, it.snapshot, it.diff) + private fun revertDto(s: Any?) = when (s) { + null -> null + is ai.kilocode.jetbrains.api.model.SessionRevert -> revertDto(s.messageID, s.partID, s.snapshot, s.diff) + else -> runCatching { + val cls = s.javaClass + fun str(name: String) = cls.methods.firstOrNull { it.name == name && it.parameterCount == 0 }?.invoke(s) as? String + val message = str("getMessageID") ?: return@runCatching null + revertDto(message, str("getPartID"), str("getSnapshot"), str("getDiff")) + }.getOrNull() } private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) = From 9bfbc35c5c674b09600f169a87704c2dee50a1b4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 5 Aug 2026 09:36:25 +0200 Subject: [PATCH 26/78] fix(cli): safely restore grep signal controls --- .changeset/green-greps-settle.md | 5 + packages/core/src/cross-spawn-spawner.ts | 47 +++- packages/core/src/kilocode/ripgrep-grep.ts | 50 +++++ packages/core/src/kilocode/spawn-exit.ts | 14 ++ packages/core/src/ripgrep.ts | 48 ++-- .../test/kilocode/ripgrep-settlement.test.ts | 208 ++++++++++++++++++ .../src/kilocode/tool/grep-signal-controls.ts | 64 ++++++ packages/opencode/src/tool/grep.ts | 26 ++- .../tool/grep-signal-controls.test.ts | 128 +++++++++++ .../__snapshots__/parameters.test.ts.snap | 23 +- packages/opencode/test/tool/grep.test.ts | 2 +- .../opencode/test/tool/parameters.test.ts | 15 ++ 12 files changed, 596 insertions(+), 34 deletions(-) create mode 100644 .changeset/green-greps-settle.md create mode 100644 packages/core/src/kilocode/ripgrep-grep.ts create mode 100644 packages/core/src/kilocode/spawn-exit.ts create mode 100644 packages/core/test/kilocode/ripgrep-settlement.test.ts create mode 100644 packages/opencode/src/kilocode/tool/grep-signal-controls.ts create mode 100644 packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts diff --git a/.changeset/green-greps-settle.md b/.changeset/green-greps-settle.md new file mode 100644 index 00000000000..9f7cacabf79 --- /dev/null +++ b/.changeset/green-greps-settle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add bounded, context-aware grep controls without leaving agents waiting on completed searches. diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 5888693f014..cb5f41d2896 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -3,6 +3,7 @@ import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" import * as NodePath from "@effect/platform-node/NodePath" import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change import { tap as tapStdio, tapped } from "./kilocode/stdio-tap" // kilocode_change - Bun drops buffered stdio on close +import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { settle } from "./kilocode/exit-code" // kilocode_change - settle signal termination as 128 + signum import * as Deferred from "effect/Deferred" @@ -268,7 +269,7 @@ export const make = Effect.gen(function* () { return { stdout, stderr, all: Stream.merge(stdout, stderr) } } - const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => + const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions, settle: boolean) => Effect.callback((resume) => { const signal = Deferred.makeUnsafe() const proc = launch(command.command, command.args, opts) @@ -280,6 +281,7 @@ export const make = Effect.gen(function* () { }) proc.on("exit", (...args) => { exit = args + if (settle) Deferred.doneUnsafe(signal, Exit.succeed(args)) // kilocode_change - bounded grep must not await inherited pipes }) proc.on("close", (...args) => { if (end) return @@ -326,6 +328,18 @@ export const make = Effect.gen(function* () { return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) }) + // kilocode_change start - inspect descendants owned by commands that settle on direct exit + const groupAlive = (proc: NodeChildProcess.ChildProcess) => { + if (process.platform === "win32") return false + try { + process.kill(-proc.pid!, 0) + return true + } catch { + return false + } + } + // kilocode_change end + const timeout = ( proc: NodeChildProcess.ChildProcess, @@ -370,6 +384,7 @@ export const make = Effect.gen(function* () { switch (command._tag) { case "StandardCommand": { const validation = SpawnValidation.take(command) // kilocode_change - retain target validation through preparation + const direct = SpawnExit.take(command) // kilocode_change - opt selected commands into direct-exit settlement const dir = yield* cwd(command.options) // kilocode_change start - prepare agent-scoped commands through the selected sandbox backend const target = yield* prepareSandbox(command, dir, env(command.options)) @@ -396,21 +411,33 @@ export const make = Effect.gen(function* () { const [proc, signal] = yield* Effect.acquireRelease( // kilocode_change start - spawn the prepared command and options - spawn(target, { - cwd: dir, - env: env(target.options), - stdio: stdios(sin, sout, serr, extra), - detached: target.options.detached ?? process.platform !== "win32", - shell: target.options.shell, - // kilocode_change end - windowsHide: process.platform === "win32", - }), + spawn( + target, + { + cwd: dir, + env: env(target.options), + stdio: stdios(sin, sout, serr, extra), + detached: target.options.detached ?? process.platform !== "win32", + shell: target.options.shell, + // kilocode_change end + windowsHide: process.platform === "win32", + }, + direct, // kilocode_change + ), Effect.fnUntraced(function* ([proc, signal]) { const done = yield* Deferred.isDone(signal) const kill = timeout(proc, command, target.options) // kilocode_change if (done) { const [code] = yield* Deferred.await(signal) if (process.platform === "win32") return yield* Effect.void + // kilocode_change start - clean up only descendants owned by direct-settling commands + if (direct && groupAlive(proc)) { + yield* Effect.ignore(killGroup(command, proc, target.options.killSignal ?? "SIGTERM")) + yield* Effect.sleep("100 millis") + if (groupAlive(proc)) yield* Effect.ignore(killGroup(command, proc, "SIGKILL")) + return yield* Effect.void + } + // kilocode_change end if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) return yield* Effect.void } diff --git a/packages/core/src/kilocode/ripgrep-grep.ts b/packages/core/src/kilocode/ripgrep-grep.ts new file mode 100644 index 00000000000..cac163c7f5d --- /dev/null +++ b/packages/core/src/kilocode/ripgrep-grep.ts @@ -0,0 +1,50 @@ +import type { Match } from "@opencode-ai/schema/filesystem" + +export interface Options { + readonly context?: number + readonly literal?: boolean + readonly ignoreCase?: boolean +} + +export type GrepMatch = Match & { + readonly context: boolean + readonly textTruncated: boolean +} + +export const flags = (input: Options) => [ + ...(input.literal ? ["--fixed-strings"] : []), + ...(input.ignoreCase ? ["--ignore-case"] : []), + ...(input.context ? [`--context=${input.context}`] : []), +] + +export const stop = (limit: number) => { + let matches = 0 + return (row: { readonly context: boolean }) => !row.context && ++matches > limit +} + +export const select = < + A extends { + readonly context: boolean + readonly path: { readonly text: string } + readonly line_number: number + }, +>( + input: { readonly limit: number; readonly context?: number }, + items: readonly A[], +) => { + let count = 0 + const overflow = items.findIndex((row) => !row.context && ++count > input.limit) + const selected = items.slice(0, overflow === -1 ? items.length : overflow) + const matches = selected.filter((row) => !row.context) + return selected.filter( + (row) => + !row.context || + matches.some( + (match) => + match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0), + ), + ) +} + +export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch => + Object.assign(match, { context, textTruncated }) diff --git a/packages/core/src/kilocode/spawn-exit.ts b/packages/core/src/kilocode/spawn-exit.ts new file mode 100644 index 00000000000..3bd4c0f88a9 --- /dev/null +++ b/packages/core/src/kilocode/spawn-exit.ts @@ -0,0 +1,14 @@ +import type { ChildProcess } from "effect/unstable/process" + +const commands = new WeakSet() + +export function attach(command: ChildProcess.StandardCommand) { + commands.add(command) + return command +} + +export function take(command: ChildProcess.StandardCommand) { + const found = commands.has(command) + commands.delete(command) + return found +} diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5361c083a43..71ee912e249 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -1,9 +1,11 @@ export * as Ripgrep from "./ripgrep" -import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { makeGlobalNode } from "./effect/app-node" import { Entry, Match } from "@opencode-ai/schema/filesystem" +import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change +import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -21,7 +23,7 @@ const MAX_RECORD_BYTES = 64 * 1024 const MAX_SUBMATCHES = 100 const RawMatch = Schema.Struct({ - type: Schema.Literal("match"), + type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records data: Schema.Struct({ path: Schema.Struct({ text: Schema.String }), lines: Schema.Struct({ text: Schema.String }), @@ -37,7 +39,7 @@ const RawMatch = Schema.Struct({ }), }) -type RawMatchData = (typeof RawMatch.Type)["data"] +type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, @@ -69,7 +71,8 @@ export interface GlobInput { readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn } -export interface GrepInput { +export interface GrepInput extends KiloGrep.Options { + // kilocode_change readonly cwd: string readonly pattern: string readonly file?: string @@ -82,7 +85,7 @@ export interface GrepInput { export interface Interface { readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect, Error> // kilocode_change - readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change + readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change } // kilocode_change start - retain truncation state through model-facing tools @@ -114,6 +117,7 @@ const layer = Layer.effect( readonly parse: (line: string) => Effect.Effect readonly pattern?: string readonly onItem?: (item: A) => Effect.Effect + readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match readonly validate?: Effect.Effect // kilocode_change - spawn-bound target validation }) => { const program = Effect.scoped( @@ -124,16 +128,24 @@ const layer = Layer.effect( cwd: input.cwd, extendEnv: true, stdin: "ignore", + forceKillAfter: input.stop ? Duration.seconds(1) : undefined, // kilocode_change - bound grep interruption }) - const handle = yield* process.spawn( - input.validate ? SpawnValidation.attach(command, input.validate) : command, - ) + const validated = input.validate ? SpawnValidation.attach(command, input.validate) : command + const spawned = input.stop ? SpawnExit.attach(validated) : validated // kilocode_change + const handle = yield* process.spawn(spawned) // kilocode_change end const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( Effect.map((output) => output.buffer.toString("utf8")), Effect.forkScoped, ) let observed = 0 + let stopped = false // kilocode_change + const take = input.stop // kilocode_change start + ? Stream.takeUntil((row) => { + stopped = input.stop?.(row) ?? false + return stopped + }) + : Stream.take(input.limit + 1) // kilocode_change end const rows = yield* Stream.decodeText(handle.stdout).pipe( Stream.splitLines, Stream.filter((line) => line.length > 0), @@ -143,11 +155,12 @@ const layer = Layer.effect( if (!input.onItem || observed++ >= input.limit) return Effect.void return input.onItem(row) }), - Stream.take(input.limit + 1), + take, // kilocode_change Stream.runCollect, Effect.map((chunk) => [...chunk]), ) - const truncated = rows.length > input.limit + if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change + const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop owns truncation if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } const code = yield* handle.exitCode @@ -244,11 +257,13 @@ const layer = Layer.effect( grep: (input) => run({ ...input, + stop: KiloGrep.stop(input.limit), // kilocode_change args: [ "--no-config", "--json", "--hidden", "--no-messages", + ...KiloGrep.flags(input), // kilocode_change ...(input.include ? [`--glob=${input.include}`] : []), "--glob=!**/.git/**", "--", @@ -264,13 +279,19 @@ const layer = Layer.effect( }) ).pipe( Effect.flatMap((json) => { - if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") + if ( + !json || + typeof json !== "object" || + !("type" in json) || + (json.type !== "match" && json.type !== "context") // kilocode_change + ) return Effect.succeed(undefined) return Schema.decodeUnknownEffect(RawMatch)(json).pipe( Effect.map((match) => ({ ...match.data, path: { text: match.data.path.text.replace(/^\.[\\/]/, "") }, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), + context: match.type === "context", // kilocode_change })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) @@ -280,12 +301,12 @@ const layer = Layer.effect( // kilocode_change start - retain spawn metadata after mapping matches Effect.map((result) => ({ ...result, - items: result.items.map((match) => { + items: KiloGrep.select(input, result.items).map((match) => { const relative = match.path.text .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") - return Match.make({ + const item = Match.make({ entry: Entry.make({ path: RelativePath.make(relative), type: "file", @@ -299,6 +320,7 @@ const layer = Layer.effect( end: submatch.end, })), }) + return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000) }), })), // kilocode_change end diff --git a/packages/core/test/kilocode/ripgrep-settlement.test.ts b/packages/core/test/kilocode/ripgrep-settlement.test.ts new file mode 100644 index 00000000000..955aa424a08 --- /dev/null +++ b/packages/core/test/kilocode/ripgrep-settlement.test.ts @@ -0,0 +1,208 @@ +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Fiber, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { RipgrepBinary } from "@opencode-ai/core/ripgrep/binary" +import { tmpdir } from "../fixture/tmpdir" +import { it } from "../lib/effect" + +const record = (type: "match" | "context", line: number, text: string) => + JSON.stringify({ + type, + data: { + path: { text: "fixture.ts" }, + lines: { text: `${text}\n` }, + line_number: line, + absolute_offset: line * 10, + submatches: type === "match" ? [{ match: { text: "NEEDLE.*" }, start: 0, end: 8 }] : [], + }, + }) + +const alive = (pid: number) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const read = async (file: string) => { + const deadline = Date.now() + 1_000 + while (Date.now() < deadline) { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value) return value + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${file}`) +} + +const cleanup = async (file: string) => { + const pid = Number(await fs.readFile(file, "utf8").catch(() => "")) + if (!pid || !alive(pid)) return + try { + process.kill(pid, "SIGKILL") + } catch (err) { + if (alive(pid)) throw err + } +} + +const gone = async (pid: number) => { + const deadline = Date.now() + 1_000 + while (Date.now() < deadline) { + if (!alive(pid)) return true + await Bun.sleep(10) + } + return !alive(pid) +} + +const fixture = async (dir: string, source: string) => { + if (process.platform !== "win32") { + const binary = path.join(dir, "rg") + await fs.writeFile(binary, `#!${process.execPath}\n${source}`, { mode: 0o755 }) + return binary + } + + const script = path.join(dir, "fake-rg.cjs") + const binary = path.join(dir, "rg.cmd") + await fs.writeFile(script, source) + await fs.writeFile(binary, `@echo off\r\n"${process.execPath}" "%~dp0fake-rg.cjs" %*\r\n`) + return binary +} + +const layer = (binary: string) => + LayerNode.compile(Ripgrep.node, [ + [ + RipgrepBinary.node, + Layer.succeed(RipgrepBinary.Service, RipgrepBinary.Service.of({ filepath: Effect.succeed(binary) })), + ], + ] as const) + +describe("Kilo ripgrep settlement", () => { + it.live( + "settles a bounded parameterized grep when inherited output stays open", + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const retained = path.join(tmp.path, "retained.pid") + const owned = path.join(tmp.path, "owned.pid") + const args = path.join(tmp.path, "args.json") + const output = + [ + record("context", 1, "before"), + record("match", 2, "NEEDLE.*"), + record("context", 3, "after"), + record("context", 5, "later before"), + record("match", 6, "NEEDLE.*"), + ].join("\n") + "\n" + const source = `const { spawn } = require("node:child_process") +const { writeFileSync, writeSync } = require("node:fs") +const retained = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(0), 30_000)"], { + detached: true, + stdio: ["ignore", "inherit", "inherit"], +}) +retained.unref() +${ + process.platform === "win32" + ? "" + : `const owned = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(0), 30_000)"], { + stdio: "ignore", +}) +owned.unref()` +} +writeFileSync(${JSON.stringify(retained)}, String(retained.pid)) +${process.platform === "win32" ? "" : `writeFileSync(${JSON.stringify(owned)}, String(owned.pid))`} +writeFileSync(${JSON.stringify(args)}, JSON.stringify(process.argv.slice(2))) +writeSync(1, ${JSON.stringify(output)}) +` + const binary = yield* Effect.promise(() => fixture(tmp.path, source)) + + const result = yield* Ripgrep.Service.pipe( + Effect.flatMap((ripgrep) => + ripgrep.grep({ + cwd: tmp.path, + pattern: "NEEDLE.*", + include: "*.ts", + context: 1, + limit: 1, + literal: true, + ignoreCase: true, + }), + ), + Effect.provide(layer(binary)), + Effect.timeout("5 seconds"), + ) + const pid = Number(yield* Effect.promise(() => read(retained))) + const passed: unknown = JSON.parse(yield* Effect.promise(() => read(args))) + if (!Array.isArray(passed) || !passed.every((arg) => typeof arg === "string")) { + throw new Error("Fake ripgrep did not capture string arguments") + } + + expect(result.truncated).toBe(true) + expect(result.items.map((item) => [item.context, item.line, item.text.trim()])).toEqual([ + [true, 1, "before"], + [false, 2, "NEEDLE.*"], + [true, 3, "after"], + ]) + expect(passed).toContain("--fixed-strings") + expect(passed).toContain("--ignore-case") + expect(passed).toContain("--context=1") + expect(passed).toContain("--glob=*.ts") + expect(alive(pid)).toBe(true) + if (process.platform !== "win32") { + const child = Number(yield* Effect.promise(() => read(owned))) + expect(yield* Effect.promise(() => gone(child))).toBe(true) + } + }), + (tmp) => + Effect.promise(async () => { + await cleanup(path.join(tmp.path, "retained.pid")) + await cleanup(path.join(tmp.path, "owned.pid")) + await tmp[Symbol.asyncDispose]() + }), + ), + 10_000, + ) + + it.live( + "force kills a bounded grep that does not exit after cancellation", + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const ready = path.join(tmp.path, "ready.pid") + const source = `const { writeFileSync } = require("node:fs") +if (process.platform !== "win32") process.on("SIGTERM", () => {}) +writeFileSync(${JSON.stringify(ready)}, String(process.pid)) +setInterval(() => {}, 10_000) +` + const binary = yield* Effect.promise(() => fixture(tmp.path, source)) + + const controller = new AbortController() + const fiber = yield* Ripgrep.Service.pipe( + Effect.flatMap((ripgrep) => + ripgrep.grep({ cwd: tmp.path, pattern: "needle", context: 1, limit: 1, signal: controller.signal }), + ), + Effect.provide(layer(binary)), + Effect.exit, + Effect.forkScoped, + ) + const pid = Number(yield* Effect.promise(() => read(ready))) + controller.abort() + const exit = yield* Fiber.join(fiber).pipe(Effect.timeout("5 seconds")) + + expect(exit._tag).toBe("Failure") + expect(alive(pid)).toBe(false) + }), + (tmp) => + Effect.promise(async () => { + await cleanup(path.join(tmp.path, "ready.pid")) + await tmp[Symbol.asyncDispose]() + }), + ), + 10_000, + ) +}) diff --git a/packages/opencode/src/kilocode/tool/grep-signal-controls.ts b/packages/opencode/src/kilocode/tool/grep-signal-controls.ts new file mode 100644 index 00000000000..49eb582c645 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/grep-signal-controls.ts @@ -0,0 +1,64 @@ +import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" + +export const DEFAULT_LIMIT = 100 + +export const fields = { + context: Schema.optional(NonNegativeInt).annotate({ + description: "Number of context lines to show before and after each match (default 0)", + }), + limit: Schema.optional(PositiveInt).annotate({ + description: "Maximum matching lines to return (default 100)", + }), + literal: Schema.optional(Schema.Boolean).annotate({ + description: "Treat pattern as plain text instead of a regex (default false)", + }), + ignoreCase: Schema.optional(Schema.Boolean).annotate({ + description: "Match without regard to letter case (default false)", + }), +} + +type Input = { + readonly context?: number + readonly limit?: number + readonly literal?: boolean + readonly ignoreCase?: boolean +} + +export const metadata = (input: Input, limit: number, context: number) => ({ + context, + limit, + literal: input.literal, + ignoreCase: input.ignoreCase, +}) + +export const options = (input: Input, limit: number, context: number) => ({ + limit, + context, + literal: input.literal, + ignoreCase: input.ignoreCase, +}) + +export const describe = (description: string) => `${description} +- Searches file contents using regular expressions by default; use literal=true for plain-text patterns +- Use ignoreCase=true for case-insensitive matching, context=N for surrounding lines, and limit=N to bound matches (default 100) +- Context lines are explicitly labeled when requested` + +export const line = ( + row: { readonly line: number; readonly text: string; readonly context: boolean }, + context: number, +) => { + const label = context === 0 ? `Line ${row.line}` : `${row.context ? "[context]" : "[match]"} Line ${row.line}` + return ` ${label}: ${row.text}` +} + +export const limitNotice = (limit: number) => + `${limit} matches limit reached. Use limit=${Math.min(Number.MAX_SAFE_INTEGER, limit * 2)} for more, or refine pattern.` + +export const notices = (rows: readonly { readonly textTruncated: boolean }[]) => { + const output: string[] = [] + if (rows.some((row) => row.textTruncated)) { + output.push("", "Some matching or context lines were truncated. Use read for full lines.") + } + return output +} diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 30a1779208c..f4277831685 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -3,18 +3,20 @@ import { Effect, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import * as KiloGrep from "@/kilocode/tool/grep-signal-controls" // kilocode_change import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" export const Parameters = Schema.Struct({ - pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), + pattern: Schema.String.annotate({ description: "Pattern to search for in file contents (regex by default)" }), // kilocode_change path: Schema.optional(Schema.String).annotate({ description: "The directory to search in. Defaults to the current working directory.", }), include: Schema.optional(Schema.String).annotate({ description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', }), + ...KiloGrep.fields, // kilocode_change }) export const GrepTool = Tool.define( @@ -23,10 +25,12 @@ export const GrepTool = Tool.define( const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service return { - description: DESCRIPTION, + description: KiloGrep.describe(DESCRIPTION), // kilocode_change parameters: Parameters, - execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const limit = params.limit ?? KiloGrep.DEFAULT_LIMIT // kilocode_change + const context = params.context ?? 0 // kilocode_change const empty = { title: params.pattern, metadata: { matches: 0, truncated: false }, @@ -44,6 +48,7 @@ export const GrepTool = Tool.define( pattern: params.pattern, path: params.path, include: params.include, + ...KiloGrep.metadata(params, limit, context), // kilocode_change }, }) @@ -66,7 +71,7 @@ export const GrepTool = Tool.define( file: info?.type === "File" ? path.basename(search) : undefined, // kilocode_change - constrain exact-file searches pattern: params.pattern, include: params.include, - limit: 100, + ...KiloGrep.options(params, limit, context), // kilocode_change signal: ctx.abort, // kilocode_change - stop ripgrep when the tool call is cancelled }) // kilocode_change start @@ -74,18 +79,20 @@ export const GrepTool = Tool.define( if (matches.length === 0) return empty // kilocode_change end - const rows = matches.map((item) => ({ // kilocode_change + const rows = matches.map((item) => ({ + // kilocode_change path: path.resolve(cwd, item.entry.path), line: item.line, text: item.text, + context: item.context, // kilocode_change + textTruncated: item.textTruncated, // kilocode_change })) - const limit = 100 const truncated = result.truncated // kilocode_change const final = rows if (final.length === 0) return empty - const total = rows.length + const total = rows.filter((row) => !row.context).length // kilocode_change const hasMore = truncated // kilocode_change const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`] @@ -96,13 +103,14 @@ export const GrepTool = Tool.define( current = match.path output.push(`${match.path}:`) } - output.push(` Line ${match.line}: ${match.text}`) + output.push(KiloGrep.line(match, context)) // kilocode_change } if (truncated) { output.push("") - output.push("(Results truncated. Consider using a more specific path or pattern.)") + output.push(KiloGrep.limitNotice(limit)) // kilocode_change } + output.push(...KiloGrep.notices(rows)) // kilocode_change if (result.partial) output.push("", "(Some paths were inaccessible.)") // kilocode_change return { diff --git a/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts new file mode 100644 index 00000000000..cc077248a0a --- /dev/null +++ b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts @@ -0,0 +1,128 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Agent } from "../../../src/agent/agent" +import { Git } from "../../../src/git" +import { GrepTool } from "../../../src/tool/grep" +import { Truncate } from "../../../src/tool/truncate" +import { MessageID, SessionID } from "../../../src/session/schema" +import { TestInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const it = testEffect( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + FSUtil.defaultLayer, + Ripgrep.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + Git.defaultLayer, + ), +) + +const ctx = { + sessionID: SessionID.make("ses_grep_signal_controls"), + messageID: MessageID.make("msg_grep_signal_controls"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const file = (test: { readonly directory: string }, name: string) => path.join(test.directory, name) + +const init = Effect.gen(function* () { + const info = yield* GrepTool + return yield* info.init() +}) + +describe("Kilo grep signal-to-noise controls", () => { + it.instance("preserves the default match output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(file(test, "default.txt"), "before\nneedle\nafter\n")) + const grep = yield* init + const result = yield* grep + .execute({ pattern: "needle", path: test.directory }, ctx) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain("Line 2: needle") + expect(result.output).not.toContain("[match]") + }), + ) + + it.instance("executes all signal controls and settles at the custom limit", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => + Bun.write( + file(test, "controls.txt"), + ["regex before", "NEEDLEzzz", "regex after", "before", "NEEDLE.*", "after", "far", "needle.*"].join("\n") + + "\n", + ), + ) + const grep = yield* init + const result = yield* grep + .execute( + { + pattern: "needle.*", + path: test.directory, + include: "*.txt", + context: 1, + limit: 1, + literal: true, + ignoreCase: true, + }, + ctx, + ) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata).toEqual({ matches: 1, truncated: true }) + expect(result.output).toContain("[context] Line 4: before") + expect(result.output).toContain("[match] Line 5: NEEDLE.*") + expect(result.output).toContain("[context] Line 6: after") + expect(result.output).not.toContain("NEEDLEzzz") + expect(result.output).not.toContain("Line 7: far") + expect(result.output).not.toContain("Line 8: needle.*") + expect(result.output).toContain("1 matches limit reached. Use limit=2 for more, or refine pattern.") + }), + ) + + it.instance("does not count context lines toward the match limit", () => + Effect.gen(function* () { + const test = yield* TestInstance + const content = Array.from( + { length: 35 }, + (_, index) => `before-${index}\nneedle-${index}\nafter-${index}\ngap-${index}`, + ).join("\n") + yield* Effect.promise(() => Bun.write(file(test, "context-limit.txt"), `${content}\n`)) + const grep = yield* init + const result = yield* grep + .execute({ pattern: "needle", path: test.directory, context: 1, limit: 100 }, ctx) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata.matches).toBe(35) + expect(result.metadata.truncated).toBe(false) + expect(result.output).toContain("needle-34") + expect(result.output).not.toContain("matches limit reached") + }), + ) + + it.instance("guides the model to read truncated lines", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(file(test, "long.txt"), `${"x".repeat(2_100)}needle\n`)) + const grep = yield* init + const result = yield* grep.execute({ pattern: "needle", path: test.directory }, ctx) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain("Some matching or context lines were truncated. Use read for full lines.") + }), + ) +}) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 368225175b6..a31700b7c0e 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -114,16 +114,37 @@ exports[`tool parameters JSON Schema (wire shape) grep 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "context": { + "description": "Number of context lines to show before and after each match (default 0)", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "ignoreCase": { + "description": "Match without regard to letter case (default false)", + "type": "boolean", + }, "include": { "description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")", "type": "string", }, + "limit": { + "description": "Maximum matching lines to return (default 100)", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer", + }, + "literal": { + "description": "Treat pattern as plain text instead of a regex (default false)", + "type": "boolean", + }, "path": { "description": "The directory to search in. Defaults to the current working directory.", "type": "string", }, "pattern": { - "description": "The regex pattern to search for in file contents", + "description": "Pattern to search for in file contents (regex by default)", "type": "string", }, }, diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 0c0769d4dab..e125349cff5 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -145,7 +145,7 @@ describe("tool.grep", () => { const grep = yield* info.init() const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx) - expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)") + expect(result.output).toContain("100 matches limit reached. Use limit=200 for more, or refine pattern.") // kilocode_change expect(result.output).not.toMatch(/showing \d+ of \d+ matches/) }), ) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 67247b00b8e..1b43d44206b 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -163,6 +163,21 @@ describe("tool parameters", () => { expect(parsed.path).toBe("/tmp") expect(parsed.include).toBe("*.ts") }) + // kilocode_change start - configurable grep signal controls + test("accepts signal controls", () => { + expect(parse(Grep, { pattern: "TODO", context: 0, limit: 1, literal: true, ignoreCase: true })).toMatchObject({ + context: 0, + limit: 1, + literal: true, + ignoreCase: true, + }) + }) + test("rejects invalid signal controls", () => { + expect(accepts(Grep, { pattern: "TODO", context: -1 })).toBe(false) + expect(accepts(Grep, { pattern: "TODO", limit: 0 })).toBe(false) + expect(accepts(Grep, { pattern: "TODO", limit: 1.5 })).toBe(false) + }) + // kilocode_change end test("rejects missing pattern", () => { expect(accepts(Grep, {})).toBe(false) }) From 348db374757768f3e6eb534b323efd2b464906c5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 5 Aug 2026 09:48:01 +0200 Subject: [PATCH 27/78] test(cli): update grep harness for layer nodes --- .../test/kilocode/tool/grep-signal-controls.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts index cc077248a0a..8a634d793ab 100644 --- a/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts +++ b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Agent } from "../../../src/agent/agent" @@ -13,13 +14,8 @@ import { TestInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const it = testEffect( - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), ), ) From c0649f7cb27aabf2cf992aa88eaed132adee91f9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 5 Aug 2026 11:40:43 +0200 Subject: [PATCH 28/78] fix(vscode): avoid macOS git launcher overhead --- .changeset/quick-git-launches.md | 5 + .../src/agent-manager/AgentManagerProvider.ts | 4 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 152 ++++++++++++------ packages/kilo-vscode/src/extension.ts | 6 +- .../kilo-vscode/src/util/git-executable.ts | 70 ++++++++ .../tests/unit/git-executable.test.ts | 142 ++++++++++++++++ .../kilo-vscode/tests/unit/git-ops.test.ts | 47 ++++++ 7 files changed, 375 insertions(+), 51 deletions(-) create mode 100644 .changeset/quick-git-launches.md create mode 100644 packages/kilo-vscode/src/util/git-executable.ts create mode 100644 packages/kilo-vscode/tests/unit/git-executable.test.ts diff --git a/.changeset/quick-git-launches.md b/.changeset/quick-git-launches.md new file mode 100644 index 00000000000..6c29101ce1f --- /dev/null +++ b/.changeset/quick-git-launches.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce Agent Manager Git polling overhead by reusing the validated Git executable and bypassing the macOS developer-tool launcher when safe. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 069783c53fc..f2bf96cb615 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -27,6 +27,7 @@ import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type Work import { PRStatusBridge } from "./pr-status-bridge" import { createPollers, type ProjectPollers } from "./project/pollers" import { GitOps } from "./GitOps" +import type { GitExecutable } from "../util/git-executable" import { versionedName } from "./branch-name" import { BranchNamingController } from "./branch-naming" import { SetupScriptService } from "./SetupScriptService" @@ -122,6 +123,7 @@ export class AgentManagerProvider implements Disposable { constructor( private readonly host: Host, private readonly connectionService: KiloConnectionService, + binary: GitExecutable = () => Promise.resolve("git"), ) { this.outputChannel = host.createOutput("Kilo Agent Manager") this.terminalManager = new SessionTerminalManager( @@ -175,7 +177,7 @@ export class AgentManagerProvider implements Disposable { log: (...args) => this.log(...args), }) const semaphore = new Semaphore(3) - this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore }) + this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore, binary }) const wiring = createProjectWiring({ host: this.host, git: this.gitOps, diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index ea26906b893..d4be3b7ca6f 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -2,6 +2,7 @@ import * as nodePath from "path" import * as os from "os" import * as fs from "fs/promises" import { spawn } from "../util/process" +import type { GitExecutable } from "../util/git-executable" import simpleGit from "simple-git" import { parseWorktreeList, @@ -18,6 +19,8 @@ interface GitOpsOptions { runGit?: (args: string[], cwd: string) => Promise /** Shared concurrency gate for child process spawning. */ semaphore?: Semaphore + /** Validated Git executable shared by Agent Manager operations. */ + binary?: GitExecutable } export interface ApplyConflict { @@ -96,6 +99,9 @@ export class GitOps { private readonly runGit: (args: string[], cwd: string) => Promise private readonly controller = new AbortController() private readonly semaphore: Semaphore | undefined + private readonly binary: GitExecutable + private readonly injected: boolean + private executableCache: Promise | undefined private readonly resolutionCache = new Map() private static readonly CACHE_TTL_MS = 60000 private static readonly MAX_CACHE_SIZE = 100 @@ -107,12 +113,19 @@ export class GitOps { constructor(options: GitOpsOptions) { this.log = options.log this.semaphore = options.semaphore + this.binary = options.binary ?? (() => Promise.resolve("git")) + this.injected = options.runGit !== undefined this.runGit = options.runGit ?? - ((args, cwd) => - simpleGit(cwd, { abort: this.controller.signal }) + (async (args, cwd) => { + const binary = await this.executable() + return simpleGit(cwd, { + abort: this.controller.signal, + binary, + }) .raw(args) - .then((out) => out.trim())) + .then((out) => out.trim()) + }) } dispose(): void { @@ -148,22 +161,28 @@ export class GitOps { private raw(args: string[], cwd: string): Promise { const signal = this.controller.signal if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) - const invoke = () => - new Promise((resolve, reject) => { - const onAbort = () => reject(new Error("GitOps disposed")) - signal.addEventListener("abort", onAbort, { once: true }) - this.runGit(args, cwd).then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (err) => { - signal.removeEventListener("abort", onAbort) - reject(err) - }, - ) - }) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + return this.executable().then(() => { + if (signal.aborted) throw new Error("GitOps disposed") + const invoke = () => { + const pending = this.runGit(args, cwd) + if (!this.injected) return pending + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + pending.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) + } + return this.semaphore ? this.semaphore.run(invoke) : invoke() + }) } /** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */ @@ -551,43 +570,78 @@ export class GitOps { return { code: result.code, stdout: result.stdout.toString("utf8"), stderr: result.stderr } } - private execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise { + private async execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise { + if (this.controller.signal.aborted) { + return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } + } + const cmd = await this.executable().catch(() => undefined) + if (!cmd || this.controller.signal.aborted) { + return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } + } + const invoke = () => this.invoke(cmd, args, cwd, options) + return this.semaphore ? this.semaphore.run(invoke) : invoke() + } + + private executable(): Promise { + const signal = this.controller.signal + if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) + + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + this.executableCache ??= Promise.resolve().then(() => this.binary()) + this.executableCache.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) + } + + private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise { if (this.controller.signal.aborted) { return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }) } - const invoke = () => - new Promise((resolve) => { - const child = spawn("git", args, { - cwd, - env: options?.env, - signal: this.controller.signal, - stdio: ["pipe", "pipe", "pipe"], - }) - if (options?.stdin !== undefined) { - if (!child.stdin) { - resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "stdin not available for git process" }) - return - } - child.stdin.end(options.stdin) - } + return new Promise((resolve) => { + const child = spawn(cmd, args, { + cwd, + env: options?.env, + stdio: ["pipe", "pipe", "pipe"], + }) + const out: Buffer[] = [] + const err: Buffer[] = [] + let failure: string | undefined + const abort = () => child.kill("SIGINT") - const out: Buffer[] = [] - const err: Buffer[] = [] - child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) - child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) + this.controller.signal.addEventListener("abort", abort, { once: true }) + child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) + child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) - child.on("error", (error) => { - resolve({ code: 1, stdout: Buffer.alloc(0), stderr: error.message }) - }) - child.on("close", (code) => { - resolve({ - code: code ?? 1, - stdout: Buffer.concat(out), - stderr: Buffer.concat(err).toString("utf8"), - }) + child.on("error", (error) => { + failure = error.message + }) + child.on("close", (code) => { + this.controller.signal.removeEventListener("abort", abort) + resolve({ + code: code ?? 1, + stdout: Buffer.concat(out), + stderr: failure ?? Buffer.concat(err).toString("utf8"), }) }) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + + if (options?.stdin === undefined) return + if (child.stdin) { + child.stdin.end(options.stdin) + return + } + failure = "stdin not available for git process" + child.kill("SIGINT") + }) } } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b1549e3c479..20f842e324b 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -25,6 +25,7 @@ import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" import { createNotebookBridge } from "./services/notebook" +import { createGitExecutable } from "./util/git-executable" let agentManager: AgentManagerProvider | undefined let shuttingDown = false @@ -149,7 +150,10 @@ export function activate(context: vscode.ExtensionContext) { // Create Agent Manager provider for editor panel const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService) - const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService) + const git = createGitExecutable({ + log: (message) => console.warn(`[Kilo New] ${message}`), + }) + const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git) agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) agentManager = agentManagerProvider context.subscriptions.push(agentManagerProvider) diff --git a/packages/kilo-vscode/src/util/git-executable.ts b/packages/kilo-vscode/src/util/git-executable.ts new file mode 100644 index 00000000000..a4fa64f377b --- /dev/null +++ b/packages/kilo-vscode/src/util/git-executable.ts @@ -0,0 +1,70 @@ +import { constants } from "fs" +import * as fs from "fs/promises" +import * as path from "path" +import { exec } from "./process" + +export type GitExecutable = () => Promise + +interface GitExecutableOptions { + platform?: NodeJS.Platform + path?: string + run?: (cmd: string, args: string[]) => Promise<{ stdout: string }> + access?: (file: string, mode: number) => Promise + realpath?: (file: string) => Promise + log?: (message: string) => void +} + +/** + * Preserve normal PATH lookup on every platform. On macOS only, bypass Apple's + * /usr/bin/git launcher after confirming it is the command PATH would select and + * xcrun identifies a valid executable for the active developer directory. + */ +export function createGitExecutable(options: GitExecutableOptions = {}): GitExecutable { + const platform = options.platform ?? process.platform + const run = options.run ?? ((cmd, args) => exec(cmd, args, { timeout: 15_000 })) + const access = options.access ?? fs.access + const realpath = options.realpath ?? fs.realpath + const log = options.log ?? (() => undefined) + let cached: Promise | undefined + + return (): Promise => { + cached ??= (async () => { + if (platform !== "darwin") return "git" + + try { + const env = options.path ?? process.env.PATH ?? "/usr/bin:/bin" + const selected = await (async () => { + for (const dir of env.split(path.posix.delimiter)) { + // Relative and empty PATH entries depend on each command's cwd, so + // they cannot be resolved once without changing lookup semantics. + if (!dir || !path.posix.isAbsolute(dir)) return undefined + const file = path.posix.join(dir, "git") + const resolved = await access(file, constants.X_OK) + .then(() => realpath(file)) + .catch(() => undefined) + if (resolved) return resolved + } + return undefined + })() + if (selected !== "/usr/bin/git") return "git" + + const result = await run("/usr/bin/xcrun", ["--find", "git"]) + const candidate = result.stdout.trim() + if (!candidate || candidate === selected || !path.posix.isAbsolute(candidate)) return "git" + if (!/^[/a-zA-Z0-9._~-]+$/.test(candidate)) return "git" + + await access(candidate, constants.X_OK) + const version = await run(candidate, ["--version"]) + if (!version.stdout.trim().startsWith("git version ")) return "git" + + log(`Using ${candidate} directly instead of the macOS Git launcher`) + return candidate + } catch (err) { + log(`Unable to bypass the macOS Git launcher, using PATH: ${err}`) + return "git" + } + })() + + return cached + } +} diff --git a/packages/kilo-vscode/tests/unit/git-executable.test.ts b/packages/kilo-vscode/tests/unit/git-executable.test.ts new file mode 100644 index 00000000000..f6850a391db --- /dev/null +++ b/packages/kilo-vscode/tests/unit/git-executable.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test" +import { createGitExecutable } from "../../src/util/git-executable" + +describe("createGitExecutable", () => { + it("preserves PATH lookup on other platforms", async () => { + const git = createGitExecutable({ + platform: "linux", + run: async () => { + throw new Error("should not run") + }, + }) + + expect(await git()).toBe("git") + }) + + it("resolves and validates the real macOS Git executable", async () => { + const calls: string[] = [] + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin:/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd, args) => { + calls.push([cmd, ...args].join(" ")) + if (cmd === "/usr/bin/xcrun") return { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" } + return { stdout: "git version 2.50.1\n" } + }, + }) + + expect(await git()).toBe("/Library/Developer/CommandLineTools/usr/bin/git") + expect(calls).toEqual(["/usr/bin/xcrun --find git", "/Library/Developer/CommandLineTools/usr/bin/git --version"]) + }) + + it("falls back to the macOS launcher when resolution fails", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => { + throw new Error("xcrun failed") + }, + }) + + expect(await git()).toBe("git") + }) + + it("rejects a resolved command that is not Git", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd) => + cmd === "/usr/bin/xcrun" ? { stdout: "/tmp/not-git\n" } : { stdout: "unexpected command\n" }, + }) + + expect(await git()).toBe("git") + }) + + it("does not override a non-Apple Git selected by PATH", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "/opt/homebrew/bin:/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps per-command lookup for relative PATH entries", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "./bin:/usr/bin", + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps per-command lookup for empty PATH entries", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: ":/usr/bin", + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps PATH lookup when the developer directory contains unsafe path characters", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => ({ stdout: "/Applications/Xcode Beta.app/Contents/Developer/usr/bin/git\n" }), + }) + + expect(await git()).toBe("git") + }) + + it("shares one resolution across concurrent callers", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd) => { + calls++ + return cmd === "/usr/bin/xcrun" + ? { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" } + : { stdout: "git version 2.50.1\n" } + }, + }) + + expect(await Promise.all([git(), git(), git()])).toEqual([ + "/Library/Developer/CommandLineTools/usr/bin/git", + "/Library/Developer/CommandLineTools/usr/bin/git", + "/Library/Developer/CommandLineTools/usr/bin/git", + ]) + expect(calls).toBe(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index 563620705d5..b840e119e4f 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -41,6 +41,53 @@ async function withRepo(run: (cwd: string) => Promise): Promise { } describe("GitOps", () => { + it("uses the configured Git executable for raw commands", async () => { + await withRepo(async (cwd) => { + let calls = 0 + const git = new GitOps({ + log: () => undefined, + binary: async () => { + calls++ + return "git" + }, + }) + + expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd)) + expect(calls).toBe(1) + }) + }) + + it("does not hold a semaphore slot while resolving Git", async () => { + const semaphore = new Semaphore(1) + let resolve!: (value: string) => void + const binary = new Promise((done) => { + resolve = done + }) + const git = new GitOps({ log: () => undefined, semaphore, binary: () => binary }) + const pending = git.currentBranch("/repo") + let entered = false + + await semaphore.run(async () => { + entered = true + }) + resolve("git") + await pending + + expect(entered).toBe(true) + }) + + it("stops waiting for Git resolution when disposed", async () => { + const git = new GitOps({ + log: () => undefined, + binary: () => new Promise(() => undefined), + }) + const pending = git.currentBranch("/repo") + + git.dispose() + + expect(await pending).toBe("") + }) + describe("currentBranch", () => { it("returns the current branch name", async () => { const git = ops(async (args) => { From 7382d3a5eedd9ab589e5a7e6406adbb243119acd Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:44:28 +0000 Subject: [PATCH 29/78] docs: consolidate Auto Balanced into Auto Efficient Auto Balanced is being retired as a separate tier; its behavior and positioning are absorbed into Auto Efficient. Updates: - packages/kilo-docs/pages/code-with-ai/agents/auto-model.md: drop the Balanced tier row/bullet, fold its fallback-baseline behavior into Efficient's description - packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md: remove the Balanced tier row and rewrite the Balanced-vs-Efficient comparison section as an Efficient-only explainer - packages/kilo-docs/pages/gateway/models-and-providers.md: rename the kilo-auto/balanced routing section to kilo-auto/efficient, update the tier-routing summary and curl example - packages/kilo-docs/pages/deploy-secure/security-reviews.md: rename "Kilo Balanced" default models to "Kilo Efficient" - packages/kilo-docs/pages/code-with-ai/gastown/settings.md and troubleshooting.md: rename Auto Balanced references to Auto Efficient - packages/kilo-docs/pages/kiloclaw/overview.md and end-to-end.md: rename Auto Balanced / Balanced references to Auto Efficient --- .../pages/code-with-ai/agents/auto-model.md | 10 ++++------ .../pages/code-with-ai/gastown/settings.md | 2 +- .../pages/code-with-ai/gastown/troubleshooting.md | 2 +- .../pages/deploy-secure/security-reviews.md | 8 ++++---- .../pages/gateway/models-and-providers.md | 8 ++++---- .../getting-started/rate-limits-and-costs.md | 15 ++++----------- packages/kilo-docs/pages/kiloclaw/end-to-end.md | 2 +- packages/kilo-docs/pages/kiloclaw/overview.md | 2 +- 8 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index a287cf094c6..d09392976dc 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -10,7 +10,6 @@ Auto Model is a smart routing system that selects an underlying model for each r | Tier | Best For | Pricing | |---|---|---| | `kilo-auto/frontier` | Maximum capability with the best available models | Paid | -| `kilo-auto/balanced` | Strong performance at a lower cost | Paid | | `kilo-auto/efficient` | Lowest cost per task, with capability matched to difficulty | Paid | | `kilo-auto/free` | The best free models available | Free | @@ -31,8 +30,7 @@ The underlying models behind each Auto Model tier are updated server-side as bet ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. -- **Balanced** — Routes to a cost-effective model for all modes. The specific model is selected based on the API interface in use, but does not vary by mode. A good default for most developers who want strong AI assistance without paying frontier prices. -- **Efficient** — Session-aware routing that classifies the difficulty of each request in real time and routes it to the cheapest model proven accurate enough for that task, based on Kilo's continuously-run benchmarks. Routine work stays lean while harder tasks get a more capable model. Because it watches your session in context, it keeps using a model across related turns and only switches when a cheaper option is clearly worth it. If a routing decision can't be made, it falls back to the Balanced tier, so quality never drops below Balanced. +- **Efficient** — Session-aware routing that classifies the difficulty of each request in real time and routes it to the cheapest model proven accurate enough for that task, based on Kilo's continuously-run benchmarks. Routine work stays lean while harder tasks get a more capable model. Because it watches your session in context, it keeps using a model across related turns and only switches when a cheaper option is clearly worth it. If a routing decision can't be made, it falls back to a fixed, cost-effective baseline model, so quality never drops below that baseline. A good default for most developers who want strong AI assistance without paying frontier prices. - **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. ### How Auto Efficient routing works @@ -61,7 +59,7 @@ New entries are benchmarked on demand before they can serve traffic. Each entry | Failed | Benchmarking failed — retry the entry | | Unavailable | The model or variant is no longer in your catalog — remove the entry | -Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to the Balanced tier, so quality never drops below Balanced. +Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to a fixed, cost-effective baseline model, so quality never drops below that baseline. {% callout type="note" %} You can benchmark up to 10 new or retried pairs per owner per rolling 24 hours. Entries that are already ready or benchmarking don't count against this limit. @@ -85,7 +83,7 @@ No need to manually switch models when changing modes. Auto Model handles routin ### Flexible Cost Control -Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Balanced offers capable models at a fraction of the cost; Efficient minimizes cost per task by matching model capability to task difficulty; Free costs nothing. +Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Efficient minimizes cost per task by matching model capability to task difficulty, at a fraction of Frontier's cost; Free costs nothing. ## Requirements @@ -101,7 +99,7 @@ Select an Auto Model tier from the model dropdown in the Kilo Code chat interfac 1. Open Kilo Code in VS Code or JetBrains 2. Click the model selector dropdown -3. Choose an Auto Model such as `kilo-auto/frontier` or `kilo-auto/balanced` +3. Choose an Auto Model such as `kilo-auto/frontier` or `kilo-auto/efficient` 4. Start chatting - the right model is selected automatically based on your current mode ## When to Use Auto Model diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md index d95fa6bf147..7512e72e6d6 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md @@ -17,7 +17,7 @@ The primary model used by all agents (polecats, refinery, mayor). This affects q Popular choices: - **Kilo Auto Frontier** — highest quality models, best results (recommended) -- **Kilo Auto Balanced** — good balance of quality and cost (minimum for Gas Town) +- **Kilo Auto Efficient** — cheapest model proven accurate enough for each task, with capability matched to difficulty (minimum for Gas Town) ### Role-Specific Models diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md index 62067e340f1..63b3fcc7880 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md @@ -124,7 +124,7 @@ Beads automatically escalate after 3 failed review cycles. If a bead is genuinel **Fix:** 1. Review failed bead descriptions — make them more specific 2. Ensure the repo builds cleanly (agents struggle with pre-existing broken builds) -3. Consider upgrading the model (Auto Balanced → Auto Frontier for complex work) +3. Consider upgrading the model (Auto Efficient → Auto Frontier for complex work) 4. Add custom instructions to guide agents: test commands, build steps, conventions ## Getting Help diff --git a/packages/kilo-docs/pages/deploy-secure/security-reviews.md b/packages/kilo-docs/pages/deploy-secure/security-reviews.md index 68d6677aa08..22c25c8fdc7 100644 --- a/packages/kilo-docs/pages/deploy-secure/security-reviews.md +++ b/packages/kilo-docs/pages/deploy-secure/security-reviews.md @@ -243,9 +243,9 @@ General settings include: |---|---|---| | Security Agent enabled | Off until you turn it on | Turning it on queues an initial sync for the selected repository scope. | | Repository selection | Selected repositories during setup | Choose all accessible repositories or selected repositories. | -| Triage model | Kilo Balanced | Used for initial triage and exploitability recommendations. | -| Analysis model | Kilo Balanced | Used for sandbox analysis and result extraction. | -| Remediation model | Kilo Balanced | Used by Cloud Agent for remediation PR work. | +| Triage model | Kilo Efficient | Used for initial triage and exploitability recommendations. | +| Analysis model | Kilo Efficient | Used for sandbox analysis and result extraction. | +| Remediation model | Kilo Efficient | Used by Cloud Agent for remediation PR work. | | Analysis mode | Auto | Auto, Shallow, or Deep. | #### Turn Security Agent on or off @@ -271,7 +271,7 @@ Security Agent uses a separate model for each stage: - The Analysis model runs sandbox analysis and extracts the result. - The Remediation model is used by Cloud Agent to prepare remediation pull requests. -Kilo Balanced is the default for all three stages. You can change each model independently. The model recorded in finding details is the model used when that analysis or remediation attempt ran. AI triage, sandbox analysis, and remediation consume Kilo Code credits. +Kilo Efficient is the default for all three stages. You can change each model independently. The model recorded in finding details is the model used when that analysis or remediation attempt ran. AI triage, sandbox analysis, and remediation consume Kilo Code credits. #### Choose an analysis mode diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 922706b984b..b7a78df9ca0 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -73,7 +73,7 @@ For NVIDIA free endpoints (Super/Ultra/etc): Trial use only - do not submit pers ## Auto models -Auto virtual models select an underlying model using tier-specific routing. Frontier uses the `x-kilocode-mode` request header. Balanced uses the API interface, Free uses deterministic affinity across available candidates, and Small uses account balance. +Auto virtual models select an underlying model using tier-specific routing. Frontier uses the `x-kilocode-mode` request header. Efficient classifies task difficulty in session context and falls back to the API interface for its baseline model, Free uses deterministic affinity across available candidates, and Small uses account balance. {% callout type="info" title="Underlying models can change" %} The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable. @@ -89,9 +89,9 @@ Highest performance and capability for any task. Frontier requests are sent with | `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | | Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | -### `kilo-auto/balanced` +### `kilo-auto/efficient` -Great balance of price and capability. The resolved model depends on the API interface used by the client. +Session-aware routing that classifies each request by difficulty and routes to the cheapest model proven accurate enough for the task. When no confident routing decision can be made, requests fall back to a baseline model resolved by the API interface used by the client. | API interface | Resolved Model | Reasoning effort | |---|---|---| @@ -132,5 +132,5 @@ curl -X POST "https://api.kilo.ai/api/gateway/chat/completions" \ -H "Authorization: Bearer $KILO_API_KEY" \ -H "x-kilocode-mode: plan" \ -H "Content-Type: application/json" \ - -d '{"model": "kilo-auto/balanced", "messages": [{"role": "user", "content": "Design a database schema"}]}' + -d '{"model": "kilo-auto/efficient", "messages": [{"role": "user", "content": "Design a database schema"}]}' ``` diff --git a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md index 8b49b32ea69..9850e877655 100644 --- a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md +++ b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md @@ -14,7 +14,6 @@ Auto Model is Kilo's smart routing system. Instead of selecting a specific provi | Tier | Name | Best For | Cost | |---|---|---|---| | `kilo-auto/frontier` | Auto Frontier | Maximum capability — routes to top-tier models for planning/architect/debug and high-quality models for coding | Paid (highest) | -| `kilo-auto/balanced` | Auto Balanced | Strong performance at a predictably lower cost — routes every request to one fixed high-quality model | Paid | | `kilo-auto/efficient` | Auto Efficient | Lowest cost per task — classifies each request by difficulty and routes to the cheapest benchmark-proven model for that task | Paid (lowest) | | `kilo-auto/free` | Auto Free | No credits required — rotates through available free models | Free | @@ -22,20 +21,14 @@ Auto Model is Kilo's smart routing system. Instead of selecting a specific provi The underlying models behind each tier are updated server-side as better options become available or as providers change pricing. See [kilo.ai/models](https://kilo.ai/models) for current model assignments and live pricing. {% /callout %} -## Balanced vs Efficient — What's the Difference? +## What Makes Auto Efficient Efficient? -Both tiers are paid, but they optimize for different things. +**Auto Efficient** observes your coding session in context, classifies the difficulty of each request in real time, and routes it to the *cheapest model proven accurate enough* for that specific task, based on Kilo's continuously running benchmarks. Routine tasks (small edits, lookups, quick explanations) are handled by leaner models; harder tasks (architecture, debugging, complex refactors) automatically get a more capable model. -**Auto Balanced** routes every request to a single, fixed high-quality model. You get consistent, strong results with predictable cost — a reliable default for most developers. - -**Auto Efficient** goes further. It observes your coding session in context, classifies the difficulty of each request in real time, and routes it to the *cheapest model proven accurate enough* for that specific task, based on Kilo's continuously running benchmarks. Routine tasks (small edits, lookups, quick explanations) are handled by leaner models; harder tasks (architecture, debugging, complex refactors) automatically get a more capable model. - -Efficient is also session-aware: it stays with a model across related turns and only switches when a cheaper option is clearly worth it. If it cannot make a routing decision with confidence, it falls back to Balanced — so quality never drops below Balanced. - -Think of Efficient as Balanced with an intelligent cost optimizer layered on top. +Efficient is also session-aware: it stays with a model across related turns and only switches when a cheaper option is clearly worth it. If it cannot make a routing decision with confidence, it falls back to a fixed, high-quality baseline model — so quality never drops below that baseline. You get consistent, strong results with predictable cost, plus an intelligent cost optimizer layered on top. {% callout type="tip" %} -For everyday coding tasks, start with **Auto Efficient** or **Auto Balanced**. Switch to **Auto Frontier** for complex architecture sessions or deep debugging where maximum capability matters. +For everyday coding tasks, start with **Auto Efficient**. Switch to **Auto Frontier** for complex architecture sessions or deep debugging where maximum capability matters. {% /callout %} ## How to Switch Auto Models diff --git a/packages/kilo-docs/pages/kiloclaw/end-to-end.md b/packages/kilo-docs/pages/kiloclaw/end-to-end.md index 2ff8e011726..901f76dab82 100644 --- a/packages/kilo-docs/pages/kiloclaw/end-to-end.md +++ b/packages/kilo-docs/pages/kiloclaw/end-to-end.md @@ -158,6 +158,6 @@ Or ask your Claw to build a custom skill from scratch — it has a built-in skil ## Manage inference -**Model picker:** Balanced is a good starting point. Frontier is more capable but significantly more expensive. +**Model picker:** Efficient is a good starting point. Frontier is more capable but significantly more expensive. You can also use your [Kilo Pass](https://kilo.ai/pricing/kilo-pass) credits — find this under **Profile** in the dashboard. diff --git a/packages/kilo-docs/pages/kiloclaw/overview.md b/packages/kilo-docs/pages/kiloclaw/overview.md index a55e43395fa..8af8b3ef184 100644 --- a/packages/kilo-docs/pages/kiloclaw/overview.md +++ b/packages/kilo-docs/pages/kiloclaw/overview.md @@ -37,7 +37,7 @@ Depending on your setup, you can also use: {% image src="/docs/img/kiloclaw/profile-claw-nav.png" alt="Profile page showing Claw navigation" width="400" caption="Claw navigation in profile sidebar" /%} 3. Click **Create Instance** -4. Your instance will use **Kilo Auto Balanced** as the default model. You can optionally select a different model from the dropdown — see all available models at the [Kilo Leaderboard](https://kilo.ai/leaderboard#all-models). +4. Your instance will use **Kilo Auto Efficient** as the default model. You can optionally select a different model from the dropdown — see all available models at the [Kilo Leaderboard](https://kilo.ai/leaderboard#all-models). {% image src="/docs/img/kiloclaw/create-instance.png" alt="Create instance modal with model selection" width="600" caption="Model selection during instance creation" /%} From 450b8a2242887aef22c8eac557509b8eee77fec2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 09:20:19 -0400 Subject: [PATCH 30/78] chore: sync bun.lock kilo-jetbrains version with package.json Lockfile still listed 7.4.17; package.json is pinned to 7.4.20. --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 1a09d995841..0f1a05b52c8 100644 --- a/bun.lock +++ b/bun.lock @@ -345,7 +345,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.17", + "version": "7.4.20", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", From 76f8967dc19a5f4f48726ad401fbd3142fd17750 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 5 Aug 2026 07:31:50 -0600 Subject: [PATCH 31/78] 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 b8ffd081479..5ad85cf9747 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 b7113eb5b80..994a1b47a11 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 06706bc96be..13860c0d0b4 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 5af4d6ee603..d7b0a709f05 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 7f0e66c5e7b..54cea5055e1 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/78] 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 00000000000..bc9cf774d13 --- /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 9425f0779f7..580d226c33d 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 bee74c12bb4..11cb6754ffe 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 8138ae4daed..617e805ba76 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 58e929dcd7e..2fe3edca360 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 8c489813a65..477f001849f 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 1bad925ec0c..6ed21bcb695 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 23443265e1b..1751383899a 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 faf0e7b7828..84f9423db3b 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 477a78333ca..9d4689d514b 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 7c7d4a9affe..ba9ce1d3bca 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 e05b4c3bdf1..2322ce61640 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/78] 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 ecd59d67da5..2fc5b06ef58 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 ae293b6b1ff..a7223d3cbad 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 e09355e19f2..bcba0effb4c 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 5482dfbd492..1c1181c34d1 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/78] 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 d6555546ee2..e5d5a002c10 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 cd7e51379bc..0b1f063f759 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 38e5b51d77c..e257e62d971 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 7eeaa951500..beee188bd70 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 14f7b8c0271..1d918c70bcd 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 77fb712763a..831bfb6ea19 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 a210429af0d..2479977f632 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 20043447cba..39779464ca9 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 c481716553c..84e055664bc 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/78] 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 00000000000..3e07e6c6981 --- /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 6553d3e1058..fd88764e24e 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 e3861069e97..cf8f7b5386b 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/78] 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 df1deffa15f..529fce727e5 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 865cfa1ac28..a07800555a4 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 304457a8ddd..ca711b30aa3 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 a8b1d5e9b61..9f099096e93 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 9f6520b370a..415e1522157 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/78] 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 9ac86fcbf9d..00000000000 --- 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 d028a830280..a2b6331bbba 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 16b65019b96..76077daeabb 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/78] 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 15944a645df..a3ad22de392 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 df5882b5000..f865d877598 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 65d9d0909cd..aa558608cf1 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 9b0ca266323..0aaaa9b1063 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 0d19e12cfd9..9ad869fc591 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 683bf55d439..dfe14978821 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 e9e1c52f3cb..1c8e84104d9 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 c2e935a4ed7..5594fdf3199 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 e43ba741bd8..d96e7241085 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 d391bcc32a3..57502a00bea 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 ef4c5efe137..4b9bf11f462 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 2bf9c4c4cce..08de02c275d 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 aef654244b3..90a13ed0ed9 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 8959598aa7a..ee10c59c9aa 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 b954188a5ca..15c992fac3f 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 77ea64c7752..b69ddc88618 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 909073cae3e..f16bb2266b8 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 bc74d81c93a..6eec893d1af 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/78] 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 2fc5b06ef58..f04217edecf 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 a7223d3cbad..a57676806d1 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/78] 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 00000000000..e2ec3c49969 --- /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 a2b6331bbba..f7f5f3617d6 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 76077daeabb..b61398ca28b 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 dfcbec1f1c77e004681a2ff2201402a2f54dc59b Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Wed, 5 Aug 2026 23:05:56 +0000 Subject: [PATCH 41/78] 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 d000b8245ae..9f6199b7fd6 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 22bcfa17240..2209c5aed7f 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 da5fc5136cbe7b22e06f0de03300006a01659adb Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Wed, 5 Aug 2026 19:14:08 -0400 Subject: [PATCH 42/78] 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 9f6199b7fd6..cf4296bc8a9 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 43/78] 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 cf4296bc8a9..780a4f616c2 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 2209c5aed7f..7db9a13fbb7 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 44/78] 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 780a4f616c2..8f50db751a9 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 45/78] 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 00000000000..5f3314267ab --- /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 c04d818b4b8..035b090ad65 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 069783c53fc..6844149ec3b 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 9b5fd45ef5a..11e513f32c5 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 00000000000..d9ef5ae0349 --- /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 04bc8f023b6..8a9ec9b3ca8 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 c62663414fe..cf161d03150 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 00000000000..9d68d512cf2 --- /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 00000000000..10ca489e244 --- /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 00000000000..3867fb0b67e --- /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 46/78] 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 00000000000..7623cf0e739 --- /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 24f916542e5..998c3d6cdfd 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 348a632a48e..7ad1005e097 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 47/78] 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 d4be3b7ca6f..d33799e0c06 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 b840e119e4f..efb1fd98798 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 48/78] 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 998c3d6cdfd..40b98a58e14 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 7ad1005e097..709a704b8f6 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 49/78] 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 cb5f41d2896..58fb29912ee 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 50/78] 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 6844149ec3b..29fff5851ea 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 d9ef5ae0349..bfe2f02b8ed 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 8a9ec9b3ca8..56384b72063 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 9d68d512cf2..ef53d5300ed 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 3867fb0b67e..33429eff680 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 51/78] 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 00000000000..a1ac64ae30e --- /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 23275d040a3..50c6971dcfe 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 f3875fe1e7a..f0631cef6c3 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 52/78] 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 00000000000..f13128f7b9c --- /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 3a3a08869b3..b8629048b7e 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 53/78] 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 b8629048b7e..bb216b39eec 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 54/78] 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 00000000000..8c21c818bb9 --- /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 e7d6d333f22..17d9b1d60f0 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 22909b26fe2..26bbc56ca5b 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 fa9974996ac9f8d0bd37a62ecb339c0b524e4a26 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 10:33:19 +0200 Subject: [PATCH 55/78] feat(agent-manager): target new worktrees by project --- .changeset/bright-project-picker.md | 5 + ...t-manager-new-worktree-project-selector.md | 834 ++++++++++++++++++ .../src/agent-manager/AgentManagerProvider.ts | 7 +- .../kilo-vscode/src/agent-manager/types.ts | 1 + .../src/agent-manager/worktree-importer.ts | 36 +- .../tests/unit/agent-manager-arch.test.ts | 3 +- ...agent-manager-new-worktree-project.test.ts | 49 + .../agent-manager/AgentManagerApp.tsx | 34 +- .../agent-manager/NewWorktreeDialog.tsx | 115 ++- .../webview-ui/agent-manager/ProjectList.tsx | 8 +- .../agent-manager/ProjectSelect.tsx | 54 ++ .../agent-manager/agent-manager.css | 83 ++ .../webview-ui/agent-manager/i18n/ar.ts | 3 + .../webview-ui/agent-manager/i18n/br.ts | 3 + .../webview-ui/agent-manager/i18n/bs.ts | 3 + .../webview-ui/agent-manager/i18n/da.ts | 3 + .../webview-ui/agent-manager/i18n/de.ts | 3 + .../webview-ui/agent-manager/i18n/en.ts | 3 + .../webview-ui/agent-manager/i18n/es.ts | 3 + .../webview-ui/agent-manager/i18n/fa.ts | 3 + .../webview-ui/agent-manager/i18n/fr.ts | 3 + .../webview-ui/agent-manager/i18n/it.ts | 3 + .../webview-ui/agent-manager/i18n/ja.ts | 3 + .../webview-ui/agent-manager/i18n/ko.ts | 3 + .../webview-ui/agent-manager/i18n/nl.ts | 3 + .../webview-ui/agent-manager/i18n/no.ts | 3 + .../webview-ui/agent-manager/i18n/pl.ts | 3 + .../webview-ui/agent-manager/i18n/ru.ts | 3 + .../webview-ui/agent-manager/i18n/th.ts | 3 + .../webview-ui/agent-manager/i18n/tr.ts | 3 + .../webview-ui/agent-manager/i18n/uk.ts | 3 + .../webview-ui/agent-manager/i18n/zh.ts | 3 + .../webview-ui/agent-manager/i18n/zht.ts | 3 + .../webview-ui/src/hooks/useSlashCommand.ts | 2 + .../src/types/messages/extension-messages.ts | 1 + 35 files changed, 1261 insertions(+), 34 deletions(-) create mode 100644 .changeset/bright-project-picker.md create mode 100644 .kilo/plans/agent-manager-new-worktree-project-selector.md create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ProjectSelect.tsx diff --git a/.changeset/bright-project-picker.md b/.changeset/bright-project-picker.md new file mode 100644 index 00000000000..18458aaa856 --- /dev/null +++ b/.changeset/bright-project-picker.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Let Agent Manager users choose the repository when creating or importing a worktree in multi-project mode. diff --git a/.kilo/plans/agent-manager-new-worktree-project-selector.md b/.kilo/plans/agent-manager-new-worktree-project-selector.md new file mode 100644 index 00000000000..ed6ed73d1d9 --- /dev/null +++ b/.kilo/plans/agent-manager-new-worktree-project-selector.md @@ -0,0 +1,834 @@ +# Agent Manager — New Worktree Project Selector + +Status: implemented 2026-08-06. The implementation is uncommitted. + +This is Slice 4 item 6 ("Add project-aware New Worktree targeting") from +`agent-manager-multi-project-uniform-ui.md`, the last unimplemented item of that slice. +`agent-manager-multi-project-runtime.md:29` deferred it out of the backend-first scope. +Everything the extension side needs already exists; this is almost entirely a webview +change. + +Implementation notes: + +- The project catalog is passed into the dialog as an accessor so the picker reflects + registry changes while it remains open. +- The per-project default-base resolver returns `undefined` when the project has no + configured/local branch, allowing the backend-detected branch response to remain the + fallback instead of being replaced by a hardcoded `main`. +- Branch, import-result, and worktree-ready messages carry `projectId` in multi-project + mode, which makes fast project changes and cross-project creation activation safe. +- The slash-command hook accepts optional caller-owned commands; `/project` is scoped to + this dialog and is hidden when multi-project mode is unavailable. + +--- + +## Problem + +With `kilo-code.new.experimental.multiProject` enabled, the New Worktree dialog has no +notion of which repository it targets, and the user cannot see or change it. + +`Cmd+N` opens the dialog with no project at all: + +```tsx +// AgentManagerApp.tsx:1870-1876 +const showNewWorktreeDialog = () => { + if (!loaded()) return + expandSidebar() + dialog.show(() => ( + dialog.close()} defaultBaseBranch={repoDefaultBranch()} /> + )) +} +``` + +`projectId` is `undefined`, so every message the dialog sends omits it +(`agentManager.requestBranches` at `NewWorktreeDialog.tsx:321`, +`agentManager.createMultiVersion` at `:373`, `agentManager.importFromPR` at `:566`, +`agentManager.importFromBranch` at `:575`). The extension then silently falls back to the +active project in `messageProject()` (`AgentManagerProvider.ts:474`) before running the +message inside `ProjectScope`. + +The result is correct but opaque: + +- The dialog never shows which repository the worktree lands in. +- The only way to target a specific project is the per-project `+` button + (`ProjectList.tsx:136-146`), which does pass `projectId` explicitly. +- `defaultBaseBranch` is resolved from the *active* project + (`AgentManagerApp.tsx:261,272`), so even Advanced options' base-branch list and default + badge are implicitly single-project. + +The desired behavior, per the original request: the dialog should show the assigned +project and let the user change it. Defaulting to the last selected project is fine as a +default; it just must be visible and overridable. + +--- + +## Design decisions + +### Placement: inline with the tab switcher + +The selector renders inside the New/Import pill row (`NewWorktreeDialog.tsx:620-699`), +right-aligned with a constrained width, so it is shared by both tabs without adding a +full-width form row. + +``` +┌─ New Worktree ──────────────────────────────┐ +│ [ New ] [ Import ] [ folder kilocode ▾ ] │ ← inline, multiProject only +│ [ Worktree name (optional) ] │ +│ [ prompt … ] Code ▾ GPT-5.6 ▾ None ▾ │ +│ › Advanced options │ +│ VERSIONS 1 2 3 4 ⧉ Compare Models │ +│ [ Create Worktree ] │ +└──────────────────────────────────────────────┘ +``` + +Rejected alternatives and why: + +- **Inside Advanced options.** Wrong category. Advanced options holds refinements of a + known target (branch name, base branch). The project *is* the target: changing it + invalidates the branch list, base branch, default-branch badge, and setup scripts. + Hiding it also fails the stated requirement of seeing which project is assigned. +- **A full-width row directly beneath the tabs.** It covered both tabs, but consumed + unnecessary vertical space and made the project control look like a primary form field. +- **In the dialog title** (`New Worktree in [kilocode ▾]`). Reads nicely but requires + widening `Dialog`'s `title` prop to accept JSX, which touches kilo-ui for one caller. + +### Visibility + +Render the row only when `multiProject` is true. With the flag off (the default) the +dialog stays byte-identical to today, so there is no regression surface for the +all-user path. When the flag is on but only the pinned project exists, still render it: +showing the target is informative and the requirement is explicitly about seeing the +assignment. + +### Default value + +`props.projectId ?? activeProjectId()`. `activeProjectId()` already exists at +`AgentManagerApp.tsx:268` (`projectList().find((p) => p.active)?.id ?? currentProjectId()`). + +No new persistence. The active project is already the durable "last selected" state +(persisted per project as `activeTarget` in each repo's `.kilo/agent-manager.json`, plus +the registry's ordering). Adding a separate "last dialog project" key would create a +second source of truth that can disagree with the sidebar. + +### Reuse, no new CSS + +The row uses the existing `am-advanced-field` + `am-nv-config-label` + +`am-selector-wrapper` + `am-selector-trigger` markup, i.e. exactly the structure the base +branch selector already uses at `NewWorktreeDialog.tsx:813-905`, with `DeferredPopover` +(already imported) instead of `BranchSelectPopover`. + +### Component extraction + +`NewWorktreeDialog.tsx` is already 1136 lines. The selector and its popover list go into a +new `webview-ui/agent-manager/ProjectSelect.tsx` (roughly `BranchSelect.tsx`'s role): +presentational, takes `projects`, `value`, `onSelect`, and labels, and owns nothing but +its own list rendering. The dialog keeps only the signal, the popover trigger, and the +effects. + +Note: `webview-ui/agent-manager/NewWorktreeDialog.tsx` is not under a `maxLines` cap +(`tests/unit/agent-manager-arch.test.ts` caps `src/agent-manager/*.ts` only), but the file +is on the arch test's watched list and the caps exist to discourage exactly this kind of +growth. + +--- + +## Changes + +### 1. `webview-ui/agent-manager/ProjectSelect.tsx` (new) + +Presentational popover body listing projects: + +- Row = project label + dimmed root path (tooltip on the full root, matching + `ProjectsSection.tsx:60`). +- Check mark on the selected project. +- Untrusted and missing projects are disabled and carry the same affordances the accordion + uses: `lock` icon + trust hint, `warning` icon + missing hint + (`ProjectsSection.tsx:67-75`). Selecting them is not possible; trust happens in the + sidebar, not in this dialog. Keeps the dialog free of trust-flow branching. +- There is intentionally no Add project action in this picker. Project registration and + trust management stay in the Agent Manager Projects toolbar. + +### 2. `webview-ui/agent-manager/NewWorktreeDialog.tsx` + +Props change: + +```ts +export const NewWorktreeDialog: Component<{ + onClose: () => void + projectId?: string // now: initial value, not fixed target + projects?: AgentProjectSnapshot[] // omitted / empty => single-project, row hidden + activeProjectId?: string + defaultBase?: (projectId: string) => string // replaces defaultBaseBranch?: string + mode: ModeRouter +}> +``` + +`defaultBaseBranch?: string` must become a per-project lookup because each project has its +own configured default and its own local branch. `ProjectList.tsx:142` already computes +that expression (`state?.defaultBaseBranch ?? props.local[projectId]?.branch`); hoist it +into the callback so both call sites share it. + +New state and derived values: + +```ts +const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) +const [projectOpen, setProjectOpen] = createSignal(false) +const selectable = () => (props.projects ?? []).filter((p) => p.trusted && !p.missing) +const showProject = () => (props.projects?.length ?? 0) > 0 +``` + +Every outbound message switches from `props.projectId` to `project()`: +`:321` `requestBranches`, `:373` `createMultiVersion`, `:566` `importFromPR`, +`:575` `importFromBranch`. + +Reload branch data on project change, replacing the one-shot `onMount` request at `:319-321`: + +```ts +createEffect( + on(project, (id) => { + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) // custom base is project-specific + setDefaultBranch(props.defaultBase?.(id) ?? "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), +) +``` + +Drop stale branch replies in the `agentManager.branches` handler (`:520-525`). +`AgentManagerBranchesMessage` **already declares an optional `projectId`** +(`extension-messages.ts:939-945`, `src/agent-manager/types.ts:293-298`); the field is +simply never populated or read today. Without this guard, switching projects twice quickly +can race a wrong branch list into the base-branch popover: + +```ts +if (ev.projectId && ev.projectId !== project()) return +``` + +Also replace the `if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)` guard +at `:523` — with a per-project lookup, the guard must consult +`props.defaultBase?.(project())` instead of a fixed prop. + +Preserved across a project change (all project-agnostic): prompt text and its +`advancedDialogPrompt` persistence, images, name, agent, model, variant, versions, compare +allocations, sandbox override. + +Keyboard: add `project` to `WORKTREE_PROMPT_COMMANDS` so `/project` opens the popover, +consistent with mode/model/variant/sandbox already being reachable from the dialog's slash +menu (`:302-314`). Hide it from the list when `showProject()` is false, using the same +`hidden` set mechanism already used for `agents` / `variant` / `sandbox`. + +### 3. `webview-ui/agent-manager/AgentManagerApp.tsx` + +`showNewWorktreeDialog` (`:1870-1876`) passes the catalog and a per-project default +resolver instead of a single branch string: + +```tsx + dialog.close()} + projects={multiProject() ? projectList() : undefined} + activeProjectId={activeProjectId()} + defaultBase={defaultBase} +/> +``` + +where `defaultBase(id)` reads `registry.ensure(id).defaultBaseBranch() ?? registry.ensure(id).localStats()?.branch ?? repoDetectedBranch() ?? "main"`. +`registry.ensure` and both store fields already exist +(`project/registry.ts:39`, `project/store.ts:62,67,113,123`). + +### 4. `webview-ui/agent-manager/ProjectList.tsx` + +`newWorktree(projectId)` (`:136-146`) passes the same `projects` / `activeProjectId` / +`defaultBase` props with `projectId` as the initial value, so the per-project `+` button +opens the dialog pre-scoped but still switchable. Its current inline +`state?.defaultBaseBranch ?? props.local[projectId]?.branch` expression is replaced by the +shared resolver passed down from `AgentManagerApp`. + +### 5. `src/agent-manager/worktree-importer.ts` + +Stamp `projectId` on all three `agentManager.branches` posts (`:27`, `:48`, `:54`). The +field is already in the type; the value is available from the ambient `ProjectScope` +context the message runs in (`AgentManagerProvider.ts:479`). Without this the stale-reply +guard in the webview is inert. + +### 6. Activate the created worktree when the project differs + +Creating in a non-active project currently leaves the sidebar where it is: +`createMultiVersion` never activates (no activation call in `provider-multi-version.ts`), +and the new worktree just appears in that project's accordion. That is right for the +per-project `+` button, but for `Cmd+N` where the user deliberately switched projects, +landing in the new worktree is what the flow implies. + +Post an `agentManager.activateSelection` for the first created worktree when the chosen +project differs from the active one. `activateSelection` already handles readiness, trust, +and stale-target fallback (`project/messages.ts:76-99`), so this is one message, not new +machinery. Hook it to the existing `agentManager.worktreeSetup` / `multiVersionProgress` +handling in `AgentManagerApp.tsx:1453-1471`, which already carries `projectId`. + +### 7. i18n + +New keys in `webview-ui/agent-manager/i18n/en.ts` (near the existing +`agentManager.dialog.*` block at `:130`): + +- `agentManager.dialog.project.select` — "Select project" +- `agentManager.dialog.project.untrusted` — "Trust this project in the sidebar first" +- `agentManager.dialog.project.missing` — "Repository not found" + +Then translate the four keys into the other 20 locale files in that directory via the +`translator` subagent. + +--- + +## Implementation order + +1. `ProjectSelect.tsx` with the presentational list, plus i18n keys in `en.ts`. +2. Dialog: `project` signal, prop rename to `defaultBase`, route all four outbound + messages through `project()`, render the row behind `showProject()`. +3. Dialog: `createEffect(on(project, …))` branch reload, base-branch reset, stale-reply + guard. +4. Call-site updates in `AgentManagerApp.tsx` and `ProjectList.tsx`, shared `defaultBase` + resolver. +5. `projectId` stamp in `worktree-importer.ts`. +6. `/project` slash command. +7. Post-create activation when the target project differs. +8. Locale fan-out. +9. Changeset (`minor`, user-facing): worktree creation targets an explicit project. + +--- + +## Tests + +Existing source-text unit tests already assert against this dialog and will need to stay +green: `tests/unit/new-worktree-dialog-sandbox.test.ts`, +`tests/unit/prompt-input-bidirectional.test.ts`, and the dialog entry in +`tests/unit/agent-manager-arch.test.ts`. + +New coverage: + +- The dialog posts `createMultiVersion` / `requestBranches` / `importFromBranch` / + `importFromPR` with the *selected* project id, not the prop, after a project change. +- A `agentManager.branches` reply carrying a non-current `projectId` does not mutate the + branch list (the race guard). +- Changing project clears `baseBranch` and re-derives `defaultBranch` from `defaultBase`. +- Prompt text survives a project change (no accidental reset through the shared + `advancedDialogPrompt` cache). +- The row does not render when `projects` is empty, so the single-project dialog is + unchanged. +- Untrusted and missing projects are not selectable. + +Checks to run before declaring done, from `packages/kilo-vscode/`: +`bun run typecheck`, `bun run lint`, `bun run test:unit`, `bun run knip`. + +--- + +## Manual verification + +In the isolated harness (`bun run extension:isolated`) with +`kilo-code.new.experimental.multiProject` enabled and two repositories registered: + +1. `Cmd+N` from project A shows "Project: A". Switch to B, create, and confirm the + worktree lands in B's accordion and the sidebar activates it. +2. Switch project with Advanced options open and confirm the base-branch list and default + badge follow the new project rather than showing A's branches. +3. Switch project rapidly back and forth and confirm the branch list matches the selected + project (the race guard). +4. Type a prompt, switch project, confirm the prompt is retained. +5. Use the Import tab after switching project and confirm branches and PR import target + the selected repository. +6. Turn the flag off and confirm the dialog is visually identical to today. + +--- + +## Out of scope + +- Trusting or removing a project from inside the dialog. Trust stays in the sidebar; the + dialog only disables untrusted entries. +- Any change to how the active project is persisted. +- The quick-create path (`Cmd+Shift+N` → `agentManager.createWorktree`, + `AgentManagerApp.tsx:1863-1867`). It has no dialog, so it keeps targeting the active + project. Worth revisiting only if the explicit-target rule should apply there too. +- Per-project setup-script or agent selection in the dialog. + +## Risks + +- **Stale branch data** is the only real correctness risk, and it is why the `projectId` + stamp plus the reply guard are mandatory rather than optional polish. +- **Prop signature change** (`defaultBaseBranch: string` → `defaultBase: (id) => string`) + touches both call sites; a partial migration would silently show one project's default + branch while creating in another. +- **Dialog file growth**; mitigated by extracting `ProjectSelect.tsx`. + +--- + +# Appendix: exact UI and styling specification + +Everything below is copy-paste ready. Class names, tokens, and icon names are all verified +against the current tree. Do not invent new tokens or new class names beyond the ones +listed here. + +## A. Visual layout + +``` +┌─ New Worktree ─────────────────────────────────────────── X ─┐ +│ │ +│ [ New ][ Import ] [ 📁 kilocode ⌃⌄ ] │ +│ └────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Worktree name (optional) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ prompt … │ │ +│ │ Code ▾ OpenAI / GPT-5.6 ▾ None ▾ ✨ 🔒 🎤 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ › Advanced options │ +│ VERSIONS [1][2][3][4] [⧉ Compare Models] │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Create Worktree │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +Open dropdown (anchored under the trigger, same width as the trigger): + +``` + ┌────────────────────────────────────────────┐ + │ 📁 kilocode ~/Documents/git/kilocode ✓│ ← .am-project-option-active + │ 📁 cloud ~/Documents/git/cloud │ + │ 🔒 sample-app ~/dev/sample-app │ ← disabled, 50% opacity + │ ⚠ old-repo ~/dev/old-repo │ ← disabled, 50% opacity + └────────────────────────────────────────────┘ +``` + +Rules: + +- The selector is inline with the New/Import buttons inside the tab-switcher flex row, so + it applies to New and Import alike. +- The project name and folder icon identify the scope without a separate visible label. +- The trigger is the same control as the Advanced options base-branch trigger + (`.am-selector-trigger`), so the dialog has one visual language for "pick a thing". +- The row is **not rendered at all** when `props.projects` is empty or undefined. That is + the single-project / flag-off case, which must stay pixel-identical to today. + +## B. Exact icon names + +Only these, from `packages/ui/src/components/icon.tsx`: + +| Where | `Icon name` | Notes | +|---|---|---| +| Trigger left | `folder` | Always, regardless of project state. | +| Trigger right | `selector` | Same as every other `.am-selector-trigger`. | +| Option row, normal | `folder` | | +| Option row, untrusted | `lock` | Matches the sidebar accordion affordance. | +| Option row, missing | `warning` | Matches the sidebar accordion affordance. | +| Option row, selected | `check-small` | Right-aligned. | + +All at `size="small"`. Do not use `folder-add-left`, `check`, or `plus`. + +## C. New file: `webview-ui/agent-manager/ProjectSelect.tsx` + +```tsx +// Project picker list for the New Worktree dialog + +/** @jsxImportSource solid-js */ + +import { For, Show, type Component } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { AgentProjectSnapshot } from "../src/types/messages" + +interface ProjectSelectProps { + projects: AgentProjectSnapshot[] + selected?: string + onSelect: (id: string) => void + labels: { untrusted: string; missing: string } +} + +export const ProjectSelect: Component = (props) => ( +
+ + {(project) => { + const blocked = () => !project.trusted || project.missing + const hint = () => { + if (project.missing) return props.labels.missing + if (!project.trusted) return props.labels.untrusted + return project.root + } + const icon = () => { + if (project.missing) return "warning" as const + if (!project.trusted) return "lock" as const + return "folder" as const + } + return ( + + ) + }} + +
+) +``` + +Notes for the implementer: + +- Wrap in `.am-dropdown-list`, not a bare fragment: that class supplies the scroll cap and + 4px padding, and `.am-dropdown [data-slot="popover-body"]` zeroes the popover padding. +- No search input. A project list is short; adding one would need keyboard nav plumbing for + no benefit. +- `props.labels` is passed in rather than calling `useLanguage()` here, matching how + `BranchSelect` and `SidebarSearchMenu` take label props. + +## D. Exact JSX inserted into `NewWorktreeDialog.tsx` + +### D.1 Imports + +Add to the existing type import block at lines 6-12: + +```ts + AgentProjectSnapshot, +``` + +Add after line 48 (`import { BranchSelect, BranchSelectPopover } …`): + +```ts +import { ProjectSelect } from "./ProjectSelect" +``` + +`Icon`, `Show`, `DeferredPopover`, `createSignal`, `createEffect` are already imported. +`on` from `solid-js` must be added to the line 5 import list. + +### D.2 Props + +Replace the component signature at lines 84-89 with: + +```tsx +export const NewWorktreeDialog: Component<{ + onClose: () => void + /** Resolves the default base branch for one project. */ + defaultBase?: (projectId: string) => string | undefined + /** Initial target project. The user can change it while the dialog is open. */ + projectId?: string + /** Full project catalog. Empty or undefined hides the project row entirely. */ + projects?: () => AgentProjectSnapshot[] + /** Project the sidebar currently has active; used as the default target. */ + activeProjectId?: string + mode: ModeRouter +}> = (props) => { +``` + +### D.3 State + +Immediately after line 101 (`const [tab, setTab] = createSignal("new")`): + +```tsx +const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) +const [projectOpen, setProjectOpen] = createSignal(false) +const projects = () => props.projects?.() ?? [] +const showProject = () => projects().length > 0 +const projectLabel = () => projects().find((p) => p.id === project())?.label ?? "" +``` + +`defaultBranch` (line 106) changes from `props.defaultBaseBranch ?? "main"` to: + +```tsx +const [defaultBranch, setDefaultBranch] = createSignal( + (project() && props.defaultBase?.(project()!)) || "main", +) +``` + +### D.4 The inline selector + +Insert inside the tab switcher after the Import button: + +```tsx +{/* Project scope — applies to both tabs. Hidden unless multi-project is on. */} + +
+
+ + + + + {t("agentManager.dialog.project.select")} + + } + > + {projectLabel()} + + + + + + + } + > + { + track("project_select", { changed: id !== props.activeProjectId }) + setProject(id) + setProjectOpen(false) + }} + labels={{ + untrusted: t("agentManager.dialog.project.untrusted"), + missing: t("agentManager.dialog.project.missing"), + }} + /> + +
+
+
+``` + +Critical details, in order of how easily they get wrong: + +1. `placement="bottom-start"`, **not** `top-start`. The rest of this dialog uses + `top-start` because those triggers sit near the bottom of the panel. This one sits at + the top, so it must open downward. +2. `portal={false}` plus the escape CSS in section E.3. Do not switch to a portal unless + the clipping fallback in E.3 is needed. +3. `sameWidth` so the dropdown matches the trigger width, consistent with the base-branch + and compare-models popovers. +4. Never send the project label, root, or id as a telemetry property. `track` takes only + the boolean shown above. + +### D.5 Reactive reload on project change + +Delete the one-shot request at lines 319-321 inside `onMount` and replace it with an effect +placed next to the other `createEffect` calls: + +```tsx +// Project scope owns the branch data, the base branch, and the default badge. +// Prompt, name, model, agent, versions and attachments are project-agnostic and survive. +createEffect( + on(project, (id) => { + if (!id) return + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) + setDefaultBranch(props.defaultBase?.(id) ?? "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), +) +``` + +`on(project, …)` without `{ defer: true }` runs immediately, which replaces the removed +`onMount` request. Keep the textarea focus logic in `onMount` untouched. + +In the `agentManager.branches` handler (lines 520-525), replace the body with: + +```tsx +if (msg.type === "agentManager.branches") { + const ev = msg as AgentManagerBranchesMessage + if (ev.projectId && ev.projectId !== project()) return + setBranches(ev.branches) + const id = project() + if (!id || !props.defaultBase?.(id)) setDefaultBranch(ev.defaultBranch) + setBranchesLoading(false) +} +``` + +### D.6 Outbound project id + +Four call sites change from `props.projectId` to `project()`: + +| Line | Message | +|---|---| +| 321 (now inside the effect) | `agentManager.requestBranches` | +| 373 | `agentManager.createMultiVersion` | +| 566 | `agentManager.importFromPR` | +| 575 | `agentManager.importFromBranch` | + +Grep afterwards: `props.projectId` must appear exactly once in the file, in the `project` +signal initializer. + +## E. Exact CSS + +All of it goes into `webview-ui/agent-manager/agent-manager.css`. No changes to kilo-ui. + +### E.1 The inline selector + +Insert directly after the `.am-tab-switcher-pill-active` rule (agent-manager.css:3614-3617), +before the `/* Import tab layout */` comment at line 3619: + +```css +/* Project scope selector — inline with the New/Import tabs */ + +.am-nv-project-inline { + display: flex; + align-items: center; + flex-shrink: 0; + flex: 0 1 260px; + min-width: 0; + margin-left: auto; +} + +.am-nv-project-inline .am-selector-wrapper { + width: 100%; + min-width: 0; +} +``` + +The `flex: 0 1 260px` cap keeps the project control compact while allowing long project +names to truncate. `margin-left: auto` keeps it aligned to the right of the New/Import +buttons. + +### E.2 The dropdown rows + +Insert after the `.am-dropdown-empty` rule (agent-manager.css:3863-3868), before the +`/* Import empty state */` comment at line 3870: + +```css +/* Project option rows in the New Worktree project dropdown. + Deliberately distinct from .am-project-item, which styles the sidebar accordion. */ + +.am-project-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-base); + font-size: var(--font-size-base); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.am-project-option:hover:not(:disabled) { + background: var(--surface-inset-base-hover); +} + +.am-project-option-active { + background: var(--surface-inset-base); +} + +.am-project-option:disabled { + opacity: 0.5; + cursor: default; +} + +.am-project-option-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.am-project-option-left [data-component="icon"] { + color: var(--text-weaker); + flex-shrink: 0; +} + +.am-project-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; + max-width: 45%; +} + +.am-project-option-root { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-size: var(--kilo-font-size-11); + color: var(--text-weaker); +} + +``` + +Why not reuse `.am-branch-item`: it is defined twice (lines 3211 and 3810) and the earlier +definition sets `font-family: var(--font-mono, monospace)` on `.am-branch-item-name`, which +would render project labels in monospace. Reusing it also couples project rows to future +branch-row changes. `.am-project-item` is likewise off limits: it already styles the +sidebar project accordion header (line 325). + +### E.3 Popover clipping escape + +`[data-slot="dialog-body"]` is `overflow: hidden` in `packages/ui/src/components/dialog.css:99-105`, +and `[data-slot="dialog-content"]` is `overflow: auto` (line 38). The existing escape rules +at agent-manager.css:2822-2828 only match popovers **inside** `.am-nv-dialog`, and this row +is deliberately outside it. Without the following, the dropdown is clipped by the dialog. + +Add to that same rule group (extend the existing selector list rather than duplicating the +declaration): + +```css +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"], +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] { + overflow: visible; +} +``` + +Verification step, not optional: open the dropdown with four or more projects registered +and confirm no row is cut off and no inner scrollbar appears on the dialog. If it still +clips, the documented fallback is to drop `portal={false}` from the `DeferredPopover` in +D.4 and delete this rule; the dialog already sets `overflow: visible` on +`[data-slot="dialog-content"]` for portal-based dropdowns (agent-manager.css:2755-2760). + +## F. i18n + +Add to `webview-ui/agent-manager/i18n/en.ts`, immediately after +`"agentManager.dialog.namePlaceholder"`: + +```ts +"agentManager.dialog.project.select": "Select project", +"agentManager.dialog.project.untrusted": "Trust this project in the sidebar first", +"agentManager.dialog.project.missing": "Repository not found", +``` + +Then add the same three keys to all 20 sibling locale files in that directory (`ar bs br da +de es fa fr it ja ko nl no pl ru th tr uk zh zht`) via the `translator` subagent. + +## G. What must not change + +- No new CSS variables or tokens. Only the ones listed above, all already in use in this + file. +- No edits to `packages/kilo-ui/` or `packages/ui/`. +- No change to `.am-project-item`, `.am-branch-item`, `.am-selector-trigger`, + `.am-nv-config-label`, or any other existing rule. The only existing rule touched is the + `overflow: visible` selector group in E.3, and only by adding selectors to it. +- No new message types. `agentManager.addProject`, `agentManager.requestBranches`, + `agentManager.createMultiVersion`, `agentManager.importFromBranch`, and + `agentManager.importFromPR` all already exist and already accept what is needed. +- With `props.projects` empty, the rendered dialog markup must be identical to before the + change. Verify by toggling `kilo-code.new.experimental.multiProject` off. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index ebfe467e076..098aa9a0d49 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -774,15 +774,15 @@ export class AgentManagerProvider implements Disposable { private onImportMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestBranches") { - void this.importer.branches() + void this.importer.branches(m.projectId) return null } if (m.type === "agentManager.importFromBranch") { - void this.importer.branch(m.branch) + void this.importer.branch(m.branch, m.projectId) return null } if (m.type === "agentManager.importFromPR") { - void this.importer.pr(m.url) + void this.importer.pr(m.url, m.projectId) return null } } @@ -1018,6 +1018,7 @@ export class AgentManagerProvider implements Disposable { this.pushState() this.postToWebview({ type: "agentManager.worktreeSetup", + projectId: this.host.multiProject() ? this.context?.id : undefined, status: "ready", message: "Worktree ready", sessionId, diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 76389d4f288..7064f16d224 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -299,6 +299,7 @@ interface BranchesMessage { interface ImportResultMessage { type: "agentManager.importResult" + projectId?: string success: boolean message: string errorCode?: WorktreeSetupErrorCode diff --git a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts index f589a7c4ebe..c44d7d9cf95 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts @@ -21,10 +21,10 @@ export class WorktreeImporter { constructor(private readonly host: WorktreeImporterHost) {} - async branches(): Promise { + async branches(projectId?: string): Promise { const manager = this.host.manager() if (!manager) { - this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) + this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" }) return } @@ -46,31 +46,32 @@ export class WorktreeImporter { this.host.post({ type: "agentManager.branches", + projectId, branches, defaultBranch: result.defaultBranch, }) } catch (error) { this.host.log(`Failed to list branches: ${error}`) - this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) + this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" }) } } - async branch(branch: string): Promise { - await this.run({ branch }) + async branch(branch: string, projectId?: string): Promise { + await this.run({ branch }, projectId) } - async pr(url: string): Promise { - await this.run({ url }) + async pr(url: string, projectId?: string): Promise { + await this.run({ url }, projectId) } - private async run(target: { branch: string } | { url: string }): Promise { + private async run(target: { branch: string } | { url: string }, projectId?: string): Promise { const manager = this.host.manager() const state = this.host.state() if (!manager || !state) { - this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) + this.host.post({ type: "agentManager.importResult", projectId, success: false, message: "Not a git repository" }) return } - if (this.busy()) return + if (this.busy(projectId)) return this.importing = true const branch = "branch" in target const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." @@ -79,7 +80,7 @@ export class WorktreeImporter { ? `Branch "${target.branch}" is already checked out in another worktree` : "This PR's branch is already checked out in another worktree" try { - const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const + const progress = { type: "agentManager.worktreeSetup", projectId, status: "creating" } as const this.host.post({ ...progress, message: creating }) const result = branch ? await manager.createWorktree({ existingBranch: target.branch }) @@ -102,7 +103,7 @@ export class WorktreeImporter { state.addSession(session.id, worktree.id) this.host.register(session.id, result.path) this.host.ready(session.id, result, worktree.id) - this.host.post({ type: "agentManager.importResult", success: true, message: success }) + this.host.post({ type: "agentManager.importResult", projectId, success: true, message: success }) this.host.log(`${log} as worktree ${worktree.id}`) } catch (error) { state.removeWorktree(worktree.id) @@ -111,27 +112,28 @@ export class WorktreeImporter { throw error } } catch (error) { - this.importError(error, duplicate) + this.importError(error, duplicate, projectId) } finally { this.importing = false } } - private busy(): boolean { + private busy(projectId?: string): boolean { if (!this.importing) return false this.host.post({ type: "agentManager.importResult", + projectId, success: false, message: "Another import is already in progress", }) return true } - private importError(error: unknown, duplicate: string): void { + private importError(error: unknown, duplicate: string, projectId?: string): void { const raw = error instanceof Error ? error.message : String(error) const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw const code = classifyWorktreeError(message) - this.host.post({ type: "agentManager.worktreeSetup", status: "error", message, errorCode: code }) - this.host.post({ type: "agentManager.importResult", success: false, message, errorCode: code }) + this.host.post({ type: "agentManager.worktreeSetup", projectId, status: "error", message, errorCode: code }) + this.host.post({ type: "agentManager.importResult", projectId, success: false, message, errorCode: code }) } } 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 b4a24df88a7..3d530d82b36 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -23,6 +23,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), @@ -690,7 +691,7 @@ describe("Agent Manager Provider — onMessage routing", () => { it("worktree import behavior lives in the cohesive importer", () => { const text = importer() - for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value) + for (const value of ["createFromPR", "createWorktree", "this.busy(projectId)"]) expect(text).toContain(value) expect(body("onImportMessage")).toContain("this.importer") }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts new file mode 100644 index 00000000000..549062f1c73 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" + +const root = join(__dirname, "..", "..") +const dialog = readFileSync(join(root, "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"), "utf8") +const importer = readFileSync(join(root, "src", "agent-manager", "worktree-importer.ts"), "utf8") +const css = readFileSync(join(root, "webview-ui", "agent-manager", "agent-manager.css"), "utf8") + +describe("Agent Manager New Worktree project targeting", () => { + it("routes dialog operations through the selected project and rejects stale responses", () => { + expect(dialog).toContain("const [project, setProject]") + expect(dialog).toContain("if (ev.projectId !== project()) return") + expect(dialog).toContain('type: "agentManager.requestBranches", projectId: id') + expect(dialog).toContain('type: "agentManager.createMultiVersion"') + expect(dialog).toContain("projectId: target") + expect(dialog).toContain('type: "agentManager.importFromPR", projectId: project()') + expect(dialog).toContain('type: "agentManager.importFromBranch", projectId: project()') + }) + + it("tags branch and import responses with their owning project", () => { + expect(importer).toContain("async branches(projectId?: string)") + expect(importer).toContain('type: "agentManager.branches", projectId') + expect(importer).toContain('type: "agentManager.importResult", projectId') + expect(importer).toContain('type: "agentManager.worktreeSetup", projectId') + }) + + it("keeps the project picker aligned with the dialog selector system", () => { + expect(css).toContain(".am-nv-project-inline") + expect(css).toContain(".am-project-option") + expect(css).toContain('[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"])') + }) + + it("defines project labels in every Agent Manager locale", () => { + const keys = [ + "agentManager.dialog.project.select", + "agentManager.dialog.project.untrusted", + "agentManager.dialog.project.missing", + ] + const locales = readdirSync(join(root, "webview-ui", "agent-manager", "i18n")).filter((file) => + file.endsWith(".ts"), + ) + + for (const file of locales) { + const source = readFileSync(join(root, "webview-ui", "agent-manager", "i18n", file), "utf8") + for (const key of keys) expect(source, `${file} is missing ${key}`).toContain(`"${key}"`) + } + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a68c1099f47..f1a15cb27ea 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -266,6 +266,11 @@ const AgentManagerContent: Component = () => { const [currentProjectId, setCurrentProjectId] = createSignal() const [projectStates, setProjectStates] = createSignal>({}) const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId() + const [pendingCreate, setPendingCreate] = createSignal<{ projectId: string }>() + const scheduleCreate = (projectId: string) => { + if (projectId === activeProjectId()) return + setPendingCreate({ projectId }) + } const isActivePayload = (pid: string | undefined) => projectList().length === 0 || pid === undefined || pid === activeProjectId() @@ -282,6 +287,14 @@ const AgentManagerContent: Component = () => { persisted: persisted ?? {}, activeId: () => currentProjectId() ?? "single", }) + const defaultBase = (id: string) => { + const store = registry.ensure(id) + return ( + store.defaultBaseBranch() ?? + store.localStats()?.branch ?? + (id === activeProjectId() ? repoDetectedBranch() : undefined) + ) + } const localSessionIDs = () => registry.active().tabs.ids() const setLocalSessionIDs = (next: string[] | ((prev: string[]) => string[])) => registry.active().tabs.set(next) /** Remove a session ID from the local tab (no-op if absent). */ @@ -1371,6 +1384,14 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.worktreeSetup") { const ev = msg as AgentManagerWorktreeSetupMessage + const pending = pendingCreate() + if (ev.status === "ready" && ev.projectId && pending?.projectId === ev.projectId && ev.worktreeId) { + setPendingCreate(undefined) + vscode.postMessage({ + type: "agentManager.activateSelection", + target: { projectId: ev.projectId, kind: "worktree", worktreeId: ev.worktreeId }, + }) + } const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() const updateBusy: Setter> = (value) => store.setBusy(value) if (ev.status === "ready" || ev.status === "error") { @@ -1453,6 +1474,7 @@ const AgentManagerContent: Component = () => { // When a multi-version progress update arrives, mark newly created worktrees as loading if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { const ev = msg as unknown as AgentManagerMultiVersionProgressMessage + if (ev.status === "done" && pendingCreate()?.projectId === ev.projectId) setPendingCreate(undefined) if (ev.status === "done" && ev.groupId) { // Clear busy state for all worktrees in this group const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() @@ -1871,7 +1893,15 @@ const AgentManagerContent: Component = () => { if (!loaded()) return expandSidebar() dialog.show(() => ( - dialog.close()} defaultBaseBranch={repoDefaultBranch()} /> + dialog.close()} + projectId={multiProject() ? activeProjectId() : undefined} + projects={multiProject() ? projectList : undefined} + activeProjectId={activeProjectId()} + defaultBase={defaultBase} + onCreate={scheduleCreate} + /> )) } @@ -2348,6 +2378,8 @@ const AgentManagerContent: Component = () => { selection={selection() ?? undefined} currentSessionID={session.currentSessionID} mode={mode} + defaultBase={defaultBase} + onCreate={scheduleCreate} bindings={kb()} t={t} onSearchRef={(ref) => (sidebarSearchMenu = ref)} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 3a3a08869b3..ad851fce50f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -2,10 +2,11 @@ /** @jsxImportSource solid-js */ -import { type Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js" +import { type Component, For, Show, createSignal, createEffect, createMemo, on, onMount, onCleanup } from "solid-js" import type { AgentManagerBranchesMessage, AgentManagerImportResultMessage, + AgentProjectSnapshot, BranchInfo, EnhancePromptResultMessage, EnhancePromptErrorMessage, @@ -49,10 +50,11 @@ import { BranchSelect, BranchSelectPopover } from "../src/components/shared/Bran import { tracker } from "./telemetry" import { cycleAgent } from "../src/context/session-agent" import type { ModeRouter } from "./mode-router" +import { ProjectSelect } from "./ProjectSelect" type VersionCount = 1 | 2 | 3 | 4 const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] -const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox"]) +const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox", "project"]) const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt" type DialogTab = "new" | "import" @@ -83,8 +85,11 @@ function sanitizeBranchName(name: string): string { export const NewWorktreeDialog: Component<{ onClose: () => void - defaultBaseBranch?: string + defaultBase?: (projectId: string) => string | undefined projectId?: string + projects?: () => AgentProjectSnapshot[] + activeProjectId?: string + onCreate?: (projectId: string) => void mode: ModeRouter }> = (props) => { const { t } = useLanguage() @@ -99,11 +104,20 @@ export const NewWorktreeDialog: Component<{ const click = metrics.click const [tab, setTab] = createSignal("new") + const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) + const [projectOpen, setProjectOpen] = createSignal(false) + const projects = () => props.projects?.() ?? [] + const showProject = () => projects().length > 0 + const projectLabel = () => projects().find((item) => item.id === project())?.label ?? "" + const base = () => { + const id = project() + return id ? props.defaultBase?.(id) : undefined + } // --- Shared branch data (used by both New tab's base branch selector and Import tab) --- const [branches, setBranches] = createSignal([]) const [branchesLoading, setBranchesLoading] = createSignal(false) - const [defaultBranch, setDefaultBranch] = createSignal(props.defaultBaseBranch ?? "main") + const [defaultBranch, setDefaultBranch] = createSignal(base() ?? "main") const [branchSearch, setBranchSearch] = createSignal("") // --- New tab state --- @@ -307,18 +321,25 @@ export const NewWorktreeDialog: Component<{ if (session.agents().length < 2) hidden.add("agents") if (variants().length === 0) hidden.add("variant") if (!sandboxVisible()) hidden.add("sandbox") + if (!showProject()) hidden.add("project") return hidden }, WORKTREE_PROMPT_COMMANDS, WORKTREE_PROMPT_SCOPE, + [ + { + name: "project", + description: t("agentManager.dialog.project.select"), + hints: [], + action: () => setProjectOpen(true), + }, + ], ) const onFocusPrompt = () => restorePrompt() window.addEventListener("focusPrompt", onFocusPrompt) onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt)) onMount(() => { - setBranchesLoading(true) - vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId }) // Resize textarea if restoring a cached prompt if (prompt()) adjustHeight() const focus = () => { @@ -334,6 +355,20 @@ export const NewWorktreeDialog: Component<{ }) }) + // Branch data and base-branch defaults belong to the selected project. Other + // dialog state deliberately survives project changes. + createEffect( + on(project, (id) => { + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) + setDefaultBranch(id ? (props.defaultBase?.(id) ?? "main") : "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), + ) + const effectiveBaseBranch = () => baseBranch() ?? defaultBranch() const filteredBranches = createMemo(() => { @@ -367,10 +402,12 @@ export const NewWorktreeDialog: Component<{ const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined const count = total() const sel = isCompare ? null : model() + const target = project() + if (target) props.onCreate?.(target) vscode.postMessage({ type: "agentManager.createMultiVersion", - projectId: props.projectId, + projectId: target, text, name: name().trim() || undefined, versions: count, @@ -519,12 +556,14 @@ export const NewWorktreeDialog: Component<{ const importUnsub = vscode.onMessage((msg) => { if (msg.type === "agentManager.branches") { const ev = msg as AgentManagerBranchesMessage + if (ev.projectId !== project()) return setBranches(ev.branches) - if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch) + if (!base()) setDefaultBranch(ev.defaultBranch) setBranchesLoading(false) } if (msg.type === "agentManager.importResult") { const ev = msg as AgentManagerImportResultMessage + if (ev.projectId !== project()) return setPrPending(false) setImportPending(false) if (ev.success) { @@ -563,7 +602,7 @@ export const NewWorktreeDialog: Component<{ const url = prUrl().trim() if (!url || isPending()) return setPrPending(true) - vscode.postMessage({ type: "agentManager.importFromPR", projectId: props.projectId, url }) + vscode.postMessage({ type: "agentManager.importFromPR", projectId: project(), url }) } const handleBranchSelect = (name: string) => { @@ -572,7 +611,7 @@ export const NewWorktreeDialog: Component<{ setImportPending(true) setBranchOpen(false) setBranchSearch("") - vscode.postMessage({ type: "agentManager.importFromBranch", projectId: props.projectId, branch: name }) + vscode.postMessage({ type: "agentManager.importFromBranch", projectId: project(), branch: name }) } return ( @@ -595,6 +634,62 @@ export const NewWorktreeDialog: Component<{ > {t("agentManager.dialog.tab.import")} + {/* Project scope applies to both New and Import tabs. */} + +
+
+ + + + + {t("agentManager.dialog.project.select")} + + } + > + {projectLabel()} + + + + + + + } + > + { + track("project_select", { changed: id !== props.activeProjectId }) + setProject(id) + setProjectOpen(false) + }} + labels={{ + untrusted: t("agentManager.dialog.project.untrusted"), + missing: t("agentManager.dialog.project.missing"), + }} + /> + +
+
+
{/* New tab */} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 1575e4e4074..60e24b46178 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -34,6 +34,8 @@ interface Props { selection?: string currentSessionID?: () => string | undefined mode: ModeRouter + defaultBase?: (projectId: string) => string | undefined + onCreate?: (projectId: string) => void busy?: (projectId: string, id: string) => boolean working?: (projectId: string, id: string) => boolean localBusy?: (projectId: string) => boolean @@ -134,12 +136,14 @@ export const ProjectList: Component = (props) => { return select({ projectId: item.projectId, kind: "session", sessionId: item.sessionId }) } const newWorktree = (projectId: string) => { - const state = props.states[projectId] dialog.show(() => ( props.projects} + activeProjectId={props.selectedProject} + defaultBase={props.defaultBase} + onCreate={props.onCreate} mode={props.mode} - defaultBaseBranch={state?.defaultBaseBranch ?? props.local[projectId]?.branch} onClose={() => dialog.close()} /> )) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSelect.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSelect.tsx new file mode 100644 index 00000000000..ea37b09a140 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSelect.tsx @@ -0,0 +1,54 @@ +// Project picker list for the New Worktree dialog + +/** @jsxImportSource solid-js */ + +import { For, Show, type Component } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { AgentProjectSnapshot } from "../src/types/messages" + +interface ProjectSelectProps { + projects: AgentProjectSnapshot[] + selected?: string + onSelect: (id: string) => void + labels: { untrusted: string; missing: string } +} + +export const ProjectSelect: Component = (props) => ( +
+ + {(project) => { + const blocked = () => !project.trusted || project.missing + const hint = () => { + if (project.missing) return props.labels.missing + if (!project.trusted) return props.labels.untrusted + return project.root + } + const icon = () => { + if (project.missing) return "warning" as const + if (!project.trusted) return "lock" as const + return "folder" as const + } + + return ( + + ) + }} + +
+) 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 6cfadee1a9e..5bc4a65293b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2828,6 +2828,11 @@ body.am-wt-dragging-active * { overflow: visible; } +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"], +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] { + overflow: visible; +} + .am-slash-command-dropdown { min-width: 0; max-width: none; @@ -3616,6 +3621,22 @@ body.am-wt-dragging-active * { color: var(--text-on-interactive-base) !important; } +/* Project scope selector — inline with the New/Import tabs */ + +.am-nv-project-inline { + display: flex; + align-items: center; + flex-shrink: 0; + flex: 0 1 260px; + min-width: 0; + margin-left: auto; +} + +.am-nv-project-inline .am-selector-wrapper { + width: 100%; + min-width: 0; +} + /* Import tab layout */ .am-import-tab { @@ -3867,6 +3888,68 @@ body.am-wt-dragging-active * { color: var(--text-weaker); } +/* Project option rows in the New Worktree project dropdown. */ + +.am-project-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-base); + font-size: var(--font-size-base); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.am-project-option:hover:not(:disabled) { + background: var(--surface-inset-base-hover); +} + +.am-project-option-active { + background: var(--surface-inset-base); +} + +.am-project-option:disabled { + opacity: 0.5; + cursor: default; +} + +.am-project-option-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.am-project-option-left [data-component="icon"] { + color: var(--text-weaker); + flex-shrink: 0; +} + +.am-project-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; + max-width: 45%; +} + +.am-project-option-root { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-size: var(--kilo-font-size-11); + color: var(--text-weaker); +} + /* Import empty state */ .am-import-empty { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 586079b908d..04124ebe748 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -114,6 +114,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "إلغاء", "agentManager.dialog.removeStaleWorktree.confirm": "إزالة Worktree القديم", + "agentManager.dialog.project.select": "اختيار مشروع", + "agentManager.dialog.project.untrusted": "يُرجى الوثوق بهذا المشروع من الشريط الجانبي أولًا", + "agentManager.dialog.project.missing": "المستودع غير موجود", "agentManager.dialog.openWorktree": "شجرة عمل جديدة", "agentManager.dialog.configureWorktree": "تكوين Worktree جديد...", "agentManager.dialog.tab.new": "جديد", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 64ad7cc61a9..a50bc100b83 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -116,6 +116,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Cancelar", "agentManager.dialog.removeStaleWorktree.confirm": "Remover Worktree obsoleto", + "agentManager.dialog.project.select": "Selecionar projeto", + "agentManager.dialog.project.untrusted": "Primeiro, confie neste projeto na barra lateral", + "agentManager.dialog.project.missing": "Repositório não encontrado", "agentManager.dialog.openWorktree": "Novo Worktree", "agentManager.dialog.configureWorktree": "Configurar Novo Worktree...", "agentManager.dialog.tab.new": "Novo", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index a42401b977e..3071b152ece 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -116,6 +116,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Otkaži", "agentManager.dialog.removeStaleWorktree.confirm": "Ukloni zastarjeli Worktree", + "agentManager.dialog.project.select": "Odaberi projekat", + "agentManager.dialog.project.untrusted": "Prvo vjeruj ovom projektu na bočnoj traci", + "agentManager.dialog.project.missing": "Repozitorij nije pronađen", "agentManager.dialog.openWorktree": "Novi worktree", "agentManager.dialog.configureWorktree": "Konfiguriši Novi Worktree...", "agentManager.dialog.tab.new": "Novo", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 705758f1b94..ef6342f1ac3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annuller", "agentManager.dialog.removeStaleWorktree.confirm": "Fjern forældet Worktree", + "agentManager.dialog.project.select": "Vælg projekt", + "agentManager.dialog.project.untrusted": "Godkend først dette projekt i sidepanelet", + "agentManager.dialog.project.missing": "Repository ikke fundet", "agentManager.dialog.openWorktree": "Ny Worktree", "agentManager.dialog.configureWorktree": "Konfigurer Nyt Worktree...", "agentManager.dialog.tab.new": "Ny", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 234e551f1aa..844eccb0e73 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -118,6 +118,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Abbrechen", "agentManager.dialog.removeStaleWorktree.confirm": "Veralteten Worktree entfernen", + "agentManager.dialog.project.select": "Projekt auswählen", + "agentManager.dialog.project.untrusted": "Vertrauen Sie diesem Projekt zuerst in der Seitenleiste", + "agentManager.dialog.project.missing": "Repository nicht gefunden", "agentManager.dialog.openWorktree": "Neuer Worktree", "agentManager.dialog.configureWorktree": "Neuen Worktree konfigurieren...", "agentManager.dialog.tab.new": "Neu", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 56954f2c7ac..5e0e27eed38 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -125,6 +125,9 @@ export const dict = { "agentManager.dialog.tab.new": "New", "agentManager.dialog.tab.import": "Import", "agentManager.dialog.namePlaceholder": "Worktree name (optional)", + "agentManager.dialog.project.select": "Select project", + "agentManager.dialog.project.untrusted": "Trust this project in the sidebar first", + "agentManager.dialog.project.missing": "Repository not found", "agentManager.dialog.promptPlaceholder.mac": "Type a message (\u2318Enter to send)", "agentManager.dialog.promptPlaceholder.other": "Type a message (Ctrl+Enter to send)", "agentManager.dialog.advancedOptions": "Advanced options", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index f1ec88f48c3..4b438f22249 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Cancelar", "agentManager.dialog.removeStaleWorktree.confirm": "Eliminar Worktree obsoleto", + "agentManager.dialog.project.select": "Seleccionar proyecto", + "agentManager.dialog.project.untrusted": "Confía primero en este proyecto desde la barra lateral", + "agentManager.dialog.project.missing": "Repositorio no encontrado", "agentManager.dialog.openWorktree": "Nuevo Worktree", "agentManager.dialog.configureWorktree": "Configurar Nuevo Worktree...", "agentManager.dialog.tab.new": "Nuevo", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index e128e95a674..d318ab4256c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -121,6 +121,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "لغو", "agentManager.dialog.removeStaleWorktree.confirm": "حذف Worktree قدیمی", + "agentManager.dialog.project.select": "انتخاب پروژه", + "agentManager.dialog.project.untrusted": "ابتدا در نوار کناری به این پروژه اعتماد کنید", + "agentManager.dialog.project.missing": "مخزن یافت نشد", "agentManager.dialog.openWorktree": "Worktree جدید", "agentManager.dialog.tab.new": "جدید", "agentManager.dialog.tab.import": "وارد کردن", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 3a2f7871314..10d707785b0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annuler", "agentManager.dialog.removeStaleWorktree.confirm": "Supprimer le Worktree obsolète", + "agentManager.dialog.project.select": "Sélectionner un projet", + "agentManager.dialog.project.untrusted": "Approuvez d'abord ce projet dans la barre latérale", + "agentManager.dialog.project.missing": "Dépôt introuvable", "agentManager.dialog.openWorktree": "Nouveau worktree", "agentManager.dialog.configureWorktree": "Configurer un Nouveau Worktree...", "agentManager.dialog.tab.new": "Nouveau", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 8f0cfabc3d1..4105961ff05 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -123,6 +123,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annulla", "agentManager.dialog.removeStaleWorktree.confirm": "Rimuovi worktree obsoleto", + "agentManager.dialog.project.select": "Seleziona progetto", + "agentManager.dialog.project.untrusted": "Prima, fidati di questo progetto nella barra laterale", + "agentManager.dialog.project.missing": "Repository non trovata", "agentManager.dialog.openWorktree": "Nuovo worktree", "agentManager.dialog.tab.new": "Nuovo", "agentManager.dialog.tab.import": "Importa", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index c689b6ac005..e3396a9144e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "キャンセル", "agentManager.dialog.removeStaleWorktree.confirm": "無効な Worktree を削除", + "agentManager.dialog.project.select": "プロジェクトを選択", + "agentManager.dialog.project.untrusted": "まずサイドバーでこのプロジェクトを信頼してください", + "agentManager.dialog.project.missing": "リポジトリが見つかりません", "agentManager.dialog.openWorktree": "新規ワークツリー", "agentManager.dialog.configureWorktree": "新規 Worktree の構成...", "agentManager.dialog.tab.new": "新規", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 28855e0bb7b..db772c76315 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -115,6 +115,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "취소", "agentManager.dialog.removeStaleWorktree.confirm": "오래된 Worktree 제거", + "agentManager.dialog.project.select": "프로젝트 선택", + "agentManager.dialog.project.untrusted": "먼저 사이드바에서 이 프로젝트를 신뢰하세요", + "agentManager.dialog.project.missing": "저장소를 찾을 수 없음", "agentManager.dialog.openWorktree": "새 워크트리", "agentManager.dialog.configureWorktree": "새 Worktree 구성...", "agentManager.dialog.tab.new": "새로 만들기", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 81a003f9f03..125733840f8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -122,6 +122,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annuleren", "agentManager.dialog.removeStaleWorktree.confirm": "Verouderde worktree verwijderen", + "agentManager.dialog.project.select": "Project selecteren", + "agentManager.dialog.project.untrusted": "Vertrouw dit project eerst in de zijbalk", + "agentManager.dialog.project.missing": "Repository niet gevonden", "agentManager.dialog.openWorktree": "Nieuwe worktree", "agentManager.dialog.configureWorktree": "Nieuwe Worktree Configureren...", "agentManager.dialog.tab.new": "Nieuw", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 4a0946996f5..194199df5e1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -115,6 +115,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Avbryt", "agentManager.dialog.removeStaleWorktree.confirm": "Fjern utdatert Worktree", + "agentManager.dialog.project.select": "Velg prosjekt", + "agentManager.dialog.project.untrusted": "Stol på dette prosjektet i sidepanelet først", + "agentManager.dialog.project.missing": "Repository ikke funnet", "agentManager.dialog.openWorktree": "Ny worktree", "agentManager.dialog.configureWorktree": "Konfigurer Nytt Worktree...", "agentManager.dialog.tab.new": "Ny", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 47ba2cbffbe..a20890b35d0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Anuluj", "agentManager.dialog.removeStaleWorktree.confirm": "Usuń nieaktualny Worktree", + "agentManager.dialog.project.select": "Wybierz projekt", + "agentManager.dialog.project.untrusted": "Najpierw zaufaj temu projektowi na pasku bocznym", + "agentManager.dialog.project.missing": "Nie znaleziono repozytorium", "agentManager.dialog.openWorktree": "Nowy Worktree", "agentManager.dialog.configureWorktree": "Skonfiguruj Nowe Worktree...", "agentManager.dialog.tab.new": "Nowy", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 52cf8082e0c..84ef5b9ed46 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Отмена", "agentManager.dialog.removeStaleWorktree.confirm": "Удалить устаревший Worktree", + "agentManager.dialog.project.select": "Выбрать проект", + "agentManager.dialog.project.untrusted": "Сначала подтвердите доверие к этому проекту на боковой панели", + "agentManager.dialog.project.missing": "Репозиторий не найден", "agentManager.dialog.openWorktree": "Новый worktree", "agentManager.dialog.configureWorktree": "Настроить новое Worktree...", "agentManager.dialog.tab.new": "Новый", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 320a1bebbc3..5a7bb8eef8f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -112,6 +112,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "ยกเลิก", "agentManager.dialog.removeStaleWorktree.confirm": "ลบ Worktree ที่ล้าสมัย", + "agentManager.dialog.project.select": "เลือกโปรเจกต์", + "agentManager.dialog.project.untrusted": "โปรดเชื่อถือโปรเจกต์นี้ในแถบด้านข้างก่อน", + "agentManager.dialog.project.missing": "ไม่พบ Repository", "agentManager.dialog.openWorktree": "Worktree ใหม่", "agentManager.dialog.configureWorktree": "กำหนดค่า Worktree ใหม่...", "agentManager.dialog.tab.new": "ใหม่", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index d02a01e95e7..820bbee740b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -123,6 +123,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "İptal", "agentManager.dialog.removeStaleWorktree.confirm": "Eskimiş worktree'yi kaldır", + "agentManager.dialog.project.select": "Proje seç", + "agentManager.dialog.project.untrusted": "Önce kenar çubuğunda bu projeye güvenin", + "agentManager.dialog.project.missing": "Depo bulunamadı", "agentManager.dialog.openWorktree": "Yeni Worktree", "agentManager.dialog.configureWorktree": "Yeni Worktree Yapılandır...", "agentManager.dialog.tab.new": "Yeni", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 54587c0c96a..b03f6b4fd8b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -124,6 +124,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Скасувати", "agentManager.dialog.removeStaleWorktree.confirm": "Видалити застаріле робоче дерево", + "agentManager.dialog.project.select": "Вибрати проєкт", + "agentManager.dialog.project.untrusted": "Спочатку підтвердьте, що довіряєте цьому проєкту, на бічній панелі", + "agentManager.dialog.project.missing": "Репозиторій не знайдено", "agentManager.dialog.openWorktree": "Нове робоче дерево", "agentManager.dialog.configureWorktree": "Налаштувати нове Worktree...", "agentManager.dialog.tab.new": "Нове", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 4732e36dd68..39b3d811568 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -111,6 +111,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "取消", "agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree", + "agentManager.dialog.project.select": "选择项目", + "agentManager.dialog.project.untrusted": "请先在侧边栏中信任此项目", + "agentManager.dialog.project.missing": "未找到仓库", "agentManager.dialog.openWorktree": "新建工作树", "agentManager.dialog.configureWorktree": "配置新 Worktree...", "agentManager.dialog.tab.new": "新建", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 0357ac87df4..33d267caa5d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -111,6 +111,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "取消", "agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree", + "agentManager.dialog.project.select": "選擇專案", + "agentManager.dialog.project.untrusted": "請先在側邊欄信任此專案", + "agentManager.dialog.project.missing": "找不到儲存庫", "agentManager.dialog.openWorktree": "新建工作樹", "agentManager.dialog.configureWorktree": "配置新 Worktree...", "agentManager.dialog.tab.new": "新建", diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 6dbfc9b979c..6b99617a3b1 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -58,6 +58,7 @@ export function useSlashCommand( exclude?: Set | Accessor>, include?: Set | Accessor>, scope?: string, + extra?: SlashCommandEntry[], ): SlashCommand { const [server, setServer] = createSignal([]) const [query, setQuery] = createSignal(null) @@ -191,6 +192,7 @@ export function useSlashCommand( }, }, ] + all.push(...(extra ?? [])) const excluded = () => { if (typeof exclude === "function") return exclude() diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 859482cae8a..8e300f4d714 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -947,6 +947,7 @@ export interface AgentManagerBranchesMessage { // Agent Manager Import tab: result feedback (extension → webview) export interface AgentManagerImportResultMessage { type: "agentManager.importResult" + projectId?: string success: boolean message: string errorCode?: WorktreeErrorCode From 3917ed1f9bd50232b311efc47974e4df0a30ef6c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 10:53:23 +0200 Subject: [PATCH 56/78] 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 00000000000..b9d53915489 --- /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 7d22cb0b8e9..518deb2f566 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 27361a6727a..f459fdea8b7 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 00000000000..e7b3af89a95 --- /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 00000000000..f56f7eec0ab --- /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 75a02f1bd27..2b4ab8376fe 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 00000000000..5f66c2653bf --- /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 57/78] 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 00000000000..f9fc3a1e5cc --- /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 15fac1d6be2..64fe5fc2214 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 155bd410bba..56ced904d57 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 32f2fb1b9f9..4879b32af35 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 00000000000..5794f2f2b91 --- /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 b4a24df88a7..08820212b49 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 00000000000..3e61071f4ec --- /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 d422267e9f0c60bb168937a8ff74419ce2d6e683 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:03:22 +0200 Subject: [PATCH 58/78] fix(agent-manager): activate cross-project imports --- ...agent-manager-new-worktree-project.test.ts | 4 +- .../agent-manager/AgentManagerApp.tsx | 1 + .../agent-manager/NewWorktreeDialog.tsx | 8 +- .../src/stories/agent-manager.stories.tsx | 120 +++++++++++++++++- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts index 549062f1c73..3e811eb8382 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts @@ -14,8 +14,8 @@ describe("Agent Manager New Worktree project targeting", () => { expect(dialog).toContain('type: "agentManager.requestBranches", projectId: id') expect(dialog).toContain('type: "agentManager.createMultiVersion"') expect(dialog).toContain("projectId: target") - expect(dialog).toContain('type: "agentManager.importFromPR", projectId: project()') - expect(dialog).toContain('type: "agentManager.importFromBranch", projectId: project()') + expect(dialog).toContain('type: "agentManager.importFromPR"') + expect(dialog).toContain('type: "agentManager.importFromBranch"') }) it("tags branch and import responses with their owning project", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index f1a15cb27ea..1286fd74e3e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1392,6 +1392,7 @@ const AgentManagerContent: Component = () => { target: { projectId: ev.projectId, kind: "worktree", worktreeId: ev.worktreeId }, }) } + if (ev.status === "error" && pending?.projectId === ev.projectId) setPendingCreate(undefined) const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() const updateBusy: Setter> = (value) => store.setBusy(value) if (ev.status === "ready" || ev.status === "error") { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index ad851fce50f..51727d0283d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -602,7 +602,9 @@ export const NewWorktreeDialog: Component<{ const url = prUrl().trim() if (!url || isPending()) return setPrPending(true) - vscode.postMessage({ type: "agentManager.importFromPR", projectId: project(), url }) + const target = project() + if (target) props.onCreate?.(target) + vscode.postMessage({ type: "agentManager.importFromPR", projectId: target, url }) } const handleBranchSelect = (name: string) => { @@ -611,7 +613,9 @@ export const NewWorktreeDialog: Component<{ setImportPending(true) setBranchOpen(false) setBranchSearch("") - vscode.postMessage({ type: "agentManager.importFromBranch", projectId: project(), branch: name }) + const target = project() + if (target) props.onCreate?.(target) + vscode.postMessage({ type: "agentManager.importFromBranch", projectId: target, branch: name }) } return ( diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 9ebef0f8766..a828ded37d2 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -26,8 +26,16 @@ import { Icon } from "@kilocode/kilo-ui/icon" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector" +import { DeferredPopover } from "../components/shared/DeferredPopover" +import { ProjectSelect } from "../../agent-manager/ProjectSelect" import { createSignal, onCleanup, onMount, type JSX } from "solid-js" -import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages" +import type { + AgentProjectSnapshot, + WorktreeFileDiff, + WorktreeState, + WorktreeGitStats, + PRStatus, +} from "../types/messages" import type { ReviewComment } from "../../diff-viewer/review-comments" import { createModeRouter } from "../../agent-manager/mode-router" import "../../agent-manager/agent-manager.css" @@ -1075,6 +1083,115 @@ export const NewWorktreeVariantDropdown1280: Story = { ), } +const projectPickerProjects: AgentProjectSnapshot[] = [ + { + id: "project-main", + root: "/workspace/kilocode", + label: "kilocode", + pinned: true, + active: true, + expanded: true, + initialized: true, + trusted: true, + missing: false, + }, + { + id: "project-cloud", + root: "/workspace/cloud", + label: "cloud", + pinned: false, + active: false, + expanded: false, + initialized: true, + trusted: true, + missing: false, + }, + { + id: "project-untrusted", + root: "/workspace/sample-app", + label: "sample-app", + pinned: false, + active: false, + expanded: false, + initialized: false, + trusted: false, + missing: false, + }, +] + +export const NewWorktreeProjectDropdown: Story = { + name: "NewWorktreeDialog — project dropdown open", + parameters: { layout: "fullscreen" }, + render: () => ( + +
+
+
+
+
+
New Worktree
+
+
+
+ + +
+
+ undefined} + placement="bottom-start" + flip={false} + sameWidth + portal={false} + deferDismiss + class="am-dropdown" + trigger={ + + } + > + undefined} + labels={{ + untrusted: "Trust this project in the sidebar first", + missing: "Repository not found", + }} + /> + +
+
+
+
+
+
+
+ VERSIONS +
+
+
+
+
+
+
+
+ + ), +} + const searchSection = { id: "polish", name: "Polish", color: "Blue", order: 0, collapsed: false } const slackedSection = { id: "slacked", name: "SLACKED", color: "Yellow", order: 1, collapsed: false } const sidebarSearchItems: SidebarSearchItem[] = [ @@ -1200,7 +1317,6 @@ export const SidebarSearchOpen: Story = { import { ProjectList } from "../../agent-manager/ProjectList" import type { AgentManagerStateMessage, - AgentProjectSnapshot, LocalGitStats, ProjectSessionInfo, } from "../types/messages" From 7c1023da524ad7cd36ccff620cc0362a8945dbb7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:05:28 +0200 Subject: [PATCH 59/78] 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 e7b3af89a95..18b87fb2543 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 f56f7eec0ab..60e30d4171b 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 38bff349bea517dfefee359381052b3c74f59915 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:12:14 +0200 Subject: [PATCH 60/78] style(agent-manager): format visual story --- .../webview-ui/src/stories/agent-manager.stories.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index a828ded37d2..cc0a8696769 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -1315,11 +1315,7 @@ export const SidebarSearchOpen: Story = { // --------------------------------------------------------------------------- import { ProjectList } from "../../agent-manager/ProjectList" -import type { - AgentManagerStateMessage, - LocalGitStats, - ProjectSessionInfo, -} from "../types/messages" +import type { AgentManagerStateMessage, LocalGitStats, ProjectSessionInfo } from "../types/messages" const projectA: AgentProjectSnapshot = { id: "prj-aaaa1111aaaa", From 4ea52f2dd17d56ba6c7a1ac0896b17ff020314ba Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 11:16:17 +0200 Subject: [PATCH 61/78] 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 00000000000..2a4f8cd4837 --- /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 b95374bf035..9c64afd28c5 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 3810eafaf36..679b8440724 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 87c5e2fb7a3..4ad5fb566af 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 5d0586988c6..6e3ebe6a577 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 5f05b95df7e..8a03ea08f7e 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 62/78] 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 9c64afd28c5..abf289879d1 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 6e3ebe6a577..9cd1d9d6cfb 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 63/78] 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 00000000000..08eb1cf9c18 --- /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 00000000000..28790c6a3bf --- /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 1575e4e4074..1638ae03373 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 e3e2679321f..fa3d2f929f2 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 840ec8cfba6..8f58fb07d44 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 6cfadee1a9e..5527f461e33 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 64/78] 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 1638ae03373..382f60bc2a7 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 69640241ec92b8150cbe7f5bfaff4db1eba1bcb7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 12:18:48 +0200 Subject: [PATCH 65/78] fix(agent-manager): preserve cross-project activation --- .kilo/plans/agent-manager-new-worktree-project-selector.md | 6 +++--- .../tests/unit/agent-manager-new-worktree-project.test.ts | 5 +++++ .../webview-ui/agent-manager/AgentManagerApp.tsx | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.kilo/plans/agent-manager-new-worktree-project-selector.md b/.kilo/plans/agent-manager-new-worktree-project-selector.md index ed6ed73d1d9..5f98fd086f7 100644 --- a/.kilo/plans/agent-manager-new-worktree-project-selector.md +++ b/.kilo/plans/agent-manager-new-worktree-project-selector.md @@ -827,8 +827,8 @@ de es fa fr it ja ko nl no pl ru th tr uk zh zht`) via the `translator` subagent - No change to `.am-project-item`, `.am-branch-item`, `.am-selector-trigger`, `.am-nv-config-label`, or any other existing rule. The only existing rule touched is the `overflow: visible` selector group in E.3, and only by adding selectors to it. -- No new message types. `agentManager.addProject`, `agentManager.requestBranches`, - `agentManager.createMultiVersion`, `agentManager.importFromBranch`, and - `agentManager.importFromPR` all already exist and already accept what is needed. +- No new message types. `agentManager.requestBranches`, `agentManager.createMultiVersion`, + `agentManager.importFromBranch`, and `agentManager.importFromPR` all already exist and + already accept what is needed. - With `props.projects` empty, the rendered dialog markup must be identical to before the change. Verify by toggling `kilo-code.new.experimental.multiProject` off. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts index 3e811eb8382..e7684806a28 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" const root = join(__dirname, "..", "..") const dialog = readFileSync(join(root, "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"), "utf8") +const app = readFileSync(join(root, "webview-ui", "agent-manager", "AgentManagerApp.tsx"), "utf8") const importer = readFileSync(join(root, "src", "agent-manager", "worktree-importer.ts"), "utf8") const css = readFileSync(join(root, "webview-ui", "agent-manager", "agent-manager.css"), "utf8") @@ -18,6 +19,10 @@ describe("Agent Manager New Worktree project targeting", () => { expect(dialog).toContain('type: "agentManager.importFromBranch"') }) + it("does not replace a pending cross-project activation", () => { + expect(app).toContain("if (pendingCreate()) return") + }) + it("tags branch and import responses with their owning project", () => { expect(importer).toContain("async branches(projectId?: string)") expect(importer).toContain('type: "agentManager.branches", projectId') diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 1286fd74e3e..b48896cb653 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -269,6 +269,7 @@ const AgentManagerContent: Component = () => { const [pendingCreate, setPendingCreate] = createSignal<{ projectId: string }>() const scheduleCreate = (projectId: string) => { if (projectId === activeProjectId()) return + if (pendingCreate()) return setPendingCreate({ projectId }) } const isActivePayload = (pid: string | undefined) => From 8c827a94b0762436e5ec327210d8a3d2ca78ae3c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 12:23:48 +0200 Subject: [PATCH 66/78] 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 55f665f5896..b7bba0b60e2 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 67/78] 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 abf289879d1..bef4fe8dfff 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 00000000000..47f33f26655 --- /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 a67cf4e12a0..751af5c7ff1 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 68/78] 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 eadcbe9dbf5..239ee516fc2 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 b24dac2c741..0753c0ae837 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 e1edce07827..78a5302b80d 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 de65b45f226..0e8d89dc2b8 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 d6ae3eb676f..9b33fc4b7ed 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 7c85edd3e20..a05c8b3d286 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 9bec9f013a3..b21483ccfaa 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 71e123b86a3..e351fd7ab0d 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 b0039f2efd0..1eaa9c12566 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 a227600e9c2..c48be143e8a 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 e269e186d42..caea07cf963 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 42e7b3b4b6a..66d76c1e33b 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 3e2363c6477..41f77fa6094 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 136ca2845b2..d8de11ca4b2 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 c5f0c077fcb..9a31bdf34b3 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 5b2f303660f..4720473ebb2 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 784bf935344..b481fb3ef0a 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 ebf4954c6bf..87cf0cf6a55 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 fdd58cf7fc4..4720473ebb2 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 ebf4954c6bf..7db717a6fac 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 05c387099b1..4380385eb52 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 fc5b1dc4911..481d4f5cc97 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 e819a16198a..2cda01638ff 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 8858ea455c7..3b1b3de84d1 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 8858ea455c7..3b1b3de84d1 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 b657e55ee74..bfe0176bc25 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 bc4f471b091..4a0f64a1775 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 2ec70d4ede2..7301e8f0b84 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 9cdd496d4a1..72a6a34103c 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 43f94d6b804..460be84ca5c 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 113df1aebbb405becb35a7c0134d50990254cda3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 12:42:15 +0200 Subject: [PATCH 69/78] fix(agent-manager): clear failed project activation --- .../tests/unit/agent-manager-new-worktree-project.test.ts | 2 ++ .../kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts index e7684806a28..35e439bc876 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts @@ -21,6 +21,8 @@ describe("Agent Manager New Worktree project targeting", () => { it("does not replace a pending cross-project activation", () => { expect(app).toContain("if (pendingCreate()) return") + expect(app).toContain('msg.type === "agentManager.importResult"') + expect(app).toContain("!msg.success && pendingCreate()?.projectId === msg.projectId") }) it("tags branch and import responses with their owning project", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b48896cb653..bed9b30d9d6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -27,6 +27,7 @@ import type { AgentManagerWorktreeDiffLoadingMessage, AgentManagerWorktreeDiffNoticeMessage, AgentManagerDiffBranchesMessage, + AgentManagerImportResultMessage, AgentManagerApplyWorktreeDiffResultMessage, AgentManagerWorktreeStatsMessage, AgentManagerLocalStatsMessage, @@ -1435,6 +1436,9 @@ const AgentManagerContent: Component = () => { } } + if (msg.type === "agentManager.importResult" && !msg.success && pendingCreate()?.projectId === msg.projectId) + setPendingCreate(undefined) + if (msg.type === "agentManager.sessionAdded") { const ev = msg as { type: string; sessionId: string; worktreeId: string } saveTabMemory() From 585c4e4112b978cb284dc066778a680fca6ea865 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 6 Aug 2026 11:23:57 +0000 Subject: [PATCH 70/78] chore: update kilo-vscode visual regression baselines --- .../new-worktree-project-dropdown-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-project-dropdown-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-project-dropdown-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-project-dropdown-chromium-linux.png new file mode 100644 index 00000000000..3f0dd7a5811 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-project-dropdown-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88a4490322f4f70694f7b785cff41c5900b4ef8c14da104396fe55d264a624b4 +size 14535 From 28ca0733bbe007c15b98eb28ddbf5a2bbb7a3fd8 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 13:32:45 +0200 Subject: [PATCH 71/78] feat(vscode): improve model search ranking --- .changeset/bright-model-search.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 4 + .../src/kilo-provider/early-message.ts | 6 + .../src/kilo-provider/model-usage.ts | 65 +++++++++ .../model-selector-accessibility.spec.ts | 7 +- .../tests/unit/model-selector-utils.test.ts | 48 +++++++ .../tests/unit/model-usage-history.test.ts | 20 +++ .../src/components/shared/ModelSelector.tsx | 70 +++++++-- .../components/shared/model-selector-utils.ts | 136 +++++++++++++++++- .../webview-ui/src/context/session.tsx | 26 +++- .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 + .../webview-ui/src/stories/StoryProviders.tsx | 2 + .../src/types/messages/extension-messages.ts | 8 +- .../src/types/messages/providers.ts | 7 + .../src/types/messages/webview-messages.ts | 12 ++ 15 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 .changeset/bright-model-search.md create mode 100644 packages/kilo-vscode/src/kilo-provider/model-usage.ts create mode 100644 packages/kilo-vscode/tests/unit/model-usage-history.test.ts diff --git a/.changeset/bright-model-search.md b/.changeset/bright-model-search.md new file mode 100644 index 00000000000..6bdf91a54df --- /dev/null +++ b/.changeset/bright-model-search.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Improve model search relevance with provider-aware results and personalized usage suggestions. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index c04d818b4b8..8d4a86df369 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -90,6 +90,7 @@ import { } from "./services/autocomplete/settings" import { routeEarlyMessage } from "./kilo-provider/early-message" import * as ModelState from "./kilo-provider/model-state" +import { handleModelUsageMessage } from "./kilo-provider/model-usage" import { handleForkSession } from "./kilo-provider/fork-session" import { openConfig } from "./kilo-provider/open-config" import { @@ -1025,6 +1026,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), speechToTextModels: () => this.fetchAndSendSpeechToTextModels(), + modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)), }) ) { return @@ -4054,6 +4056,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Clear globalState items that are not part of the configuration await this.extensionContext?.globalState.update("variantSelections", undefined) await this.extensionContext?.globalState.update("recentModels", undefined) + await this.extensionContext?.globalState.update("modelUsage", undefined) await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined) await this.extensionContext?.globalState.update("kilo.agentMigrationBannerDismissed", undefined) await this.extensionContext?.globalState.update("kilo.marketplace.dismissedSuggestions", undefined) @@ -4071,6 +4074,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Re-send globalState items to the webview this.postMessage({ type: "variantsLoaded", variants: {} }) this.postMessage({ type: "recentsLoaded", recents: [] }) + this.postMessage({ type: "modelUsageLoaded", usage: {} }) // Re-fetch notifications to reflect cleared dismissed IDs await this.fetchAndSendNotifications() diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index abf79b49a02..b99a626219f 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -6,6 +6,7 @@ import type { SuggestionContext } from "./handlers/suggestion" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildChatSettingsMessage } from "./chat-settings" import { buildThroughputSettingMessage } from "./throughput-settings" +import { handleModelUsageMessage, type ModelUsageMessage } from "./model-usage" type Ctx = { question: SuggestionContext @@ -18,6 +19,7 @@ type Ctx = { copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void speechToTextModels: () => Promise + modelUsage: (message: ModelUsageMessage) => Promise } export async function routeEarlyMessage( @@ -42,6 +44,10 @@ export async function routeEarlyMessage( ) return true } + if (message.type === "recordModelUsage" || message.type === "requestModelUsage") { + await ctx.modelUsage(message as ModelUsageMessage) + return true + } await routeSuggestionWebviewMessage(ctx.question, message) if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true if (message.type === "exportSessionTranscript") { diff --git a/packages/kilo-vscode/src/kilo-provider/model-usage.ts b/packages/kilo-vscode/src/kilo-provider/model-usage.ts new file mode 100644 index 00000000000..e27d6c735d0 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/model-usage.ts @@ -0,0 +1,65 @@ +const LIMIT = 200 +export type ModelUsageMap = Record +export type ModelUsageMessage = + | { type: "recordModelUsage"; providerID: string; modelID: string } + | { type: "requestModelUsage" } + +function valid(value: unknown): value is { count: number; lastUsed: number } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const item = value as Record + const count = item.count + const lastUsed = item.lastUsed + return ( + typeof count === "number" && + Number.isFinite(count) && + count > 0 && + typeof lastUsed === "number" && + Number.isFinite(lastUsed) + ) +} + +export function validateModelUsage(raw: unknown): ModelUsageMap { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} + const entries = Object.entries(raw as Record).flatMap(([key, value]) => + valid(value) ? [[key, value] as const] : [], + ) + return Object.fromEntries( + entries + .sort(([, a], [, b]) => b.lastUsed - a.lastUsed) + .slice(0, LIMIT) + .map(([key, value]) => [ + key, + { + count: Math.floor(value.count), + lastUsed: value.lastUsed, + }, + ]), + ) +} + +export function recordModelUsage(raw: unknown, providerID: unknown, modelID: unknown, now = Date.now()): ModelUsageMap { + if (typeof providerID !== "string" || !providerID || typeof modelID !== "string" || !modelID) { + return validateModelUsage(raw) + } + const usage = validateModelUsage(raw) + const key = `${providerID}/${modelID}` + const current = usage[key] ?? { count: 0, lastUsed: 0 } + usage[key] = { count: current.count + 1, lastUsed: now } + return validateModelUsage(usage) +} + +export async function handleModelUsageMessage( + message: ModelUsageMessage, + context: + | { globalState: { get: (key: string) => unknown; update: (key: string, value: unknown) => Thenable } } + | undefined, + post: (message: unknown) => void, +): Promise { + const current = context?.globalState.get("modelUsage") + const usage = + message.type === "recordModelUsage" + ? recordModelUsage(current, message.providerID, message.modelID) + : validateModelUsage(current) + if (message.type === "recordModelUsage") await context?.globalState.update("modelUsage", usage) + post({ type: "modelUsageLoaded", usage }) +} diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index 6fda7cdbb95..0610185bcd1 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -80,17 +80,18 @@ test("auto efficient details show server description and model choices", async ( await expect(preview).not.toContainText("openai/gpt-5.5") }) -test("typing a provider initial moves the active descendant to matching results", async ({ page }) => { +test("search uses a flat relevance-ranked result list with provider labels", async ({ page }) => { await load(page, "shared--model-selector-accessible") await page.getByRole("button", { name: "Review model: Alpha" }).click() const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) - await combobox.fill("N") + await combobox.fill("nov") const nova = page.getByRole("treeitem", { name: "Nova" }) await expect(nova).toBeVisible() await expect(combobox).toHaveAttribute("aria-activedescendant", await nova.getAttribute("id")) - await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveAttribute("aria-expanded", "true") + await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveCount(0) + await expect(nova).toContainText("NVIDIA") }) test("provider groups collapse, expand, and skip their model rows", async ({ page }) => { diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts index f58b20349bc..d1c681f89fc 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -13,7 +13,10 @@ import { isAuto, autoSummary, autoChoices, + rankModelSearch, + mostUsedModels, } from "../../webview-ui/src/components/shared/model-selector-utils" +import type { EnrichedModel } from "../../webview-ui/src/context/provider" const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" } @@ -169,6 +172,51 @@ describe("autoSummary", () => { }) }) +const SEARCH_MODELS: EnrichedModel[] = [ + { id: "solar-pro", name: "Solar Pro", providerID: "nvidia", providerName: "NVIDIA" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "openai", providerName: "OpenAI" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "kilo", providerName: "Kilo" }, + { id: "gpt-5.6", name: "GPT-5.6", providerID: "anthropic", providerName: "Anthropic" }, +] + +describe("rankModelSearch", () => { + it("prefers an exact model token over a longer prefix match", () => { + expect( + rankModelSearch(SEARCH_MODELS, "sol") + .slice(0, 2) + .map((model) => model.name), + ).toEqual(["GPT-5.6 Sol", "GPT-5.6 Sol"]) + }) + + it("keeps provider variants together and uses usage to order equivalent variants", () => { + const result = rankModelSearch(SEARCH_MODELS, "sol", { + usage: { "kilo/gpt-5.6-sol": { count: 4, lastUsed: 10 }, "openai/gpt-5.6-sol": { count: 1, lastUsed: 20 } }, + }) + expect(result.slice(0, 2).map((model) => model.providerID)).toEqual(["kilo", "openai"]) + }) + + it("does not let usage make a weaker model beat an exact match", () => { + const result = rankModelSearch(SEARCH_MODELS, "sol", { + usage: { "nvidia/solar-pro": { count: 1000, lastUsed: 100 } }, + }) + expect(result[0]?.name).toBe("GPT-5.6 Sol") + }) +}) + +describe("mostUsedModels", () => { + it("orders suggestions by personal count and excludes favorites", () => { + const result = mostUsedModels( + SEARCH_MODELS, + { + "nvidia/solar-pro": { count: 2, lastUsed: 20 }, + "openai/gpt-5.6-sol": { count: 5, lastUsed: 10 }, + }, + new Set(["openai/gpt-5.6-sol"]), + ) + expect(result.map((model) => model.providerID)).toEqual(["nvidia"]) + }) +}) + describe("isDataCollectedModel", () => { it("uses only explicit prompt training metadata", () => { expect(isDataCollectedModel({ mayTrainOnYourPrompts: true })).toBe(true) diff --git a/packages/kilo-vscode/tests/unit/model-usage-history.test.ts b/packages/kilo-vscode/tests/unit/model-usage-history.test.ts new file mode 100644 index 00000000000..70bf6535620 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/model-usage-history.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test" +import { recordModelUsage, validateModelUsage } from "../../src/kilo-provider/model-usage" + +describe("model usage history", () => { + it("increments a model and updates its last-used timestamp", () => { + expect(recordModelUsage({ "openai/gpt": { count: 2, lastUsed: 10 } }, "openai", "gpt", 20)).toEqual({ + "openai/gpt": { count: 3, lastUsed: 20 }, + }) + }) + + it("drops malformed entries and caps persisted history", () => { + const raw = Object.fromEntries( + Array.from({ length: 205 }, (_, index) => [`provider/model-${index}`, { count: 1, lastUsed: index }]), + ) + const result = validateModelUsage({ ...raw, invalid: { count: 0, lastUsed: 1 } }) + expect(Object.keys(result)).toHaveLength(200) + expect(result["provider/model-204"]).toEqual({ count: 1, lastUsed: 204 }) + expect(result.invalid).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index f142dca2719..0df7934c366 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -45,9 +45,10 @@ import { autoSummary, buildTriggerLabel, sanitizeName, + mostUsedModels, + rankModelSearch, } from "./model-selector-utils" import { ModelPreview } from "./ModelPreview" -import { searchMatch } from "../../utils/search-match" // --------------------------------------------------------------------------- // Row / group key helpers — single source of truth for key formatting @@ -57,6 +58,7 @@ const CLEAR_KEY = "clear" const FAVORITES_KEY = "favorites" const AUTO_KEY = "auto" const RECOMMENDED_KEY = "recommended" +const MOST_USED_KEY = "most-used" function modelKey(providerID: string, modelID: string) { return `${providerID}/${modelID}` @@ -234,9 +236,11 @@ export const ModelSelectorBase: Component = (props) => { if (!q) { return visibleModels() } - return visibleModels().filter( - (m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName), - ) + return rankModelSearch(visibleModels(), q, { + usage: session?.modelUsageHistory(), + favorites: new Set(session?.favoriteModels().map((item) => modelKey(item.providerID, item.modelID))), + recent: session?.recentModels(), + }) }) // Live set of favorited keys — drives star icon visual state (filled vs outline). @@ -267,13 +271,21 @@ export const ModelSelectorBase: Component = (props) => { const groups = createMemo(() => { const autos: EnrichedModel[] = [] const recommended: EnrichedModel[] = [] + const mostUsed: EnrichedModel[] = [] const map = new Map() + if (!search() && session) { + mostUsed.push(...mostUsedModels(visibleModels(), session.modelUsageHistory(), favoriteKeys())) + } + for (const m of filtered()) { if (isAuto(m)) { autos.push(m) continue } + if (!search() && mostUsed.some((item) => modelKey(item.providerID, item.id) === modelKey(m.providerID, m.id))) { + continue + } if (m.recommendedIndex !== undefined) { recommended.push(m) continue @@ -328,6 +340,18 @@ export const ModelSelectorBase: Component = (props) => { }) } + if (mostUsed.length > 0) { + result.push({ + key: MOST_USED_KEY, + label: language.t("model.group.mostUsed"), + rows: mostUsed.map((m) => ({ + key: rowKey("model", m.providerID, m.id), + kind: "model", + model: m, + })), + }) + } + const rest: ModelGroup[] = [...map.entries()] .sort(([a], [b]) => providerSortKey(a) - providerSortKey(b)) .map(([id, list]) => { @@ -343,6 +367,20 @@ export const ModelSelectorBase: Component = (props) => { } }) + if (search()) { + return [ + { + key: "search-results", + label: language.t("model.group.searchResults"), + rows: filtered().map((m) => ({ + key: rowKey("model", m.providerID, m.id), + kind: "model", + model: m, + })), + }, + ] + } + return [...result, ...rest] }) @@ -366,7 +404,7 @@ export const ModelSelectorBase: Component = (props) => { const rows = createMemo(() => { const c = collapsed() - const list = groups().flatMap((g) => (c.has(g.key) ? [] : g.rows)) + const list = groups().flatMap((g) => (search() || !c.has(g.key) ? g.rows : [])) if (!props.allowClear) return list return [{ key: CLEAR_KEY, kind: "clear" }, ...list] }) @@ -375,6 +413,10 @@ export const ModelSelectorBase: Component = (props) => { const result: ModelNode[] = [] if (props.allowClear) result.push({ key: CLEAR_KEY, kind: "row", row: { key: CLEAR_KEY, kind: "clear" } }) for (const group of groups()) { + if (search()) { + result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group }))) + continue + } result.push({ key: groupKey(group.key), kind: "group", group }) if (!isGroupOpen(group.key)) continue result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group }))) @@ -461,13 +503,15 @@ export const ModelSelectorBase: Component = (props) => { const match = list[0] const first = match ? canonicalKey(match) : null const next = - canon && rowMap().has(canon) - ? canon - : first && rowMap().has(first) - ? first - : props.allowClear - ? CLEAR_KEY - : defaultKey() + search() && first && rowMap().has(first) + ? first + : canon && rowMap().has(canon) + ? canon + : first && rowMap().has(first) + ? first + : props.allowClear + ? CLEAR_KEY + : defaultKey() setSelectedKey(next) setBrowsing(!!search() && nodeMap().has(next)) setNavigating(false) @@ -957,7 +1001,7 @@ export const ModelSelectorBase: Component = (props) => { const hovered = () => isSelected(row.key) const preActive = () => isPreActive(row.key) const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id)) - const showProvider = () => row.kind === "favorite" + const showProvider = () => row.kind === "favorite" || !!search() const showSelect = () => expanded() && preActive() && !isActive(model) const starLabel = () => `${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}` diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts index 4cb59d26c61..dd21d4e1f88 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts @@ -1,5 +1,6 @@ -import type { ModelSelection } from "../../types/messages" +import type { ModelSelection, ModelUsageMap } from "../../types/messages" import type { EnrichedModel } from "../../context/provider" +import { searchMatch } from "../../utils/search-match" import { KILO_PROVIDER_ID as KILO_GATEWAY_ID, PROVIDER_PRIORITY as PROVIDER_ORDER, @@ -68,6 +69,139 @@ export function freeDataLabel(_free: string, data: string): string { return data } +export function modelSelectionKey(providerID: string, modelID: string): string { + return `${providerID}/${modelID}` +} + +function collapse(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "") +} + +function words(value: string): string[] { + return value + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) +} + +function tokenScore(token: string, value: string): number { + const list = words(value) + if (list.includes(token)) return 1000 + if (list.some((word) => word.startsWith(token))) return 700 + if (collapse(value).includes(collapse(token))) return 400 + if (searchMatch(token, value)) return 250 + return -1 +} + +function matchScore(model: EnrichedModel, query: string): number | undefined { + const name = stripSubProviderPrefix(sanitizeName(model.name)) + const tokens = words(query) + if (tokens.length === 0) return 0 + + const scores = tokens.map((token) => { + const modelScore = Math.max(tokenScore(token, name), tokenScore(token, model.id)) + const providerScore = tokenScore(token, model.providerName) + return { modelScore, providerScore } + }) + if (scores.some((score) => score.modelScore < 0 && score.providerScore < 0)) return undefined + + const modelScore = scores.reduce((sum, score) => sum + Math.max(score.modelScore, 0), 0) + const providerScore = scores.reduce((sum, score) => sum + Math.max(score.providerScore, 0), 0) + const exact = collapse(query) === collapse(name) || collapse(query) === collapse(model.id) + return modelScore + Math.floor(providerScore / 10) + (exact ? 5000 : 0) +} + +function logicalModelKey(model: EnrichedModel): string { + return collapse(stripSubProviderPrefix(sanitizeName(model.name))) || collapse(model.id) +} + +function usageFor(model: EnrichedModel, usage: ModelUsageMap | undefined) { + return usage?.[modelSelectionKey(model.providerID, model.id)] ?? { count: 0, lastUsed: 0 } +} + +export interface ModelSearchOptions { + usage?: ModelUsageMap + favorites?: ReadonlySet + recent?: readonly ModelSelection[] +} + +/** + * Ranks matching models globally instead of sorting each provider independently. + * Exact model tokens beat prefixes such as "sol" in "solar", while personal + * usage only breaks ties between similarly relevant matches. + */ +export function rankModelSearch( + models: readonly EnrichedModel[], + query: string, + options: ModelSearchOptions = {}, +): EnrichedModel[] { + const groups = new Map< + string, + { + score: number + count: number + lastUsed: number + items: Array<{ model: EnrichedModel; score: number; count: number; lastUsed: number }> + } + >() + const recent = new Map( + (options.recent ?? []).map((item, index) => [modelSelectionKey(item.providerID, item.modelID), index]), + ) + + for (const model of models) { + const score = matchScore(model, query) + if (score === undefined) continue + const usage = usageFor(model, options.usage) + const key = logicalModelKey(model) + const group = groups.get(key) ?? { score, count: 0, lastUsed: 0, items: [] } + group.score = Math.max(group.score, score) + group.count += usage.count + group.lastUsed = Math.max(group.lastUsed, usage.lastUsed) + group.items.push({ model, score, count: usage.count, lastUsed: usage.lastUsed }) + groups.set(key, group) + } + + return [...groups.values()] + .sort((a, b) => b.score - a.score || b.count - a.count || b.lastUsed - a.lastUsed) + .flatMap((group) => + group.items + .sort( + (a, b) => + b.score - a.score || + b.count - a.count || + b.lastUsed - a.lastUsed || + (options.favorites?.has(modelSelectionKey(b.model.providerID, b.model.id)) ? 1 : 0) - + (options.favorites?.has(modelSelectionKey(a.model.providerID, a.model.id)) ? 1 : 0) || + (recent.get(modelSelectionKey(a.model.providerID, a.model.id)) ?? Infinity) - + (recent.get(modelSelectionKey(b.model.providerID, b.model.id)) ?? Infinity) || + providerSortKey(a.model.providerID) - providerSortKey(b.model.providerID) || + a.model.providerName.localeCompare(b.model.providerName) || + a.model.name.localeCompare(b.model.name) || + a.model.id.localeCompare(b.model.id), + ) + .map((item) => item.model), + ) +} + +export function mostUsedModels( + models: readonly EnrichedModel[], + usage: ModelUsageMap | undefined, + favorites: ReadonlySet = new Set(), + limit = 5, +): EnrichedModel[] { + return models + .filter((model) => { + const item = usageFor(model, usage) + return item.count > 0 && !favorites.has(modelSelectionKey(model.providerID, model.id)) + }) + .sort((a, b) => { + const left = usageFor(a, usage) + const right = usageFor(b, usage) + return right.count - left.count || right.lastUsed - left.lastUsed || a.name.localeCompare(b.name) + }) + .slice(0, limit) +} + // Strips trailing "(free)" parenthesized suffix from model display names, e.g. // "Llama 3 (free)" → "Llama 3". A separate "Free" label/tag is rendered // elsewhere, so preserve bare trailing "Free" words (e.g. "Kilo Auto Free"). diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 54917021876..dcfd8e57e24 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -40,6 +40,7 @@ import type { SuggestionRequest, TodoItem, ModelSelection, + ModelUsageMap, ContextUsage, AgentInfo, SkillInfo, @@ -122,6 +123,7 @@ interface SessionStore { variantSelections: Record // session/agent scoped variant key -> variant name recentModels: ModelSelection[] favoriteModels: ModelSelection[] + modelUsageHistory: ModelUsageMap modelUsage: Record } @@ -244,6 +246,8 @@ interface SessionContextValue { selectVariant: (value: string, sessionID?: string) => void // Model favorites + recentModels: Accessor + modelUsageHistory: Accessor favoriteModels: Accessor toggleFavorite: (providerID: string, modelID: string) => void @@ -511,7 +515,6 @@ export const SessionProvider: ParentComponent = (props) => { ) } - // Store for sessions, messages, parts, todos, modelSelections, agentSelections const [store, setStore] = createStore({ sessions: {}, messages: {}, @@ -524,6 +527,7 @@ export const SessionProvider: ParentComponent = (props) => { variantSelections: {}, recentModels: [], favoriteModels: [], + modelUsageHistory: {}, modelUsage: {}, }) const [modelUsageReady, setModelUsageReady] = createSignal(false) @@ -632,6 +636,14 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "persistRecents", recents: updated }) } + function recordModelUsage(providerID?: string, modelID?: string) { + if (!providerID || !modelID) return + const key = `${providerID}/${modelID}` + const current = store.modelUsageHistory[key] ?? { count: 0, lastUsed: 0 } + setStore("modelUsageHistory", key, { count: current.count + 1, lastUsed: Date.now() }) + vscode.postMessage({ type: "recordModelUsage", providerID, modelID }) + } + function applyModel(agentName: string, selection: ModelSelection, sessionID?: string) { pushRecent(selection) if (sessionID) { @@ -965,6 +977,12 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "requestRecents" }) onCleanup(unsubRecents) + const unsubModelUsage = vscode.onMessage((message: ExtensionMessage) => { + if (message.type !== "modelUsageLoaded") return + setStore("modelUsageHistory", message.usage) + }) + vscode.postMessage({ type: "requestModelUsage" }) + onCleanup(unsubModelUsage) // Load persisted favorite models from extension globalState const unsubFavorites = vscode.onMessage((message: ExtensionMessage) => { if (message.type !== "favoritesLoaded") return @@ -2288,6 +2306,8 @@ export const SessionProvider: ParentComponent = (props) => { const messageID = Identifier.ascending("message") const sid = origin === undefined ? currentSessionID() : (origin ?? undefined) + const selection = providerID && modelID ? { providerID, modelID } : selected(sid) + recordModelUsage(selection?.providerID, selection?.modelID) const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) : origin === undefined @@ -2364,6 +2384,8 @@ export const SessionProvider: ParentComponent = (props) => { // Cloud previews need import-then-command; post importAndSend with command metadata const sid = origin === undefined ? currentSessionID() : (origin ?? undefined) + const selection = providerID && modelID ? { providerID, modelID } : selected(sid) + recordModelUsage(selection?.providerID, selection?.modelID) const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) : origin === undefined @@ -3025,6 +3047,8 @@ export const SessionProvider: ParentComponent = (props) => { allMessages, allParts, allStatusMap, + recentModels: () => store.recentModels, + modelUsageHistory: () => store.modelUsageHistory, favoriteModels: () => store.favoriteModels, toggleFavorite, variantList, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 5752f6b2a8c..4d8275234a0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -162,6 +162,8 @@ export const dict = { "model.group.auto": "Auto Models", "model.group.recommended": "Recommended", "model.group.favorites": "Favorites", + "model.group.mostUsed": "Most used", + "model.group.searchResults": "Search results", "model.favorite.add": "Add to favorites", "model.favorite.remove": "Remove from favorites", "model.preview.label.released": "Released", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 16361cf8f0d..51dba131c28 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -254,6 +254,8 @@ export function mockSessionValue(overrides?: { revertSession: noop, unrevertSession: noop, favoriteModels: () => [], + recentModels: () => [], + modelUsageHistory: () => ({}), toggleFavorite: noop, variantList: () => [], currentVariant: () => undefined, diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 859482cae8a..b371d909ab6 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -18,7 +18,7 @@ import type { AgentManagerSidebarTarget } from "./webview-messages" import type { PermissionRequest } from "./permissions" import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/anaconda-desktop-messages" import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions" -import type { ModelSelection, Provider, ProviderAuthState } from "./providers" +import type { ModelSelection, ModelUsageMap, Provider, ProviderAuthState } from "./providers" import type { SpeechToTextModelDef } from "../../../../src/speech-to-text/models" import type { AgentInfo, AgentRequirementResult, SkillInfo, SlashCommandInfo } from "./agents" import type { @@ -920,6 +920,11 @@ export interface RecentsLoadedMessage { recents: ModelSelection[] } +export interface ModelUsageLoadedMessage { + type: "modelUsageLoaded" + usage: ModelUsageMap +} + // Persisted model-selector expand/collapse preference (extension → webview) export interface ModelSelectorExpandedLoadedMessage { type: "modelSelectorExpandedLoaded" @@ -1298,6 +1303,7 @@ export type ExtensionMessage = | MessagesLoadedMessage | SessionModelUsageLoadedMessage | SessionModelUsageChangedMessage + | ModelUsageLoadedMessage | MessageCreatedMessage | SessionsLoadedMessage | CloudSessionsLoadedMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts index cb9b1094470..d2170190164 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts @@ -53,6 +53,13 @@ export interface ModelSelection { modelID: string } +export interface ModelUsage { + count: number + lastUsed: number +} + +export type ModelUsageMap = Record + export type ProviderAuthState = "api" | "oauth" | "wellknown" export interface ProviderConfig { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 48e5a5764ac..87d5599d118 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -1242,6 +1242,16 @@ export interface RequestRecentsMessage { type: "requestRecents" } +export interface RecordModelUsageMessage { + type: "recordModelUsage" + providerID: string + modelID: string +} + +export interface RequestModelUsageMessage { + type: "requestModelUsage" +} + export interface PersistModelSelectorExpandedRequest { type: "persistModelSelectorExpanded" value: boolean @@ -1559,6 +1569,8 @@ export type WebviewMessage = | FetchCustomProviderModelsMessage | PersistRecentsRequest | RequestRecentsMessage + | RecordModelUsageMessage + | RequestModelUsageMessage | PersistModelSelectorExpandedRequest | RequestModelSelectorExpandedMessage | ToggleFavoriteRequest From 8198118202f3f0fb35b923642be72e926b259608 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 13:50:02 +0200 Subject: [PATCH 72/78] fix(vscode): stabilize model search tests --- .../tests/model-selector-accessibility.spec.ts | 9 +++++---- .../webview-ui/src/components/shared/ModelSelector.tsx | 6 ++++-- .../src/components/shared/model-selector-utils.ts | 7 ++++--- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/br.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/da.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/de.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/es.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/fa.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/it.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/no.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/th.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 2 ++ packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 2 ++ 23 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index 0610185bcd1..785bf9faf2f 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -90,7 +90,7 @@ test("search uses a flat relevance-ranked result list with provider labels", asy const nova = page.getByRole("treeitem", { name: "Nova" }) await expect(nova).toBeVisible() await expect(combobox).toHaveAttribute("aria-activedescendant", await nova.getAttribute("id")) - await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveCount(0) + await expect(page.locator(".model-selector-group-label").filter({ hasText: "NVIDIA" })).toHaveCount(0) await expect(nova).toContainText("NVIDIA") }) @@ -142,12 +142,13 @@ test("active descendant always identifies a visible tree item", async ({ page }) await active() await combobox.fill("N") await active() - await combobox.press("ArrowLeft") await combobox.press("ArrowDown") - await combobox.press("ArrowLeft") await active() await combobox.fill("no matching model") - await active() + await expect(combobox).toHaveAttribute( + "aria-activedescendant", + await page.getByRole("treeitem", { name: "Use default model" }).getAttribute("id"), + ) }) test("expanded preview waits for explicit pointer selection", async ({ page }) => { diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 0df7934c366..d7e3d299fb5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -368,6 +368,7 @@ export const ModelSelectorBase: Component = (props) => { }) if (search()) { + if (filtered().length === 0) return [] return [ { key: "search-results", @@ -503,7 +504,7 @@ export const ModelSelectorBase: Component = (props) => { const match = list[0] const first = match ? canonicalKey(match) : null const next = - search() && first && rowMap().has(first) + search() && first ? first : canon && rowMap().has(canon) ? canon @@ -513,7 +514,7 @@ export const ModelSelectorBase: Component = (props) => { ? CLEAR_KEY : defaultKey() setSelectedKey(next) - setBrowsing(!!search() && nodeMap().has(next)) + setBrowsing(!!search() && (!!first || props.allowClear === true)) setNavigating(false) setPreActiveKey(next) setPreviewKey(next) @@ -656,6 +657,7 @@ export const ModelSelectorBase: Component = (props) => { } function horizontal(step: -1 | 1) { + if (search()) return const node = nodeMap().get(selectedKey()) if (!node) return if (node.kind === "group" && node.group) { diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts index dd21d4e1f88..d93d142e37b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts @@ -100,7 +100,7 @@ function matchScore(model: EnrichedModel, query: string): number | undefined { const scores = tokens.map((token) => { const modelScore = Math.max(tokenScore(token, name), tokenScore(token, model.id)) - const providerScore = tokenScore(token, model.providerName) + const providerScore = modelScore < 0 ? tokenScore(token, model.providerName) : -1 return { modelScore, providerScore } }) if (scores.some((score) => score.modelScore < 0 && score.providerScore < 0)) return undefined @@ -138,6 +138,7 @@ export function rankModelSearch( const groups = new Map< string, { + key: string score: number count: number lastUsed: number @@ -153,7 +154,7 @@ export function rankModelSearch( if (score === undefined) continue const usage = usageFor(model, options.usage) const key = logicalModelKey(model) - const group = groups.get(key) ?? { score, count: 0, lastUsed: 0, items: [] } + const group = groups.get(key) ?? { key, score, count: 0, lastUsed: 0, items: [] } group.score = Math.max(group.score, score) group.count += usage.count group.lastUsed = Math.max(group.lastUsed, usage.lastUsed) @@ -162,7 +163,7 @@ export function rankModelSearch( } return [...groups.values()] - .sort((a, b) => b.score - a.score || b.count - a.count || b.lastUsed - a.lastUsed) + .sort((a, b) => b.score - a.score || b.count - a.count || b.lastUsed - a.lastUsed || a.key.localeCompare(b.key)) .flatMap((group) => group.items .sort( diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 6c425940a8f..c3f332b1e19 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -158,6 +158,8 @@ export const dict = { "model.group.auto": "النماذج التلقائية", "model.group.recommended": "موصى به", "model.group.favorites": "المفضلة", + "model.group.mostUsed": "الأكثر استخدامًا", + "model.group.searchResults": "نتائج البحث", "model.favorite.add": "إضافة إلى المفضلة", "model.favorite.remove": "إزالة من المفضلة", "model.preview.label.released": "الإصدار", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 1a7d9ae17ba..6bd88c36cad 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -162,6 +162,8 @@ export const dict = { "model.group.auto": "Modelos automáticos", "model.group.recommended": "Recomendado", "model.group.favorites": "Favoritos", + "model.group.mostUsed": "Mais usados", + "model.group.searchResults": "Resultados da pesquisa", "model.favorite.add": "Adicionar aos favoritos", "model.favorite.remove": "Remover dos favoritos", "model.preview.label.released": "Lançado", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 509f0265201..ea88f370586 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -163,6 +163,8 @@ export const dict = { "model.group.auto": "Automatski modeli", "model.group.recommended": "Preporučeno", "model.group.favorites": "Favoriti", + "model.group.mostUsed": "Najčešće korišteni", + "model.group.searchResults": "Rezultati pretrage", "model.favorite.add": "Dodaj u favorite", "model.favorite.remove": "Ukloni iz favorita", "model.preview.label.released": "Objavljeno", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 4f1d6c9034c..abb5040a715 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -162,6 +162,8 @@ export const dict = { "model.group.auto": "Automatiske modeller", "model.group.recommended": "Anbefalet", "model.group.favorites": "Favoritter", + "model.group.mostUsed": "Mest brugte", + "model.group.searchResults": "Søgeresultater", "model.favorite.add": "Føj til favoritter", "model.favorite.remove": "Fjern fra favoritter", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 7c1366c7261..768e99a5f24 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -169,6 +169,8 @@ export const dict = { "model.group.auto": "Automatische Modelle", "model.group.recommended": "Empfohlen", "model.group.favorites": "Favoriten", + "model.group.mostUsed": "Am häufigsten verwendet", + "model.group.searchResults": "Suchergebnisse", "model.favorite.add": "Zu Favoriten hinzufügen", "model.favorite.remove": "Aus Favoriten entfernen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 2b7ed694b0b..a6690df4343 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -164,6 +164,8 @@ export const dict = { "model.group.auto": "Modelos automáticos", "model.group.recommended": "Recomendado", "model.group.favorites": "Favoritos", + "model.group.mostUsed": "Más usados", + "model.group.searchResults": "Resultados de búsqueda", "model.favorite.add": "Añadir a favoritos", "model.favorite.remove": "Eliminar de favoritos", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 71b97a2d3ff..69658aa25b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -163,6 +163,8 @@ export const dict = { "model.group.auto": "مدل‌های خودکار", "model.group.recommended": "پیشنهادی", "model.group.favorites": "موردعلاقه‌ها", + "model.group.mostUsed": "پراستفاده‌ترین", + "model.group.searchResults": "نتایج جستجو", "model.favorite.add": "افزودن به موردعلاقه‌ها", "model.favorite.remove": "حذف از موردعلاقه‌ها", "model.preview.label.released": "منتشر شده", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 3fd9956d9de..9757d189a63 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -163,6 +163,8 @@ export const dict = { "model.group.auto": "Modèles automatiques", "model.group.recommended": "Recommandé", "model.group.favorites": "Favoris", + "model.group.mostUsed": "Les plus utilisés", + "model.group.searchResults": "Résultats de recherche", "model.favorite.add": "Ajouter aux favoris", "model.favorite.remove": "Retirer des favoris", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index b8ec52156f4..876a7a0c6e4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -120,6 +120,8 @@ export const dict = { "model.group.auto": "Modelli automatici", "model.group.recommended": "Consigliati", "model.group.favorites": "Preferiti", + "model.group.mostUsed": "Più usati", + "model.group.searchResults": "Risultati di ricerca", "model.favorite.add": "Aggiungi ai preferiti", "model.favorite.remove": "Rimuovi dai preferiti", "model.preview.label.released": "Rilasciato", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index fa1eda6f6b0..9211269a739 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -162,6 +162,8 @@ export const dict = { "model.group.auto": "自動モデル", "model.group.recommended": "推奨", "model.group.favorites": "お気に入り", + "model.group.mostUsed": "よく使うモデル", + "model.group.searchResults": "検索結果", "model.favorite.add": "お気に入りに追加", "model.favorite.remove": "お気に入りから削除", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 360ed69cb00..91856758e76 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -166,6 +166,8 @@ export const dict = { "model.group.auto": "자동 모델", "model.group.recommended": "추천", "model.group.favorites": "즐겨찾기", + "model.group.mostUsed": "가장 많이 사용됨", + "model.group.searchResults": "검색 결과", "model.favorite.add": "즐겨찾기에 추가", "model.favorite.remove": "즐겨찾기에서 제거", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 64f70685b4c..d64ca94a22e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -164,6 +164,8 @@ export const dict = { "model.group.auto": "Automatische modellen", "model.group.recommended": "Aanbevolen", "model.group.favorites": "Favorieten", + "model.group.mostUsed": "Meest gebruikt", + "model.group.searchResults": "Zoekresultaten", "model.favorite.add": "Toevoegen aan favorieten", "model.favorite.remove": "Verwijderen uit favorieten", "model.preview.label.released": "Uitgebracht", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 103de2edff6..271363e9d4e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -165,6 +165,8 @@ export const dict = { "model.group.auto": "Automatiske modeller", "model.group.recommended": "Anbefalt", "model.group.favorites": "Favoritter", + "model.group.mostUsed": "Mest brukt", + "model.group.searchResults": "Søkeresultater", "model.favorite.add": "Legg til i favoritter", "model.favorite.remove": "Fjern fra favoritter", "model.preview.label.released": "Utgitt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 18830cde9be..9b2b008f351 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -162,6 +162,8 @@ export const dict = { "model.group.auto": "Modele automatyczne", "model.group.recommended": "Zalecane", "model.group.favorites": "Ulubione", + "model.group.mostUsed": "Najczęściej używane", + "model.group.searchResults": "Wyniki wyszukiwania", "model.favorite.add": "Dodaj do ulubionych", "model.favorite.remove": "Usuń z ulubionych", "model.preview.label.released": "Wydano", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 5eca69def5d..4fb5297ab80 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -162,6 +162,8 @@ export const dict = { "model.group.auto": "Автоматические модели", "model.group.recommended": "Рекомендуемые", "model.group.favorites": "Избранное", + "model.group.mostUsed": "Часто используемые", + "model.group.searchResults": "Результаты поиска", "model.favorite.add": "Добавить в избранное", "model.favorite.remove": "Удалить из избранного", "model.preview.label.released": "Выпущена", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index c4dbc68bcf5..77dd38eeaf1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -161,6 +161,8 @@ export const dict = { "model.group.auto": "โมเดลอัตโนมัติ", "model.group.recommended": "แนะนำ", "model.group.favorites": "รายการโปรด", + "model.group.mostUsed": "ใช้บ่อยที่สุด", + "model.group.searchResults": "ผลการค้นหา", "model.favorite.add": "เพิ่มในรายการโปรด", "model.favorite.remove": "ลบออกจากรายการโปรด", "model.preview.label.released": "เปิดตัว", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 0761b27b94a..49c5cf52a42 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -162,6 +162,8 @@ export const dict = { "model.group.auto": "Otomatik Modeller", "model.group.recommended": "Önerilen", "model.group.favorites": "Favoriler", + "model.group.mostUsed": "En çok kullanılan", + "model.group.searchResults": "Arama sonuçları", "model.favorite.add": "Favorilere ekle", "model.favorite.remove": "Favorilerden çıkar", "model.preview.label.released": "Yayınlanma", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index b4b8c529d24..e0c1309cae8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -163,6 +163,8 @@ export const dict = { "model.group.auto": "Автоматичні моделі", "model.group.recommended": "Рекомендовані", "model.group.favorites": "Обране", + "model.group.mostUsed": "Найчастіше використовувані", + "model.group.searchResults": "Результати пошуку", "model.favorite.add": "Додати до обраного", "model.favorite.remove": "Видалити з обраного", "model.preview.label.released": "Випущено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 041c72d116b..8bc98c1e910 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -158,6 +158,8 @@ export const dict = { "model.group.auto": "自动模型", "model.group.recommended": "推荐", "model.group.favorites": "收藏夹", + "model.group.mostUsed": "最常用", + "model.group.searchResults": "搜索结果", "model.favorite.add": "添加到收藏夹", "model.favorite.remove": "从收藏夹中移除", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 52ecca5fa9e..eb7dae5e911 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -158,6 +158,8 @@ export const dict = { "model.group.auto": "自動模型", "model.group.recommended": "推薦", "model.group.favorites": "我的最愛", + "model.group.mostUsed": "最常用", + "model.group.searchResults": "搜尋結果", "model.favorite.add": "加入我的最愛", "model.favorite.remove": "從我的最愛中移除", From e7c169456f25b4e881be94a367d7fe5f931995d6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 13:55:08 +0200 Subject: [PATCH 73/78] fix(vscode): address model search review feedback --- .../src/components/shared/ModelSelector.tsx | 44 +++++++++++-------- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/br.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/bs.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/da.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/de.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/en.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/es.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/fa.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/fr.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/it.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/ja.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/ko.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/nl.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/no.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/pl.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/ru.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/th.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/tr.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/uk.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/zh.ts | 1 - .../kilo-vscode/webview-ui/src/i18n/zht.ts | 1 - .../webview-ui/src/stories/shared.stories.tsx | 22 ++++++++++ 23 files changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index d7e3d299fb5..74e43dff552 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -84,7 +84,7 @@ interface ModelRow { interface ModelGroup { key: string - label: string + label?: string rows: ModelRow[] } @@ -163,6 +163,7 @@ export const ModelSelectorBase: Component = (props) => { const expanded = vscode.getModelSelectorExpanded const setExpanded = vscode.setModelSelectorExpanded const [search, setSearch] = createSignal("") + const hasSearch = () => search().trim().length > 0 const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY) const [browsing, setBrowsing] = createSignal(false) const [navigating, setNavigating] = createSignal(false) @@ -253,7 +254,7 @@ export const ModelSelectorBase: Component = (props) => { const favoriteModels = createMemo(() => { if (props.favorites === false) return [] - if (!session || search()) return [] + if (!session || hasSearch()) return [] const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m])) const list = session .favoriteModels() @@ -274,8 +275,14 @@ export const ModelSelectorBase: Component = (props) => { const mostUsed: EnrichedModel[] = [] const map = new Map() - if (!search() && session) { - mostUsed.push(...mostUsedModels(visibleModels(), session.modelUsageHistory(), favoriteKeys())) + if (!hasSearch() && session) { + mostUsed.push( + ...mostUsedModels( + visibleModels().filter((model) => !isAuto(model) && model.recommendedIndex === undefined), + session.modelUsageHistory(), + favoriteKeys(), + ), + ) } for (const m of filtered()) { @@ -283,7 +290,10 @@ export const ModelSelectorBase: Component = (props) => { autos.push(m) continue } - if (!search() && mostUsed.some((item) => modelKey(item.providerID, item.id) === modelKey(m.providerID, m.id))) { + if ( + !hasSearch() && + mostUsed.some((item) => modelKey(item.providerID, item.id) === modelKey(m.providerID, m.id)) + ) { continue } if (m.recommendedIndex !== undefined) { @@ -367,12 +377,11 @@ export const ModelSelectorBase: Component = (props) => { } }) - if (search()) { + if (hasSearch()) { if (filtered().length === 0) return [] return [ { key: "search-results", - label: language.t("model.group.searchResults"), rows: filtered().map((m) => ({ key: rowKey("model", m.providerID, m.id), kind: "model", @@ -385,8 +394,7 @@ export const ModelSelectorBase: Component = (props) => { return [...result, ...rest] }) - // Collapse state is honored even during search so users can skip past - // large providers (e.g. Kilo Gateway) without scrolling through every match. + // Search results are flattened so matching provider variants stay adjacent. const isGroupOpen = (key: string) => !collapsed().has(key) function toggleGroup(key: string) { @@ -405,7 +413,7 @@ export const ModelSelectorBase: Component = (props) => { const rows = createMemo(() => { const c = collapsed() - const list = groups().flatMap((g) => (search() || !c.has(g.key) ? g.rows : [])) + const list = groups().flatMap((g) => (hasSearch() || !c.has(g.key) ? g.rows : [])) if (!props.allowClear) return list return [{ key: CLEAR_KEY, kind: "clear" }, ...list] }) @@ -414,7 +422,7 @@ export const ModelSelectorBase: Component = (props) => { const result: ModelNode[] = [] if (props.allowClear) result.push({ key: CLEAR_KEY, kind: "row", row: { key: CLEAR_KEY, kind: "clear" } }) for (const group of groups()) { - if (search()) { + if (hasSearch()) { result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group }))) continue } @@ -441,7 +449,7 @@ export const ModelSelectorBase: Component = (props) => { if (!m) return props.allowClear ? CLEAR_KEY : defaultKey() const key = modelKey(m.providerID, m.id) const favorite = favoriteKey(m) - if (!search() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite + if (!hasSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite return canonicalKey(m) } const chosen = (row: ModelRow) => { @@ -504,7 +512,7 @@ export const ModelSelectorBase: Component = (props) => { const match = list[0] const first = match ? canonicalKey(match) : null const next = - search() && first + hasSearch() && first ? first : canon && rowMap().has(canon) ? canon @@ -514,7 +522,7 @@ export const ModelSelectorBase: Component = (props) => { ? CLEAR_KEY : defaultKey() setSelectedKey(next) - setBrowsing(!!search() && (!!first || props.allowClear === true)) + setBrowsing(hasSearch() && (!!first || props.allowClear === true)) setNavigating(false) setPreActiveKey(next) setPreviewKey(next) @@ -657,10 +665,10 @@ export const ModelSelectorBase: Component = (props) => { } function horizontal(step: -1 | 1) { - if (search()) return + if (hasSearch()) return const node = nodeMap().get(selectedKey()) if (!node) return - if (node.kind === "group" && node.group) { + if (node.kind === "group" && node.group && node.group.label) { if (step === -1 && isGroupOpen(node.group.key)) { toggleGroup(node.group.key) return @@ -971,7 +979,7 @@ export const ModelSelectorBase: Component = (props) => { {group.label} - +
@@ -1003,7 +1011,7 @@ export const ModelSelectorBase: Component = (props) => { const hovered = () => isSelected(row.key) const preActive = () => isPreActive(row.key) const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id)) - const showProvider = () => row.kind === "favorite" || !!search() + const showProvider = () => row.kind === "favorite" || hasSearch() const showSelect = () => expanded() && preActive() && !isActive(model) const starLabel = () => `${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}` diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index c3f332b1e19..78cdba37faa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -159,7 +159,6 @@ export const dict = { "model.group.recommended": "موصى به", "model.group.favorites": "المفضلة", "model.group.mostUsed": "الأكثر استخدامًا", - "model.group.searchResults": "نتائج البحث", "model.favorite.add": "إضافة إلى المفضلة", "model.favorite.remove": "إزالة من المفضلة", "model.preview.label.released": "الإصدار", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 6bd88c36cad..ed475d19fa7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -163,7 +163,6 @@ export const dict = { "model.group.recommended": "Recomendado", "model.group.favorites": "Favoritos", "model.group.mostUsed": "Mais usados", - "model.group.searchResults": "Resultados da pesquisa", "model.favorite.add": "Adicionar aos favoritos", "model.favorite.remove": "Remover dos favoritos", "model.preview.label.released": "Lançado", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index ea88f370586..bb27ffe0ee7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -164,7 +164,6 @@ export const dict = { "model.group.recommended": "Preporučeno", "model.group.favorites": "Favoriti", "model.group.mostUsed": "Najčešće korišteni", - "model.group.searchResults": "Rezultati pretrage", "model.favorite.add": "Dodaj u favorite", "model.favorite.remove": "Ukloni iz favorita", "model.preview.label.released": "Objavljeno", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index abb5040a715..7badc2001f6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -163,7 +163,6 @@ export const dict = { "model.group.recommended": "Anbefalet", "model.group.favorites": "Favoritter", "model.group.mostUsed": "Mest brugte", - "model.group.searchResults": "Søgeresultater", "model.favorite.add": "Føj til favoritter", "model.favorite.remove": "Fjern fra favoritter", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 768e99a5f24..f7899cba210 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -170,7 +170,6 @@ export const dict = { "model.group.recommended": "Empfohlen", "model.group.favorites": "Favoriten", "model.group.mostUsed": "Am häufigsten verwendet", - "model.group.searchResults": "Suchergebnisse", "model.favorite.add": "Zu Favoriten hinzufügen", "model.favorite.remove": "Aus Favoriten entfernen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 4d8275234a0..316caa5c7c7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -163,7 +163,6 @@ export const dict = { "model.group.recommended": "Recommended", "model.group.favorites": "Favorites", "model.group.mostUsed": "Most used", - "model.group.searchResults": "Search results", "model.favorite.add": "Add to favorites", "model.favorite.remove": "Remove from favorites", "model.preview.label.released": "Released", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index a6690df4343..30554b98498 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -165,7 +165,6 @@ export const dict = { "model.group.recommended": "Recomendado", "model.group.favorites": "Favoritos", "model.group.mostUsed": "Más usados", - "model.group.searchResults": "Resultados de búsqueda", "model.favorite.add": "Añadir a favoritos", "model.favorite.remove": "Eliminar de favoritos", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 69658aa25b4..bae8578feef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -164,7 +164,6 @@ export const dict = { "model.group.recommended": "پیشنهادی", "model.group.favorites": "موردعلاقه‌ها", "model.group.mostUsed": "پراستفاده‌ترین", - "model.group.searchResults": "نتایج جستجو", "model.favorite.add": "افزودن به موردعلاقه‌ها", "model.favorite.remove": "حذف از موردعلاقه‌ها", "model.preview.label.released": "منتشر شده", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 9757d189a63..d289df1ae98 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -164,7 +164,6 @@ export const dict = { "model.group.recommended": "Recommandé", "model.group.favorites": "Favoris", "model.group.mostUsed": "Les plus utilisés", - "model.group.searchResults": "Résultats de recherche", "model.favorite.add": "Ajouter aux favoris", "model.favorite.remove": "Retirer des favoris", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 876a7a0c6e4..142a0e04057 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -121,7 +121,6 @@ export const dict = { "model.group.recommended": "Consigliati", "model.group.favorites": "Preferiti", "model.group.mostUsed": "Più usati", - "model.group.searchResults": "Risultati di ricerca", "model.favorite.add": "Aggiungi ai preferiti", "model.favorite.remove": "Rimuovi dai preferiti", "model.preview.label.released": "Rilasciato", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 9211269a739..8eb6c5bf09d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -163,7 +163,6 @@ export const dict = { "model.group.recommended": "推奨", "model.group.favorites": "お気に入り", "model.group.mostUsed": "よく使うモデル", - "model.group.searchResults": "検索結果", "model.favorite.add": "お気に入りに追加", "model.favorite.remove": "お気に入りから削除", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 91856758e76..080af3882e6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -167,7 +167,6 @@ export const dict = { "model.group.recommended": "추천", "model.group.favorites": "즐겨찾기", "model.group.mostUsed": "가장 많이 사용됨", - "model.group.searchResults": "검색 결과", "model.favorite.add": "즐겨찾기에 추가", "model.favorite.remove": "즐겨찾기에서 제거", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index d64ca94a22e..6c3ffaafcdf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -165,7 +165,6 @@ export const dict = { "model.group.recommended": "Aanbevolen", "model.group.favorites": "Favorieten", "model.group.mostUsed": "Meest gebruikt", - "model.group.searchResults": "Zoekresultaten", "model.favorite.add": "Toevoegen aan favorieten", "model.favorite.remove": "Verwijderen uit favorieten", "model.preview.label.released": "Uitgebracht", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 271363e9d4e..818d41a3a92 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -166,7 +166,6 @@ export const dict = { "model.group.recommended": "Anbefalt", "model.group.favorites": "Favoritter", "model.group.mostUsed": "Mest brukt", - "model.group.searchResults": "Søkeresultater", "model.favorite.add": "Legg til i favoritter", "model.favorite.remove": "Fjern fra favoritter", "model.preview.label.released": "Utgitt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 9b2b008f351..bc9524b1741 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -163,7 +163,6 @@ export const dict = { "model.group.recommended": "Zalecane", "model.group.favorites": "Ulubione", "model.group.mostUsed": "Najczęściej używane", - "model.group.searchResults": "Wyniki wyszukiwania", "model.favorite.add": "Dodaj do ulubionych", "model.favorite.remove": "Usuń z ulubionych", "model.preview.label.released": "Wydano", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 4fb5297ab80..49c4e2929fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -163,7 +163,6 @@ export const dict = { "model.group.recommended": "Рекомендуемые", "model.group.favorites": "Избранное", "model.group.mostUsed": "Часто используемые", - "model.group.searchResults": "Результаты поиска", "model.favorite.add": "Добавить в избранное", "model.favorite.remove": "Удалить из избранного", "model.preview.label.released": "Выпущена", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 77dd38eeaf1..0bb9971a3ef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -162,7 +162,6 @@ export const dict = { "model.group.recommended": "แนะนำ", "model.group.favorites": "รายการโปรด", "model.group.mostUsed": "ใช้บ่อยที่สุด", - "model.group.searchResults": "ผลการค้นหา", "model.favorite.add": "เพิ่มในรายการโปรด", "model.favorite.remove": "ลบออกจากรายการโปรด", "model.preview.label.released": "เปิดตัว", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 49c5cf52a42..dadc3f93ad8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -163,7 +163,6 @@ export const dict = { "model.group.recommended": "Önerilen", "model.group.favorites": "Favoriler", "model.group.mostUsed": "En çok kullanılan", - "model.group.searchResults": "Arama sonuçları", "model.favorite.add": "Favorilere ekle", "model.favorite.remove": "Favorilerden çıkar", "model.preview.label.released": "Yayınlanma", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index e0c1309cae8..a294e45fe5c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -164,7 +164,6 @@ export const dict = { "model.group.recommended": "Рекомендовані", "model.group.favorites": "Обране", "model.group.mostUsed": "Найчастіше використовувані", - "model.group.searchResults": "Результати пошуку", "model.favorite.add": "Додати до обраного", "model.favorite.remove": "Видалити з обраного", "model.preview.label.released": "Випущено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 8bc98c1e910..afa17238a53 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -159,7 +159,6 @@ export const dict = { "model.group.recommended": "推荐", "model.group.favorites": "收藏夹", "model.group.mostUsed": "最常用", - "model.group.searchResults": "搜索结果", "model.favorite.add": "添加到收藏夹", "model.favorite.remove": "从收藏夹中移除", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index eb7dae5e911..3cb5f1c1521 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -159,7 +159,6 @@ export const dict = { "model.group.recommended": "推薦", "model.group.favorites": "我的最愛", "model.group.mostUsed": "最常用", - "model.group.searchResults": "搜尋結果", "model.favorite.add": "加入我的最愛", "model.favorite.remove": "從我的最愛中移除", diff --git a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx index 81b27047dba..48d6bd94a8a 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx @@ -131,6 +131,28 @@ export const ModelSelectorSelectedFavorite: Story = { }, } +export const ModelSelectorMostUsed: Story = { + name: "ModelSelector - most used suggestions", + render: () => { + const session = { + ...mockSessionValue(), + modelUsageHistory: () => ({ + "kilo/alpha": { count: 3, lastUsed: 100 }, + "kilo/bravo": { count: 12, lastUsed: 200 }, + "nvidia/nova": { count: 7, lastUsed: 300 }, + }), + } + + return ( + + + + + + ) + }, +} + const LARGE_MODELS: EnrichedModel[] = Array.from({ length: 600 }, (_, i) => { const id = String(i).padStart(3, "0") const provider = `provider-${i % 12}` From 8168c09901eb0c7542ddc643a16a2f626af31b92 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 6 Aug 2026 11:58:48 +0000 Subject: [PATCH 74/78] chore: update kilo-vscode visual regression baselines --- .../shared/model-selector-most-used-chromium-linux.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-most-used-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-most-used-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-most-used-chromium-linux.png new file mode 100644 index 00000000000..f6894817821 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-most-used-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838 +size 1085 From 24da90ff579dcbac6c5d8c5930f9bffb6da66f26 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 14:03:07 +0200 Subject: [PATCH 75/78] fix(agent-manager): support llama.cpp tool schema --- .changeset/friendly-llamas-manage.md | 5 +++++ packages/opencode/src/kilocode/tool/agent-manager.ts | 2 +- .../opencode/test/kilocode/agent-manager-tool.test.ts | 10 ++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .changeset/friendly-llamas-manage.md diff --git a/.changeset/friendly-llamas-manage.md b/.changeset/friendly-llamas-manage.md new file mode 100644 index 00000000000..494e5cc0658 --- /dev/null +++ b/.changeset/friendly-llamas-manage.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Support the Agent Manager tool with llama.cpp servers that reject prefix-only JSON Schema patterns. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index ba6e67619e9..99dc9fc5b09 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -117,7 +117,7 @@ const WireParams = Schema.Struct({ ), filter: Schema.optional(ListParams.fields.filter), sessionID: Schema.optional( - SessionID.annotate({ description: "For move, use a session ID returned by action=list." }), + Schema.String.annotate({ description: "For move, use a session ID returned by action=list." }), ), prompt: Schema.optional(PromptParams.fields.prompt), sectionID: Schema.optional(MoveParams.fields.sectionID), diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index ccba13be01d..59518cc6501 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -1,10 +1,10 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime, Queue } from "effect" +import { Effect, Layer, ManagedRuntime, Queue, Schema } from "effect" import { MessageID, SessionID } from "../../src/session/schema" import { provideTmpdirInstance } from "../fixture/fixture" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { AgentManagerTool } from "../../src/kilocode/tool/agent-manager" +import { AgentManagerTool, Params } from "../../src/kilocode/tool/agent-manager" import { AgentManagerEvent, type AgentManagerStart } from "../../src/kilocode/agent-manager/event" import { AgentManager } from "../../src/kilocode/agent-manager/service" import { Bus } from "../../src/bus" @@ -166,6 +166,7 @@ describe("agent_manager tool", () => { expect(schema.properties?.sessionID).toEqual( expect.objectContaining({ description: expect.stringContaining("returned by action=list") }), ) + expect(schema.properties?.sessionID).not.toHaveProperty("pattern") expect(schema.properties?.sectionID).toEqual( expect.objectContaining({ description: expect.stringContaining("Use null to unassign") }), ) @@ -186,6 +187,11 @@ describe("agent_manager tool", () => { ]) }) + test("keeps session ID validation local", () => { + expect(Schema.is(Params)({ action: "stop", sessionID: "ses_target" })).toBe(true) + expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false) + }) + test("asks for agent_manager permission", async () => { const tool = await init() const calls: unknown[] = [] From 154b1ae53ca1a4bca1aa4c43f4ef95fe6cafaa75 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 6 Aug 2026 14:08:24 +0200 Subject: [PATCH 76/78] fix(cli): avoid startup database lock crashes --- .changeset/safe-credential-reconciliation.md | 5 ++ packages/core/src/credential.ts | 29 ++++++- packages/core/src/kilocode/database-compat.ts | 36 +++++---- packages/core/src/kilocode/sqlite-error.ts | 10 +++ packages/core/test/credential.test.ts | 78 +++++++++++++++++++ .../database-migration-compat.test.ts | 17 ++++ .../src/sqlite-core/effect/session.ts | 8 +- .../effect-drizzle-sqlite/test/sqlite.test.ts | 18 +++++ .../src/kilocode/database/sqlite-error.ts | 8 +- .../kilocode/database/sqlite-error.test.ts | 20 +++++ 10 files changed, 208 insertions(+), 21 deletions(-) create mode 100644 .changeset/safe-credential-reconciliation.md create mode 100644 packages/core/src/kilocode/sqlite-error.ts diff --git a/.changeset/safe-credential-reconciliation.md b/.changeset/safe-credential-reconciliation.md new file mode 100644 index 00000000000..abb8dd5f36a --- /dev/null +++ b/.changeset/safe-credential-reconciliation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent concurrent Kilo startups from rewriting unchanged credentials, retry transient database locks, and redact bound values from database errors. diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index bed9bb25314..104b9897eb4 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -15,6 +15,7 @@ import { Global } from "./global" import { DataMigrationTable } from "./data-migration.sql" import path from "path" import { parse as parseKiloAccounts } from "./kilocode/credential-migration" +import { isBusy } from "./kilocode/sqlite-error" import { NonNegativeInt } from "./schema" // kilocode_change end @@ -170,6 +171,17 @@ export const legacyImportLayer = Layer.effectDiscard( const integration = Integration.ID.make(integrationID.replace(/\/+$/, "")) return [{ integration, value: legacyValue(integration, decoded.value) }] }) + const migrated = yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get() + const existing = yield* db.select().from(CredentialTable).orderBy(desc(CredentialTable.time_created)).all() + const same = (left: Value, right: Value) => JSON.stringify(left) === JSON.stringify(right) + if ( + migrated && + values.every((item) => { + const current = existing.find((row) => row.integration_id === item.integration) + return current !== undefined && same(current.value, item.value) + }) + ) + return yield* db.transaction((tx) => Effect.gen(function* () { for (const item of values) { @@ -181,7 +193,12 @@ export const legacyImportLayer = Layer.effectDiscard( .orderBy(desc(CredentialTable.time_created)) // kilocode_change - reconcile the active imported account .get() if (current) { - yield* tx.update(CredentialTable).set({ value: item.value }).where(eq(CredentialTable.id, current.id)).run() + if (!same(current.value, item.value)) + yield* tx + .update(CredentialTable) + .set({ value: item.value }) + .where(eq(CredentialTable.id, current.id)) + .run() continue } yield* tx.insert(CredentialTable).values({ @@ -194,7 +211,15 @@ export const legacyImportLayer = Layer.effectDiscard( yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run() }), ) - }).pipe(Effect.orDie), + }).pipe( + Effect.retry({ while: isBusy, times: 2 }), + Effect.catch((error) => + isBusy(error) + ? Effect.logWarning("legacy credential reconciliation deferred because the database is busy") + : Effect.fail(error), + ), + Effect.orDie, + ), ) // kilocode_change end diff --git a/packages/core/src/kilocode/database-compat.ts b/packages/core/src/kilocode/database-compat.ts index 9982d3d7285..733b2eece88 100644 --- a/packages/core/src/kilocode/database-compat.ts +++ b/packages/core/src/kilocode/database-compat.ts @@ -4,19 +4,29 @@ import type { Database } from "../database/database" type Db = Database.Interface["db"] export function ensure(db: Db) { - return db.transaction( - (tx) => - Effect.gen(function* () { - const rows = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')") - const names = new Set(rows.map((row) => row.name)) + const load = db.all<{ name: string }>("PRAGMA table_info('session_context_epoch')") + const ready = (rows: { name: string }[]) => { + const names = new Set(rows.map((row) => row.name)) + return ["agent", "replacement_seq", "revision"].every((name) => names.has(name)) + } + return load.pipe( + Effect.flatMap((rows) => { + if (ready(rows)) return Effect.void + return db.transaction( + (tx) => + Effect.gen(function* () { + const current = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')") + const names = new Set(current.map((row) => row.name)) - if (!names.has("agent")) - yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL") - if (!names.has("replacement_seq")) - yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer") - if (!names.has("revision")) - yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL") - }), - { behavior: "immediate" }, + if (!names.has("agent")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL") + if (!names.has("replacement_seq")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer") + if (!names.has("revision")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL") + }), + { behavior: "immediate" }, + ) + }), ) } diff --git a/packages/core/src/kilocode/sqlite-error.ts b/packages/core/src/kilocode/sqlite-error.ts new file mode 100644 index 00000000000..60f52b531c0 --- /dev/null +++ b/packages/core/src/kilocode/sqlite-error.ts @@ -0,0 +1,10 @@ +import { Cause, Option } from "effect" +import { isSqlError } from "effect/unstable/sql/SqlError" + +export function isBusy(error: unknown): boolean { + if (isSqlError(error)) return error.reason._tag === "LockTimeoutError" + if (typeof error !== "object" || error === null || !("cause" in error) || error.cause === error) return false + if (!Cause.isCause(error.cause)) return isBusy(error.cause) + const failure = Cause.findErrorOption(error.cause) + return Option.isSome(failure) && isBusy(failure.value) +} diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index e6cbf820bb1..164f297cbc7 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -1,11 +1,15 @@ import path from "path" +import { Database as SQLite } from "bun:sqlite" // kilocode_change import { describe, expect } from "bun:test" +import { eq } from "drizzle-orm" // kilocode_change import { Effect, Layer } from "effect" import { Credential } from "@opencode-ai/core/credential" +import { CredentialTable } from "@opencode-ai/core/credential/sql" // kilocode_change import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Integration } from "@opencode-ai/core/integration" // kilocode_change start import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" // kilocode_change end import { tmpdir } from "./fixture/tmpdir" @@ -20,6 +24,16 @@ function localLayer(directory: string) { ) } +// kilocode_change start +function importer(dir: string, store: Database.Interface) { + return Credential.legacyImportLayer.pipe( + Layer.provide(Layer.succeed(Database.Service, store)), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.layerWith({ data: dir })), + ) +} +// kilocode_change end + describe("Credential", () => { it.live("stores, updates, lists, and removes credentials", () => Effect.acquireUseRelease( @@ -196,6 +210,70 @@ describe("Credential", () => { ), ) + it.live("skips unchanged legacy writes and defers locked reconciliation", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + const file = path.join(tmp.path, "credential.db") + const auth = path.join(tmp.path, "auth.json") + const write = (key: string) => + Effect.promise(() => Bun.write(auth, JSON.stringify({ kilo: { type: "api", key } }))) + return Effect.gen(function* () { + yield* write("first") + const store = yield* Database.Service + const layer = importer(tmp.path, store) + yield* Layer.build(Layer.fresh(layer)) + + const before = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + yield* Layer.build(Layer.fresh(layer)) + const unchanged = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + expect(unchanged?.time_updated).toBe(before?.time_updated) + + yield* write("second") + yield* store.db.run("PRAGMA busy_timeout = 0") + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const holder = new SQLite(file) + holder.run("PRAGMA busy_timeout = 0") + holder.run("BEGIN IMMEDIATE") + return holder + }), + () => Layer.build(Layer.fresh(layer)), + (holder) => + Effect.sync(() => { + if (holder.inTransaction) holder.run("ROLLBACK") + holder.close() + }), + ) + + const stale = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + expect(stale?.value).toMatchObject({ type: "key", key: "first" }) + + yield* Layer.build(Layer.fresh(layer)) + const reconciled = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + expect(reconciled?.value).toMatchObject({ type: "key", key: "second" }) + }).pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("dual-writes stored credentials for released auth.json readers", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/kilocode/database-migration-compat.test.ts b/packages/core/test/kilocode/database-migration-compat.test.ts index 8546df16b37..83d35cf1666 100644 --- a/packages/core/test/kilocode/database-migration-compat.test.ts +++ b/packages/core/test/kilocode/database-migration-compat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Database as SQLite } from "bun:sqlite" import { SqliteClient } from "@effect/sql-sqlite-bun" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { DatabaseMigration } from "@opencode-ai/core/database/migration" @@ -159,6 +160,22 @@ describe("database migration compatibility", () => { sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'session'`, ), ).toEqual({ agent: "build", replacementSeq: 4, revision: 1 }) + + yield* db.run("PRAGMA busy_timeout = 0") + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const holder = new SQLite(filename) + holder.run("PRAGMA busy_timeout = 0") + holder.run("BEGIN IMMEDIATE") + return holder + }), + () => ensure(db), + (holder) => + Effect.sync(() => { + if (holder.inTransaction) holder.run("ROLLBACK") + holder.close() + }), + ) }), ).pipe(Effect.provide(Database.layerFromPath(filename)), Effect.scoped), ) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts index 15a56f2ca7d..535b232ac4b 100644 --- a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts @@ -279,7 +279,13 @@ export class SQLiteEffectPreparedQuery< assertUnreachable(cacheStrat) }).pipe( Effect.catch((e) => { - return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })) + return Effect.fail( + new EffectDrizzleQueryError({ + query: queryString, + params: params.map(() => ""), // kilocode_change - bound values may contain credentials + cause: Cause.fail(e), + }), + ) }), ) } diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts index 5303ee069ac..0148a1b9d4d 100644 --- a/packages/effect-drizzle-sqlite/test/sqlite.test.ts +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -130,6 +130,24 @@ test("preserves failed transaction begin errors", async () => { } }) +// kilocode_change start - query errors must never expose bound credential values +test("redacts bound values from query errors", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + const secret = "must-not-leak" + yield* db.insert(users).values({ id: 1, name: "Ada" }) + + const error = yield* db.insert(users).values({ id: 1, name: secret }).pipe(Effect.flip) + + expect(error.message).not.toContain(secret) + expect(error.params).not.toContain(secret) + expect(error.params.every((param) => param === "")).toBe(true) + }), + ) +}) +// kilocode_change end + test("supports returning and rejects empty update sets", async () => { await run( Effect.gen(function* () { diff --git a/packages/opencode/src/kilocode/database/sqlite-error.ts b/packages/opencode/src/kilocode/database/sqlite-error.ts index 31e476a8005..fafda79b056 100644 --- a/packages/opencode/src/kilocode/database/sqlite-error.ts +++ b/packages/opencode/src/kilocode/database/sqlite-error.ts @@ -1,7 +1,5 @@ -import { isSqlError } from "effect/unstable/sql/SqlError" +import { isBusy } from "@opencode-ai/core/kilocode/sqlite-error" + +export { isBusy } export const busyMessage = "Database is busy. Please try again in a moment." - -export function isBusy(error: unknown) { - return isSqlError(error) && error.reason._tag === "LockTimeoutError" -} diff --git a/packages/opencode/test/kilocode/database/sqlite-error.test.ts b/packages/opencode/test/kilocode/database/sqlite-error.test.ts index 7a3ea7472e1..ff6e5e73d5f 100644 --- a/packages/opencode/test/kilocode/database/sqlite-error.test.ts +++ b/packages/opencode/test/kilocode/database/sqlite-error.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" +import { Cause } from "effect" import { LockTimeoutError, SqlError, UnknownError } from "effect/unstable/sql/SqlError" +import { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors" import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" describe("SQLite errors", () => { @@ -27,4 +29,22 @@ describe("SQLite errors", () => { expect(isBusy(error)).toBe(false) }) + + test("recognizes lock timeouts wrapped by Drizzle", () => { + const error = new EffectDrizzleQueryError({ + query: "update credential set value = ?", + params: [""], + cause: Cause.fail( + new SqlError({ + reason: new LockTimeoutError({ + cause: new Error("database is locked"), + message: "Failed to execute statement", + operation: "execute", + }), + }), + ), + }) + + expect(isBusy(error)).toBe(true) + }) }) From b6f428f860f385ee5e6ff32dfb78123429ef763e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 6 Aug 2026 14:17:45 +0200 Subject: [PATCH 77/78] fix(agent-manager): improve inspector resize performance --- .changeset/sync-inspector-width.md | 2 +- .../unit/agent-manager-terminal-layout.test.ts | 16 ++++++++++++++++ .../webview-ui/agent-manager/AgentManagerApp.tsx | 12 ++++++++++-- .../agent-manager/terminal/TerminalTab.tsx | 7 ++++--- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.changeset/sync-inspector-width.md b/.changeset/sync-inspector-width.md index 02eeb4e7239..9372f7ace4b 100644 --- a/.changeset/sync-inspector-width.md +++ b/.changeset/sync-inspector-width.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Persist the Agent Manager inspector width and share it between the terminal and diff viewer. +Persist the Agent Manager inspector width, share it between the terminal and diff viewer, and keep resizing responsive. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index a38ffbb7b99..185e7770c80 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -5,6 +5,10 @@ import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "../../webview-ui/ const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8") +const terminal = readFileSync( + resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"), + "utf8", +) test("xterm owns the padding used by FitAddon", () => { const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1] @@ -23,6 +27,18 @@ test("uses one persisted width for the diff and terminal inspector", () => { expect(app).not.toContain("terminalWidth") }) +test("limits inspector layout updates during resize", () => { + expect(app).toContain("SIDE_RESIZE_INTERVAL_MS = 32") + expect(app).toContain("time - sideResizeTime < SIDE_RESIZE_INTERVAL_MS") +}) + +test("does not refit hidden terminal buffers during resize", () => { + const callback = terminal.match(/const ro = new ResizeObserver\(\(\) => \{([\s\S]*?)\n \}\)/)?.[1] + expect(callback).toBeDefined() + expect(callback).toContain("if (!props.active) return") + expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()")) +}) + test("clamps the restored inspector width to the shared layout bounds", () => { expect(clampPanelWidth(undefined, 1200)).toBe(600) expect(clampPanelWidth(500, 1200)).toBe(500) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a68c1099f47..b69f95f8bf8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -200,6 +200,7 @@ type SidePanel = "diff" | "pr" | "terminal" | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) // Fallback keybindings before extension sends resolved ones const MAX_JUMP_INDEX = 9 +const SIDE_RESIZE_INTERVAL_MS = 32 const defaultBindings: Record = { previousSession: isMac ? "⌘⌥↑" : "Ctrl+Alt+↑", @@ -308,6 +309,7 @@ const AgentManagerContent: Component = () => { let pendingSidebarWidth: number | undefined let sideRaf: number | undefined let pendingSideWidth: number | undefined + let sideResizeTime = 0 const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) @@ -323,10 +325,16 @@ const AgentManagerContent: Component = () => { const resizeSide = (width: number) => { pendingSideWidth = clampPanelWidth(width, window.innerWidth) if (sideRaf !== undefined) return - sideRaf = requestAnimationFrame(() => { + const flush = (time: number) => { + if (time - sideResizeTime < SIDE_RESIZE_INTERVAL_MS) { + sideRaf = requestAnimationFrame(flush) + return + } sideRaf = undefined + sideResizeTime = time setPanelWidth(pendingSideWidth!) - }) + } + sideRaf = requestAnimationFrame(flush) } const showSideTerminal = () => { setHistory(false) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 8b6e2956b78..f6fcd115403 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -377,9 +377,9 @@ export const TerminalTab: Component = (props) => { open(url) } - // Resize: fit on any host size change and forward new cols/rows to - // the backend PTY. Debounced because a user drag can fire dozens of - // resize events per second. + // Resize the visible terminal and forward new cols/rows to the backend + // PTY. Hidden terminals refit when activated, avoiding scrollback reflow + // for every mounted terminal during an inspector drag. let resizeTimer: ReturnType | undefined let lastCols = term.cols let lastRows = term.rows @@ -395,6 +395,7 @@ export const TerminalTab: Component = (props) => { }) } const ro = new ResizeObserver(() => { + if (!props.active) return try { fit.fit() } catch (err) { From e7a3ca1a3f8f9c9022359a21584b5873b600f81d Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 14:31:20 +0200 Subject: [PATCH 78/78] fix(agent-manager): retain session ID hint --- packages/opencode/src/kilocode/tool/agent-manager.ts | 2 +- packages/opencode/test/kilocode/agent-manager-tool.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 99dc9fc5b09..8c4104e25eb 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -117,7 +117,7 @@ const WireParams = Schema.Struct({ ), filter: Schema.optional(ListParams.fields.filter), sessionID: Schema.optional( - Schema.String.annotate({ description: "For move, use a session ID returned by action=list." }), + Schema.String.annotate({ description: "For move, use a session ID returned by action=list (IDs start with ses_)." }), ), prompt: Schema.optional(PromptParams.fields.prompt), sectionID: Schema.optional(MoveParams.fields.sectionID), diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 59518cc6501..2f44f3f5c7d 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -164,7 +164,7 @@ describe("agent_manager tool", () => { expect(action && typeof action === "object" ? action.description : undefined).toContain("Use list first") expect(action && typeof action === "object" ? action.description : undefined).toContain("Never edit") expect(schema.properties?.sessionID).toEqual( - expect.objectContaining({ description: expect.stringContaining("returned by action=list") }), + expect.objectContaining({ description: expect.stringContaining("IDs start with ses_") }), ) expect(schema.properties?.sessionID).not.toHaveProperty("pattern") expect(schema.properties?.sectionID).toEqual(