From b26277efadece145bc31652cbe1777240d5c62a5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 4 May 2026 16:52:57 +0300 Subject: [PATCH 01/71] fix(cli): track local reviews in telemetry --- packages/kilo-telemetry/src/telemetry.ts | 3 +++ .../src/kilocode/session/processor.ts | 27 +++++++++++++++++++ packages/opencode/src/session/processor.ts | 5 +++- packages/opencode/src/session/prompt.ts | 17 ++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 94a8d946ce8..2e037e3aa6b 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -154,6 +154,9 @@ export namespace Telemetry { // LLM export function trackLlmCompletion(properties: { taskId?: string + mode?: "review" + feature?: "code_reviews" + command?: "local-review" | "local-review-uncommitted" apiProvider: string modelId: string inputTokens?: number diff --git a/packages/opencode/src/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index 24029d3f16e..1632585bb1d 100644 --- a/packages/opencode/src/kilocode/session/processor.ts +++ b/packages/opencode/src/kilocode/session/processor.ts @@ -8,12 +8,37 @@ import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" +export type ReviewTelemetry = { + mode: "review" + feature: "code_reviews" + command: "local-review" | "local-review-uncommitted" +} + export namespace KiloSessionProcessor { const log = Log.create({ service: "session.processor.kilo" }) export const OUTPUT_LENGTH_WARNING = "The model hit its output limit, so this response may be incomplete." export const REASONING_LENGTH_WARNING = "The model hit its output limit while reasoning and produced no actionable output. Try disabling reasoning or increasing the output limit." + export function reviewTelemetry(command: string): ReviewTelemetry | undefined { + if (command === "local-review" || command === "local-review-uncommitted") { + return { mode: "review", feature: "code_reviews", command } + } + } + + export function extractReviewTelemetry(parts: MessageV2.Part[]): ReviewTelemetry | undefined { + for (const part of parts) { + if (part.type !== "text") continue + const meta: Record | undefined = part.metadata + if (!meta) continue + if (meta.mode !== "review") continue + if (meta.feature !== "code_reviews") continue + const command = meta.command + if (command !== "local-review" && command !== "local-review-uncommitted") continue + return { mode: "review", feature: "code_reviews", command } + } + } + /** * Track LLM completion telemetry for a finished step. * Only fires if at least one token bucket is non-zero. @@ -24,11 +49,13 @@ export namespace KiloSessionProcessor { tokens: { input: number; output: number; cache: { read: number; write: number } } cost: number elapsed: number + telemetry?: ReviewTelemetry }) { const { tokens } = input if (tokens.input > 0 || tokens.output > 0 || tokens.cache.write > 0 || tokens.cache.read > 0) { Telemetry.trackLlmCompletion({ taskId: input.sessionID, + ...(input.telemetry ?? {}), apiProvider: input.model.providerID, modelId: input.model.id, inputTokens: tokens.input, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f1bf6f3b2e7..e20706edffa 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -17,7 +17,7 @@ import { SessionStatus } from "./status" import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" -import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change +import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { NotFoundError } from "@/storage/storage" // kilocode_change import { errorMessage } from "@/util/error" @@ -53,6 +53,7 @@ type Input = { assistantMessage: MessageV2.Assistant sessionID: SessionID model: Provider.Model + telemetry?: ReviewTelemetry // kilocode_change } export interface Interface { @@ -126,6 +127,7 @@ export const layer: Layer.Layer< needsCompaction: false, currentText: undefined, reasoningMap: {}, + telemetry: input.telemetry, // kilocode_change stepStart: 0, // kilocode_change step: { reasoning: false, text: false, tool: false }, // kilocode_change } @@ -430,6 +432,7 @@ export const layer: Layer.Layer< tokens: usage.tokens, cost: usage.cost, elapsed: Math.round(performance.now() - ctx.stepStart), + telemetry: ctx.telemetry, }) // kilocode_change end ctx.assistantMessage.finish = value.finishReason diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9055b1aec5b..824b23a37b8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,6 +5,7 @@ import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change +import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change import z from "zod" @@ -1373,6 +1374,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + // kilocode_change start - carry local review command marker into LLM telemetry + const telemetry = KiloSessionProcessor.extractReviewTelemetry( + msgs.findLast((m) => m.info.role === "user" && m.info.id === lastUser.id)?.parts ?? [], + ) + // kilocode_change end + const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, ) @@ -1511,6 +1518,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the assistantMessage: msg, sessionID, model, + telemetry, // kilocode_change }) const outcome: "break" | "continue" = yield* Effect.gen(function* () { @@ -1760,6 +1768,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const templateParts = yield* resolvePromptParts(template) + // kilocode_change start - mark local review commands for completion telemetry + const telemetry = KiloSessionProcessor.reviewTelemetry(input.command) + if (telemetry) { + for (const part of templateParts) { + if (part.type !== "text") continue + part.metadata = { ...part.metadata, ...telemetry } + } + } + // kilocode_change end const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true const parts = isSubtask ? [ From 2999e9f56e3a115b11c0dd7b0a55b6c0f6ea05e2 Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Fri, 10 Apr 2026 08:22:59 -0400 Subject: [PATCH 02/71] feat(cli): show model costs in model picker --- .../cli/cmd/tui/component/dialog-model.tsx | 104 +++++++++++++----- .../components/model-info-panel-utils.ts | 33 ++++++ .../kilocode/components/model-info-panel.tsx | 65 +++++++++++ .../kilocode/model-info-panel-utils.test.ts | 52 +++++++++ .../kilocode/model-info-panel.types.test.ts | 7 ++ 5 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/src/kilocode/components/model-info-panel-utils.ts create mode 100644 packages/opencode/src/kilocode/components/model-info-panel.tsx create mode 100644 packages/opencode/test/kilocode/model-info-panel-utils.test.ts create mode 100644 packages/opencode/test/kilocode/model-info-panel.types.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 89125a2dffe..e119886ec1e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -1,4 +1,5 @@ -import { createMemo, createSignal } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" // kilocode_change +import { createEffect, createMemo, createSignal, Show } from "solid-js" // kilocode_change import { useLocal } from "@tui/context/local" import { useSync } from "@tui/context/sync" import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" @@ -7,8 +8,10 @@ import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import { useKeybind } from "../context/keybind" +import type { Model } from "@kilocode/sdk/v2" // kilocode_change import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" +import { ModelInfoPanel } from "@/kilocode/components/model-info-panel" // kilocode_change export function DialogModel(props: { providerID?: string }) { const local = useLocal() @@ -16,6 +19,7 @@ export function DialogModel(props: { providerID?: string }) { const dialog = useDialog() const keybind = useKeybind() const [query, setQuery] = createSignal("") + const dimensions = useTerminalDimensions() // kilocode_change const connected = useConnected() const providers = createDialogProviderOptions() @@ -31,6 +35,36 @@ export function DialogModel(props: { providerID?: string }) { const showExtra = createMemo(() => connected() && !props.providerID) + // kilocode_change start + const wide = createMemo(() => dimensions().width >= 108) + const [preview, setPreview] = createSignal<{ + model: Model + provider: string + }>() + + const lookup = (providerID: string, modelID: string) => { + const provider = sync.data.provider.find((x) => x.id === providerID) + const model = provider?.models[modelID] + if (!provider || !model) return + return { + model, + provider: provider.name, + } + } + + createEffect(() => { + dialog.setSize(wide() ? "xlarge" : "large") + }) + + createEffect(() => { + const current = local.model.current() + if (!current) return + const next = lookup(current.providerID, current.modelID) + if (!next) return + setPreview(next) + }) + // kilocode_change end + const options = createMemo(() => { const needle = query().trim() const showSections = showExtra() && needle.length === 0 @@ -168,31 +202,49 @@ export function DialogModel(props: { providerID?: string }) { dialog.clear() } + // kilocode_change start return ( - [number]["value"]> - options={options()} - keybind={[ - { - keybind: keybind.all.model_provider_list?.[0], - title: connected() ? "Connect provider" : "View all providers", - onTrigger() { - dialog.replace(() => ) - }, - }, - { - keybind: keybind.all.model_favorite_toggle?.[0], - title: "Favorite", - disabled: !connected(), - onTrigger: (option) => { - local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) - }, - }, - ]} - onFilter={setQuery} - flat={true} - skipFilter={true} - title={title()} - current={local.model.current()} - /> + + + [number]["value"]> + options={options()} + keybind={[ + { + keybind: keybind.all.model_provider_list?.[0], + title: connected() ? "Connect provider" : "View all providers", + onTrigger() { + dialog.replace(() => ) + }, + }, + { + keybind: keybind.all.model_favorite_toggle?.[0], + title: "Favorite", + disabled: !connected(), + onTrigger: (option) => { + local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) + }, + }, + ]} + onFilter={setQuery} + onMove={(option) => { + if (typeof option.value === "string") { + setPreview(undefined) + return + } + const next = lookup(option.value.providerID, option.value.modelID) + if (!next) return + setPreview(next) + }} + flat={true} + skipFilter={true} + title={title()} + current={local.model.current()} + /> + + + {(item) => } + + ) + // kilocode_change end } diff --git a/packages/opencode/src/kilocode/components/model-info-panel-utils.ts b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts new file mode 100644 index 00000000000..582db55650d --- /dev/null +++ b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts @@ -0,0 +1,33 @@ +interface Cost { + input: number + output: number + cache: { + read: number + write: number + } +} + +export function fmtPrice(n: number): string { + if (n === 0) return "Free" + if (n < 0.01) return `$${n.toFixed(4)}/1M` + return `$${n.toFixed(2)}/1M` +} + +export function fmtCachedPrice(cost: Cost): string { + const read = cost.cache.read + if (read > 0) return fmtPrice(read) + if (cost.input === 0) return fmtPrice(0) + return "N/A" +} + +export function avgPrice(cost: Cost): number { + const read = cost.cache.read + if (read > 0) return read * 0.7 + cost.input * 0.2 + cost.output * 0.1 + return cost.input * 0.9 + cost.output * 0.1 +} + +export function fmtContext(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K` + return String(n) +} diff --git a/packages/opencode/src/kilocode/components/model-info-panel.tsx b/packages/opencode/src/kilocode/components/model-info-panel.tsx new file mode 100644 index 00000000000..ceb9e860445 --- /dev/null +++ b/packages/opencode/src/kilocode/components/model-info-panel.tsx @@ -0,0 +1,65 @@ +import { TextAttributes } from "@opentui/core" +import { useTheme } from "@tui/context/theme" +import type { Model } from "@kilocode/sdk/v2" +import { avgPrice, fmtCachedPrice, fmtContext, fmtPrice } from "./model-info-panel-utils" +import { Show } from "solid-js" + +interface Props { + model: Model + provider: string +} + +export function ModelInfoPanel(props: Props) { + const { theme } = useTheme() + const m = () => props.model + + return ( + + + + {m().name ?? m().id ?? "Model"} + + {props.provider ?? m().providerID ?? ""} + + + + + Free + + + + + Input + {m() ? fmtPrice(m().cost.input) : "—"} + + + Output + {m() ? fmtPrice(m().cost.output) : "—"} + + + Cache Read + {m() ? fmtCachedPrice(m().cost) : "—"} + + + + Context Size + {m() ? fmtContext(m().limit.context) : "—"} + + + Average Cost + {m() ? fmtPrice(avgPrice(m().cost)) : "—"} + + + + + ) +} diff --git a/packages/opencode/test/kilocode/model-info-panel-utils.test.ts b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts new file mode 100644 index 00000000000..07159e5bb22 --- /dev/null +++ b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { avgPrice, fmtCachedPrice, fmtContext, fmtPrice } from "../../src/kilocode/components/model-info-panel-utils" + +describe("model info panel price formatting", () => { + test("fmtPrice returns Free for zero", () => { + expect(fmtPrice(0)).toBe("Free") + }) + + test("fmtPrice uses four decimals for very small prices", () => { + expect(fmtPrice(0.0095)).toBe("$0.0095/1M") + }) + + test("fmtPrice uses two decimals for standard prices", () => { + expect(fmtPrice(3)).toBe("$3.00/1M") + }) + + test("fmtCachedPrice returns cache read price when available", () => { + expect(fmtCachedPrice({ input: 3, output: 15, cache: { read: 0.3, write: 0 } })).toBe("$0.30/1M") + }) + + test("fmtCachedPrice returns Free for free models", () => { + expect(fmtCachedPrice({ input: 0, output: 0, cache: { read: 0, write: 0 } })).toBe("Free") + }) + + test("fmtCachedPrice returns N/A without cache read", () => { + expect(fmtCachedPrice({ input: 3, output: 15, cache: { read: 0, write: 0 } })).toBe("N/A") + }) + + test("avgPrice uses cache weighted formula when cache read exists", () => { + const val = avgPrice({ input: 3, output: 15, cache: { read: 0.3, write: 0 } }) + expect(val).toBe(2.31) + }) + + test("avgPrice uses input and output weighted formula without cache read", () => { + const val = avgPrice({ input: 3, output: 15, cache: { read: 0, write: 0 } }) + expect(val).toBe(4.2) + }) +}) + +describe("model info panel context formatting", () => { + test("formats thousands as K", () => { + expect(fmtContext(128000)).toBe("128K") + }) + + test("formats millions as M", () => { + expect(fmtContext(1000000)).toBe("1M") + }) + + test("returns exact value for small contexts", () => { + expect(fmtContext(800)).toBe("800") + }) +}) diff --git a/packages/opencode/test/kilocode/model-info-panel.types.test.ts b/packages/opencode/test/kilocode/model-info-panel.types.test.ts new file mode 100644 index 00000000000..779785c1113 --- /dev/null +++ b/packages/opencode/test/kilocode/model-info-panel.types.test.ts @@ -0,0 +1,7 @@ +import type { Model as SDKModel } from "@kilocode/sdk/v2" +import { ModelInfoPanel } from "@/kilocode/components/model-info-panel" + +type Assert = T +type Props = Parameters[0] + +type _SyncModelMatchesPanel = Assert From 9d05e5556cdfecdbfc81c55e4cb04ceac07626b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 4 May 2026 17:54:53 -0300 Subject: [PATCH 03/71] refactor: redirect kilogateway provider to profile --- .../src/components/chat/VscodeSessionTurn.tsx | 2 +- .../components/settings/ProviderSelectDialog.tsx | 3 ++- .../src/components/settings/ProvidersTab.tsx | 8 ++++++-- .../kilo-vscode/webview-ui/src/context/server.tsx | 15 +++++++++++++++ .../webview-ui/src/stories/chat.stories.tsx | 1 + .../webview-ui/src/stories/composite.stories.tsx | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 9 +++++++++ 7 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index 648cfecb93d..6aa389bbd84 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -281,7 +281,7 @@ export const VscodeSessionTurn: Component = (props) => { {/* Error handling */} - {(err) => } + {(err) => } )} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderSelectDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderSelectDialog.tsx index e9085add8d5..a2bb0536059 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProviderSelectDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProviderSelectDialog.tsx @@ -61,7 +61,8 @@ const ProviderSelectDialog = () => { if (item.id === KILO_PROVIDER_ID) { dialog.close() - server.startLogin() + // Navigate to the Profile view so the full device-auth UI is visible. + server.goToLogin() return } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx index 34b76575ddc..3f65b8bbb35 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx @@ -140,7 +140,11 @@ const ProvidersTab: Component = () => { function connectProvider(item: Provider) { if (item.id === KILO_PROVIDER_ID) { - server.startLogin() + // Route Kilo Gateway sign-in through the Profile view so the user sees + // the full device-auth UI (URL, QR, code, timer, cancel). Triggering + // `startLogin()` from here alone would run the flow silently with no + // way to recover if the browser is dismissed. + server.goToLogin() return } dialog.show(() => ) @@ -177,7 +181,7 @@ const ProvidersTab: Component = () => { server.startLogin()}> + } diff --git a/packages/kilo-vscode/webview-ui/src/context/server.tsx b/packages/kilo-vscode/webview-ui/src/context/server.tsx index b064bda50d5..95eb18e82d4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/server.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/server.tsx @@ -17,6 +17,7 @@ interface ServerContextValue { profileData: Accessor deviceAuth: Accessor startLogin: () => void + goToLogin: () => void vscodeLanguage: Accessor languageOverride: Accessor workspaceDirectory: Accessor @@ -147,6 +148,19 @@ export const ServerProvider: ParentComponent = (props) => { vscode.postMessage({ type: "login" }) } + /** + * Route any "Sign In" action through the Profile view so the user always + * sees the device-auth UI (URL, QR, code, timer, cancel). Entry points + * outside the Profile page — e.g. the Kilo Gateway card in the Providers + * settings tab, or the provider picker — must call this helper instead of + * `startLogin()` directly. Otherwise the login flow runs silently and the + * user has no way to see the code or cancel if the browser is dismissed. + */ + const goToLogin = () => { + window.postMessage({ type: "navigate", view: "profile" }, "*") + startLogin() + } + const value: ServerContextValue = { connectionState, serverInfo, @@ -157,6 +171,7 @@ export const ServerProvider: ParentComponent = (props) => { profileData, deviceAuth, startLogin, + goToLogin, vscodeLanguage, languageOverride, workspaceDirectory, diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index a5f0ed13815..b8b41bcaf92 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -676,6 +676,7 @@ const mockServer = { }), deviceAuth: () => ({ status: "idle" as const }), startLogin: () => {}, + goToLogin: () => {}, vscodeLanguage: () => "en", languageOverride: () => undefined, workspaceDirectory: () => "/project", diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 340287ae4e1..fc4a65707f1 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -1189,6 +1189,7 @@ export const DiffSummaryCollapsed: Story = { profileData: () => null, deviceAuth: () => ({ status: "idle" as const }), startLogin: () => {}, + goToLogin: () => {}, vscodeLanguage: () => "en", languageOverride: () => undefined, workspaceDirectory: () => "/project", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2b4407904d3..32291f65218 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1617,10 +1617,17 @@ export type AgentConfig = { | null | string | number + | null + | number + | null + | string + | null | { [key: string]: boolean } | boolean + | string + | null | "subagent" | "primary" | "all" @@ -1636,6 +1643,8 @@ export type AgentConfig = { | "error" | "info" | number + | null + | number | PermissionConfig | undefined } From c4fa6b34c54839b7254f4aa1aa282113828023e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 4 May 2026 18:25:00 -0300 Subject: [PATCH 04/71] chore: add changeset --- .changeset/signin-routes-to-profile.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/signin-routes-to-profile.md diff --git a/.changeset/signin-routes-to-profile.md b/.changeset/signin-routes-to-profile.md new file mode 100644 index 00000000000..f0a35615eff --- /dev/null +++ b/.changeset/signin-routes-to-profile.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Route the "Sign In" action from the Providers settings tab, provider picker, and chat auth errors to the Profile view so the device-auth code, QR, and cancel button are always visible. From 4d1023c21a11d3c703a8dc7e7992267469cf26cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 4 May 2026 18:58:10 -0300 Subject: [PATCH 05/71] fix(vscode): trust OS CA store for Kilo backend TLS Default NODE_USE_SYSTEM_CA=1 on the spawned CLI so users behind corporate MITM proxies don't fail device-auth with TLS errors. Add a kilo-code.new.extraCaCerts setting for custom PEM bundles and honor VS Code's http.proxyStrictSSL=false as an opt-out. --- .changeset/trust-system-cas.md | 5 +++++ packages/kilo-vscode/package.json | 5 +++++ .../src/services/cli-backend/server-manager.ts | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .changeset/trust-system-cas.md diff --git a/.changeset/trust-system-cas.md b/.changeset/trust-system-cas.md new file mode 100644 index 00000000000..54fb3e3f279 --- /dev/null +++ b/.changeset/trust-system-cas.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Trust the OS certificate store and honor corporate CA bundles for the bundled Kilo backend. The extension now defaults `NODE_USE_SYSTEM_CA=1` on the spawned CLI process so users behind MITM proxies (Zscaler, Netskope, Palo Alto, etc.) no longer hit TLS errors on sign-in. A new `kilo-code.new.extraCaCerts` setting accepts a PEM file path for additional CAs, and `http.proxyStrictSSL=false` is honored as an opt-out from verification. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index f7e4f4b6837..7a915a942db 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -781,6 +781,11 @@ "default": false, "description": "Load CLAUDE.md instructions and skills from your Claude Code configuration directory into Kilo sessions. Enable this if you want Kilo to use your Claude Code instructions and skills." }, + "kilo-code.new.extraCaCerts": { + "type": "string", + "default": "", + "description": "Absolute path to a PEM file containing extra CA certificates to trust when the Kilo backend makes HTTPS requests (sets NODE_EXTRA_CA_CERTS on the CLI process). Use this if you're behind a corporate proxy that performs SSL inspection. Leave empty to rely on the OS trust store." + }, "kilo-code.new.autoApprove.enabled": { "type": "boolean", "default": false, diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index e63488e8b20..2c9187f8a7b 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -66,12 +66,28 @@ export class ServerManager { return new Promise((resolve, reject) => { console.log("[Kilo New] ServerManager: 🎬 Spawning CLI process:", cliPath, ["serve", "--port", "0"]) - const claudeCompat = vscode.workspace.getConfiguration("kilo-code.new").get("claudeCodeCompat", false) + const cfg = vscode.workspace.getConfiguration("kilo-code.new") + const claudeCompat = cfg.get("claudeCodeCompat", false) // Pin cwd so the CLI doesn't inherit the extension host's cwd ("/" under F5 debug) const spawnCwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.env.HOME ?? require("os").homedir() + // TLS / corporate-proxy support: + // - Default NODE_USE_SYSTEM_CA=1 so the bundled Bun CLI trusts the OS + // trust store (Windows cert store, macOS keychain, Linux /etc/ssl). + // Mirrors VS Code's `http.systemCertificates` default (true). + // - Allow users behind MITM proxies to point at a custom CA bundle via + // `kilo-code.new.extraCaCerts` (NODE_EXTRA_CA_CERTS). + // - Honor VS Code's `http.proxyStrictSSL=false` as an explicit opt-out + // from verification, matching what VS Code already does for its own + // requests. Users explicitly set that; we don't flip it ourselves. + // All three are overridable by the user's environment. + const extraCaCerts = cfg.get("extraCaCerts", "").trim() + const proxyStrictSSL = vscode.workspace.getConfiguration("http").get("proxyStrictSSL", true) const serverProcess = spawn(cliPath, ["serve", "--port", "0"], { cwd: spawnCwd, env: { + NODE_USE_SYSTEM_CA: "1", + ...(extraCaCerts && { NODE_EXTRA_CA_CERTS: extraCaCerts }), + ...(!proxyStrictSSL && { NODE_TLS_REJECT_UNAUTHORIZED: "0" }), ...process.env, // Force mimalloc (the allocator Bun ships with) to return freed pages // to the OS immediately instead of retaining them in its arenas. From d9453f0da2b063041f6f98235220cde9129e162d Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 5 May 2026 11:00:55 +0300 Subject: [PATCH 06/71] fix(cli): process queued compaction markers --- .changeset/fair-mice-compact.md | 6 +++ .../src/kilocode/session/prompt-queue.ts | 4 +- packages/opencode/src/session/compaction.ts | 4 ++ .../kilocode/session-prompt-queue.test.ts | 48 ++++++++++++++++++- 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 .changeset/fair-mice-compact.md diff --git a/.changeset/fair-mice-compact.md b/.changeset/fair-mice-compact.md new file mode 100644 index 00000000000..7fc069fb152 --- /dev/null +++ b/.changeset/fair-mice-compact.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Fix queued-turn auto-compaction so overflow recovery runs instead of exhausting compaction attempts. diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index 04df601c98e..405906eb9c8 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -42,8 +42,8 @@ export namespace KiloSessionPromptQueue { /** * Exempt an injected user message from being hidden by scope(). - * Called after PlanFollowup.inject() so the injected follow-up is visible - * without also unhiding unrelated prompts that were queued mid-turn. + * Called after internal follow-ups or compaction markers are persisted so + * they are visible without also unhiding unrelated prompts queued mid-turn. */ export function retarget(sessionID: SessionID, id: MessageID) { const current = targets.get(sessionID) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aaee2be2feb..b9a4294837a 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -18,6 +18,7 @@ import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change const log = Log.create({ service: "session.compaction" }) @@ -583,6 +584,9 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) + // kilocode_change start - keep auto-compaction markers visible during queued turns + KiloSessionPromptQueue.retarget(input.sessionID, msg.id) + // kilocode_change end }) return Service.of({ diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 11d82d21dec..4d5cb905af1 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -2,13 +2,14 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { Bus } from "../../src/bus" -import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" import { Suggestion } from "../../src/kilocode/suggestion" import { Question } from "../../src/question" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session/session" import { MessageV2 } from "../../src/session/message-v2" +import { SessionCompaction } from "../../src/session/compaction" import { SessionPrompt } from "../../src/session/prompt" import { MessageID, SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" @@ -239,6 +240,51 @@ describe("session prompt queue", () => { expect(ids[ids.length - 1]).toBe(injected) }) + test("keeps auto-compaction markers created during a queued turn visible", async () => { + // Regression for ses_20d25cccbffeVAw7Y9XGfL9p5O: an overflow inside a queued + // turn creates an auto-compaction user message after the queued prompt. If + // scope() hides that marker, runLoop never processes the compaction task and + // instead retries the same oversized request until compaction is exhausted. + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Queued compaction regression" }) + const first = MessageID.ascending() + const ans = MessageID.ascending() + const queued = MessageID.ascending() + + await Session.updateMessage(user(session.id, first).info) + await Session.updateMessage(assistant(session.id, ans, first).info) + await Session.updateMessage(user(session.id, queued).info) + + const result = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + session.id, + queued, + Effect.promise(async () => { + await SessionCompaction.create({ + sessionID: session.id, + agent: "code", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("model") }, + auto: true, + overflow: true, + }) + const messages = await Session.messages({ sessionID: session.id }) + const compact = messages.find((msg) => msg.parts.some((part) => part.type === "compaction"))?.info.id + return { compact, ids: KiloSessionPromptQueue.scope(session.id, messages).map((item) => item.info.id) } + }), + Effect.succeed({ compact: undefined, ids: [] }), + ), + ) + + if (!result.compact) throw new Error("missing compaction marker") + expect(result.ids).toEqual([first, ans, queued, result.compact]) + expect(result.ids[result.ids.length - 1]).toBe(result.compact) + }, + }) + }) + test("hasFollowup reports true only for prompts enqueued after the active slot started", async () => { const sessionID = SessionID.make("session_followup_semantics") const observed: Array<{ where: string; value: boolean }> = [] From 87845d176b17462350cd002c8e6555db33634330 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:11:09 +0000 Subject: [PATCH 07/71] docs(upstream): extract manual merge prompt --- .kilo/command/upstream-manual-merge.md | 66 ++++++++++++++++++++++++++ script/upstream/README.md | 63 +++++++++++++++++++++++- script/upstream/merge.ts | 5 +- script/upstream/utils/git.ts | 8 ++++ 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 .kilo/command/upstream-manual-merge.md diff --git a/.kilo/command/upstream-manual-merge.md b/.kilo/command/upstream-manual-merge.md new file mode 100644 index 00000000000..2c78cbe6a1e --- /dev/null +++ b/.kilo/command/upstream-manual-merge.md @@ -0,0 +1,66 @@ +--- +description: Resolve upstream merge conflicts +--- + +Resolve the manual part of an upstream merge. + +Arguments: `$ARGUMENTS` + +Use the first argument as the upstream version, for example `v1.1.50` or +`1.1.50`. If no argument is provided, infer the version from the current branch +name, `upstream-merge-report-.md`, or the newest relevant report file. + +Workflow: + +1. Inspect the current merge state: + - `git status --short` + - `git diff --name-only --diff-filter=U` + - `upstream-merge-report-.md` when present + - `.worktrees/opencode-merge/auto-merge` for the automated merge snapshot when present +2. Before editing, write a concise plan in the chat: + - file-by-file strategy + - expected resolution kind: `hybrid`, `take-ours`, `take-theirs`, `regenerated`, `removed`, `renamed`, or `other` + - risk level: `low`, `medium`, or `high` + - verification commands you expect to run +3. Resolve each conflict carefully. + + **Reference worktrees when present:** + - `.worktrees/opencode-merge/opencode` is the pristine upstream opencode tree + - `.worktrees/opencode-merge/kilo-main` is the Kilo base snapshot + - `.worktrees/opencode-merge/auto-merge` is the automated merge snapshot and the original conflict reference + + **Inspect conflicts (optional):** + - `script/upstream/find-conflict-markers.sh ` on the working tree + - `script/upstream/find-conflict-markers.sh .worktrees/opencode-merge/auto-merge/` on the auto-merge snapshot + + **Apply the resolution rules:** + - prefer upstream code and architecture whenever compatible with Kilo behavior + - preserve Kilo-specific behavior marked with `kilocode_change` + - keep `kilocode_change` markers around Kilo-specific changes in shared opencode files + - keep Kilo-specific text, code, and marker comments the same as the auto-merge conflict snapshot unless a refactor is required + - if Kilo-specific code must be refactored to fit new upstream architecture, explain the refactor in the final summary + - if upstream moved the relevant logic to another file, port the Kilo behavior there and list both paths in the final summary + - if upstream deleted a file, analyze whether the Kilo behavior should be ported elsewhere or removed rather than restoring the deleted file + - if tests fail only because upstream intentionally removed behavior, remove or update the obsolete tests rather than adding the old file back + - do not modify unrelated files +4. Run the appropriate checks: + - stage resolved files with `git add -A` so git no longer reports unmerged paths + - if `packages/opencode/` shared files changed, run `bun run script/check-opencode-annotations.ts` + - run targeted typechecks/tests when practical for touched packages + - run `bun run typecheck` from the repo root before declaring the merge ready +5. Finish with: + - files resolved + - resolution choices and rationale + - checks run and results + - any remaining high-risk areas for reviewer attention + +Only ask the user before proceeding if a decision is destructive, changes auth, +billing, data deletion, public API compatibility, config schema behavior, +migrations, provider routing, or security posture in a way that cannot be +safely inferred from the existing Kilo changes. + +Read `script/upstream/README.md` -> `Common Pitfalls` before resolving. Watch +for auto-merged code referencing conflict-block declarations, related sibling +files that need edits but are not unmerged, when to prefer `hybrid` over +`renamed`, function signatures drifting across a conflict boundary, and why +full turbo typecheck is the right catch-all. diff --git a/script/upstream/README.md b/script/upstream/README.md index 075171cdb72..b89708af222 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -291,13 +291,72 @@ After running the merge script, you may have remaining conflicts. To resolve: 1. Open each conflicted file 2. Look for `kilocode_change` markers to identify Kilo-specific code -3. Resolve conflicts, keeping Kilo-specific changes -4. Stage and commit: +3. Review `upstream-merge-report-.md` when present for the conflict + summary and manual-resolution guidance. +4. From the merge branch worktree, optionally start the project slash command: + ```bash + kilo run --command upstream-manual-merge v1.1.50 + ``` + The command is defined in `.kilo/command/upstream-manual-merge.md` and is + discovered from the repository root. It is available when Kilo is started in + the merge worktree; it will not appear if Kilo is started from one of the + reference worktrees under `.worktrees/opencode-merge/`. +5. Resolve conflicts, keeping Kilo-specific changes and favoring upstream code + and architecture when it is compatible with Kilo behavior. +6. Stage and commit: ```bash git add -A git commit -m "resolve merge conflicts" ``` +During merge runs, the script sets `merge.conflictStyle=zdiff3` in the local +repo config so conflicts include the `|||||||` base section. Keep using those +base-aware markers for manual resolution: they help compare Kilo's side, +upstream's side, and the common ancestor without reconstructing the merge. + +### Common Pitfalls + +These come up repeatedly during manual resolution and are easy to miss. Read +through before starting: + +1. **Auto-merged code outside the conflict can depend on declarations inside + it.** When picking between ours / theirs / hybrid, scan the non-conflicting + parts of the same file for references whose declaration lives in the + conflict block. Example from v1.14.30: `sync.tsx` had an auto-merged call to + `sessionListQuery()` that used `kv.get(...)`, while `const kv = useKV()` sat + inside the conflict block on the upstream side. A naive `take-ours` + resolution would leave `kv` undeclared. Always run typecheck after each + decision batch to catch these. + +2. **Related files can need edits even when they are not listed as unmerged.** + Upstream refactors sometimes split a file into siblings (e.g. v1.14.30 split + `httpapi/permission.ts` into `groups/permission.ts` + `handlers/permission.ts`). + Kilo behavior may need to be ported into the new sibling even though git only + reports the original file as conflicted. Mention every touched sibling in the + final summary so reviewers can find the diff. + +3. **`renamed` is stricter than it sounds.** Treat a resolution as `renamed` + only when the Kilo behavior moves from the conflicted file to a different + file. If git already recorded the rename during automerge and the work is + just adapting content at the new path, use `hybrid`. + +4. **Function signatures can drift across a conflict boundary.** Automerge can + pick one side of a paired change without noticing that a non-conflicting + consumer relied on the other side's shape. Example from v1.14.30: our + `parse()` returned `tree.rootNode`, upstream's returns the full `tree` and + accesses `tree.rootNode` at the call site inside the conflict block. + Auto-merge kept the new return type, so the old call site would have been + broken if we had taken ours. Re-read call sites after resolving, not only + the conflict block itself. + +5. **Always run full turbo typecheck before declaring done.** Visually clean + resolutions can still break typing at an unrelated call site (e.g. taking + upstream's `Msg = string | ArrayBuffer | Uint8Array` broke `ws.send()` + because Uint8Array is not assignable to `BufferSource` on the queue replay + path). `bun run typecheck` from the repo root is the cheapest catch-all. + Targeted per-package typechecks are not enough -- the failing call site can + live in a non-conflicted file. + ## Rollback If something goes wrong: diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 49b81da7ac8..857efe55dab 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -253,6 +253,9 @@ async function main() { await git.ensureRerere() logger.info("git rerere enabled (resolutions will be recorded and reused automatically)") + await git.ensureConflictStyle() + logger.info("merge.conflictStyle set to zdiff3 (conflicts will include the merge base)") + // Train rerere from past upstream merge commits so the cache is populated // even on a fresh clone. This replays past merges to learn their resolutions. // The grep covers both the current convention ("merge: upstream vX.Y.Z") and the @@ -741,7 +744,7 @@ async function main() { logger.divider() logger.info("Next steps:") - logger.info(" 1. Resolve remaining conflicts manually") + logger.info(" 1. Resolve remaining conflicts manually or with /upstream-manual-merge") logger.info(" 2. git add -A && git commit -m 'resolve merge conflicts'") logger.info(` 3. git push ${config.originRemote} ${kiloBranch}`) logger.info(" 4. Create PR from " + kiloBranch + " to " + config.baseBranch) diff --git a/script/upstream/utils/git.ts b/script/upstream/utils/git.ts index a781276e983..be967f8c64e 100644 --- a/script/upstream/utils/git.ts +++ b/script/upstream/utils/git.ts @@ -267,6 +267,14 @@ export async function ensureRerere(): Promise { await $`git config rerere.autoupdate true`.quiet() } +/** + * Force zdiff3 conflict markers at the repo level so every git op during the + * merge run writes conflicts with a ||||||| base block. + */ +export async function ensureConflictStyle(): Promise { + await $`git config merge.conflictStyle zdiff3`.quiet() +} + /** * Train the rerere cache from past merge commits in the repo history. * Implements the same logic as git's contrib/rerere-train.sh: From 3788991bd459c079a9386599d10cb200390c8536 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:14:02 +0000 Subject: [PATCH 08/71] docs(upstream): require manual merge approval --- .kilo/command/upstream-manual-merge.md | 23 ++++++++++++++++------- script/upstream/README.md | 12 +++++++++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.kilo/command/upstream-manual-merge.md b/.kilo/command/upstream-manual-merge.md index 2c78cbe6a1e..c3dbd90eb0c 100644 --- a/.kilo/command/upstream-manual-merge.md +++ b/.kilo/command/upstream-manual-merge.md @@ -22,7 +22,10 @@ Workflow: - expected resolution kind: `hybrid`, `take-ours`, `take-theirs`, `regenerated`, `removed`, `renamed`, or `other` - risk level: `low`, `medium`, or `high` - verification commands you expect to run -3. Resolve each conflict carefully. +3. Ask the user to approve the plan before applying any manual conflict + resolution. Do not resolve a file until the user has approved that file's + strategy. +4. Resolve each conflict carefully, one file at a time. **Reference worktrees when present:** - `.worktrees/opencode-merge/opencode` is the pristine upstream opencode tree @@ -43,21 +46,27 @@ Workflow: - if upstream deleted a file, analyze whether the Kilo behavior should be ported elsewhere or removed rather than restoring the deleted file - if tests fail only because upstream intentionally removed behavior, remove or update the obsolete tests rather than adding the old file back - do not modify unrelated files -4. Run the appropriate checks: +5. After each file is resolved, verify the decision before moving on: + - inspect the resolved file and confirm it has no conflict markers + - compare against the opencode, kilo-main, and auto-merge references when present + - run the smallest relevant check for that file when practical + - summarize the exact resolution, tradeoff, and verification result in chat + - ask the user to approve the resolved file before staging it or resolving the next file +6. Run the appropriate checks: - stage resolved files with `git add -A` so git no longer reports unmerged paths - if `packages/opencode/` shared files changed, run `bun run script/check-opencode-annotations.ts` - run targeted typechecks/tests when practical for touched packages - run `bun run typecheck` from the repo root before declaring the merge ready -5. Finish with: +7. Finish with: - files resolved - resolution choices and rationale - checks run and results - any remaining high-risk areas for reviewer attention -Only ask the user before proceeding if a decision is destructive, changes auth, -billing, data deletion, public API compatibility, config schema behavior, -migrations, provider routing, or security posture in a way that cannot be -safely inferred from the existing Kilo changes. +Every manual merge decision requires explicit user approval before applying and +again after verification. Be especially cautious when a decision is destructive, +changes auth, billing, data deletion, public API compatibility, config schema +behavior, migrations, provider routing, or security posture. Read `script/upstream/README.md` -> `Common Pitfalls` before resolving. Watch for auto-merged code referencing conflict-block declarations, related sibling diff --git a/script/upstream/README.md b/script/upstream/README.md index b89708af222..ea47c94144f 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -301,9 +301,15 @@ After running the merge script, you may have remaining conflicts. To resolve: discovered from the repository root. It is available when Kilo is started in the merge worktree; it will not appear if Kilo is started from one of the reference worktrees under `.worktrees/opencode-merge/`. -5. Resolve conflicts, keeping Kilo-specific changes and favoring upstream code - and architecture when it is compatible with Kilo behavior. -6. Stage and commit: +5. Resolve conflicts one file at a time. For each manual file, first present + the intended strategy and wait for user approval. Keep Kilo-specific changes + and favor upstream code and architecture when it is compatible with Kilo + behavior. +6. Verify each resolved file before moving on: confirm conflict markers are + gone, compare against the reference worktrees when present, run the smallest + practical check for the touched area, summarize the tradeoff and result, and + get user approval for the resolved file. +7. Stage and commit: ```bash git add -A git commit -m "resolve merge conflicts" From 6e3ddaad607b755fd1769a4781bc2c2fa0cf0bb4 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:19:17 +0000 Subject: [PATCH 09/71] feat: add reset-to-upstream script --- script/upstream/README.md | 13 +++ script/upstream/fix-kilocode-markers.ts | 105 +--------------------- script/upstream/index.ts | 1 + script/upstream/package.json | 1 + script/upstream/reset-to-upstream.ts | 103 ++++++++++++++++++++++ script/upstream/utils/upstream.ts | 110 ++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 104 deletions(-) create mode 100644 script/upstream/reset-to-upstream.ts create mode 100644 script/upstream/utils/upstream.ts diff --git a/script/upstream/README.md b/script/upstream/README.md index 075171cdb72..9d7ce2ac03a 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -35,6 +35,7 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1. | `list-versions.ts` | List available upstream versions | | `analyze.ts` | Analyze changes without merging | | `fix-kilocode-markers.ts` | Rebuild `kilocode_change` markers for one file against the last merged upstream | +| `reset-to-upstream.ts` | Reset one file to the transformed last merged upstream version | ### Transform Scripts @@ -243,6 +244,18 @@ Options: The command finds the newest upstream tag already merged into `HEAD`, reads that upstream version of the file, applies the same branding transforms used by upstream merge automation, strips existing `kilocode_change` markers from the current file, and adds fresh markers around the remaining lines that differ from upstream. +### reset-to-upstream.ts + +``` +Usage: + bun run script/upstream/reset-to-upstream.ts [--dry-run] + +Options: + --dry-run Show what would change without writing the file +``` + +The command finds the newest upstream tag already merged into `HEAD`, reads that upstream version of the file, applies the same branding transforms used by upstream merge automation, and writes the result to the working tree. If the file does not exist upstream, the local file is deleted. + ## Using Custom Base Branches By default, upstream merges start from the `main` branch. However, you can use `--base-branch` to start from a different branch. This is useful for: diff --git a/script/upstream/fix-kilocode-markers.ts b/script/upstream/fix-kilocode-markers.ts index 7299bedd0c0..5545e1c9ac6 100644 --- a/script/upstream/fix-kilocode-markers.ts +++ b/script/upstream/fix-kilocode-markers.ts @@ -12,15 +12,8 @@ import { $ } from "bun" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" -import { compareVersions, parseVersion, type VersionInfo } from "./utils/version" -import { isAncestor } from "./utils/git" import { error, header, info, success, warn } from "./utils/logger" -import { transformI18nContent } from "./transforms/transform-i18n" -import { applyBrandingTransforms } from "./transforms/transform-take-theirs" -import { applyScriptTransforms } from "./transforms/transform-scripts" -import { applyExtensionTransforms } from "./transforms/transform-extensions" -import { applyWebTransforms } from "./transforms/transform-web" -import { applyPackageNameTransforms } from "./transforms/package-names" +import { last, normalize, root, translate, upstream } from "./utils/upstream" interface Args { file?: string @@ -86,8 +79,6 @@ const styles = new Map([ [".bash", "hash"], [".zsh", "hash"], ]) -const workflows = [".github/workflows/publish.yml", ".github/workflows/beta.yml"] -const url = "https://github.com/anomalyco/opencode.git" const exempt = ["script/upstream/"] function usage() { @@ -113,21 +104,6 @@ function args(): Args { } } -async function root() { - return (await $`git rev-parse --show-toplevel`.text()).trim() -} - -function normalize(root: string, file: string) { - if (path.isAbsolute(file)) throw new Error("File must be relative to the repo root") - if (file.includes("\0")) throw new Error("File path contains a null byte") - - const abs = path.resolve(root, file) - const rel = path.relative(root, abs).replaceAll(path.sep, "/") - - if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error("File must stay inside the repo") - return rel -} - function ext(file: string) { return path.extname(file).toLowerCase() } @@ -143,29 +119,6 @@ function annotates(file: string) { return !exempt.some((scope) => file.startsWith(scope)) } -async function translate(file: string, text: string) { - const names = applyPackageNameTransforms(text).result - const script = applyScriptTransforms(names).result - const branded = applyBrandingTransforms(script).result - const i18n = transformI18nContent(branded).result - const ext = applyExtensionTransforms(i18n, file).result - const web = applyWebTransforms(ext).result - - return workflow(file, web) -} - -function workflow(file: string, text: string) { - if (!workflows.includes(file)) return text - return text - .replace(/github\.repository == 'anomalyco\/opencode'/g, "github.repository == 'Kilo-Org/kilocode'") - .replace(/github\.repository == "anomalyco\/opencode"/g, 'github.repository == "Kilo-Org/kilocode"') - .replace(/\bopencode-ai\b/g, "@kilocode/cli") - .replace( - /GH_REPO:\s*\$\{\{ \(github\.ref_name == 'beta' && 'anomalyco\/opencode-beta'\) \|\| github\.repository \}\}/g, - "GH_REPO: ${{ github.repository }}", - ) -} - function split(text: string): Text { const eol = text.includes("\r\n") ? "\r\n" : "\n" const final = text.endsWith("\n") @@ -263,62 +216,6 @@ function clean(file: string, text: string): Clean { return { text: { ...parsed, lines }, marks } } -async function last(): Promise { - const source = await remote() - - info(`Fetching upstream tags from ${source}...`) - const fetch = await $`git fetch ${source} --tags --force`.quiet().nothrow() - if (fetch.exitCode !== 0) throw new Error(`Failed to fetch upstream: ${fetch.stderr.toString()}`) - - const versions = await list(source) - for (const version of versions) { - if (await isAncestor(version.commit, "HEAD")) return version - } - - throw new Error("Could not find a merged upstream tag in HEAD") -} - -async function remote() { - const result = await $`git remote get-url upstream`.quiet().nothrow() - if (result.exitCode === 0) return "upstream" - - warn(`No 'upstream' remote found; using ${url}`) - return url -} - -async function list(source: string): Promise { - const result = await $`git ls-remote --tags ${source}`.quiet().nothrow() - if (result.exitCode !== 0) throw new Error(`Failed to list upstream tags: ${result.stderr.toString()}`) - - const found = new Map() - for (const line of result.stdout.toString().trim().split("\n")) { - const match = line.match(/^([a-f0-9]+)\s+refs\/tags\/([^^]+)(\^\{\})?$/) - if (!match) continue - - const commit = match[1] - const tag = match[2] - const peeled = Boolean(match[3]) - if (commit && tag && (peeled || !found.has(tag))) found.set(tag, commit) - } - - return [...found] - .flatMap(([tag, commit]) => { - const version = parseVersion(tag) - return version ? [{ version, tag, commit }] : [] - }) - .sort((a, b) => compareVersions(b.version, a.version)) -} - -async function upstream(ref: string, file: string) { - const spec = `${ref}:${file}` - const result = await $`git show ${spec}`.quiet().nothrow() - if (result.exitCode === 0) return result.stdout.toString() - - const stderr = result.stderr.toString() - if (stderr.includes("exists on disk") || stderr.includes("does not exist") || stderr.includes("Path")) return null - throw new Error(`Failed to read ${file} from ${ref}: ${stderr}`) -} - function style(file: string): Style { const kind = ext(file) return styles.get(kind) ?? "hash" diff --git a/script/upstream/index.ts b/script/upstream/index.ts index e823b69c7ea..639f8cc7af8 100644 --- a/script/upstream/index.ts +++ b/script/upstream/index.ts @@ -11,6 +11,7 @@ export * from "./utils/logger" export * from "./utils/config" export * from "./utils/version" export * from "./utils/report" +export * from "./utils/upstream" // Transforms export { transformAll as transformPackageNames, transformFile } from "./transforms/package-names" diff --git a/script/upstream/package.json b/script/upstream/package.json index 0722d2f2e67..150311f7f8b 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -14,6 +14,7 @@ "transform:all": "bun run transforms/package-names.ts && bun run codemods/transform-imports.ts && bun run codemods/transform-strings.ts", "versions": "bun run transforms/preserve-versions.ts", "fix:markers": "bun run fix-kilocode-markers.ts", + "reset:upstream": "bun run reset-to-upstream.ts", "keep-ours": "bun run transforms/keep-ours.ts" }, "dependencies": { diff --git a/script/upstream/reset-to-upstream.ts b/script/upstream/reset-to-upstream.ts new file mode 100644 index 00000000000..0e93d6387b4 --- /dev/null +++ b/script/upstream/reset-to-upstream.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +/** + * Reset one file to the last merged upstream version after applying Kilo merge + * branding transforms. + * + * Usage: + * bun run script/upstream/reset-to-upstream.ts packages/opencode/src/file.ts + * bun run script/upstream/reset-to-upstream.ts packages/opencode/src/file.ts --dry-run + */ + +import { rm } from "node:fs/promises" +import path from "node:path" +import { error, header, info, success, warn } from "./utils/logger" +import { last, normalize, root, translate, upstream } from "./utils/upstream" + +interface Args { + file?: string + dryRun: boolean + help: boolean +} + +function usage() { + console.log(`Usage: bun run script/upstream/reset-to-upstream.ts [--dry-run] + +Resets one file by: + 1. Finding the newest upstream tag whose commit is already merged into HEAD. + 2. Reading that file from upstream at the merged tag. + 3. Applying upstream merge branding transforms. + 4. Writing the transformed upstream file to the working tree. + +If the file does not exist upstream, the local file is deleted. + +Options: + --dry-run Show what would change without writing the file. + --help Show this help message.`) +} + +function args(): Args { + const raw = process.argv.slice(2) + return { + file: raw.find((arg) => !arg.startsWith("--")), + dryRun: raw.includes("--dry-run"), + help: raw.includes("--help") || raw.includes("-h"), + } +} + +async function main() { + const opts = args() + if (opts.help) { + usage() + return + } + if (!opts.file) { + usage() + process.exit(1) + } + + const top = await root() + process.chdir(top) + + const file = normalize(top, opts.file) + const abs = path.join(top, file) + + header("Reset file to upstream") + + const version = await last() + success(`Last merged upstream: ${version.tag} (${version.commit.slice(0, 8)})`) + + const base = await upstream(version.commit, file) + if (base === null) { + warn(`${file} does not exist upstream`) + if (opts.dryRun) { + info(`[DRY-RUN] Would delete ${file}`) + return + } + + await rm(abs, { force: true }) + success(`Deleted ${file}`) + return + } + + const next = await translate(file, base) + const current = await Bun.file(abs) + .text() + .catch(() => null) + if (current === next) { + success(`${file} already matches transformed upstream ${version.tag}`) + return + } + + if (opts.dryRun) { + info(`[DRY-RUN] Would reset ${file} to transformed upstream ${version.tag}`) + return + } + + await Bun.write(abs, next) + success(`Reset ${file} to transformed upstream ${version.tag}`) +} + +main().catch((err) => { + error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/script/upstream/utils/upstream.ts b/script/upstream/utils/upstream.ts new file mode 100644 index 00000000000..d80c7e84a3a --- /dev/null +++ b/script/upstream/utils/upstream.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import path from "node:path" +import { applyPackageNameTransforms } from "../transforms/package-names" +import { applyExtensionTransforms } from "../transforms/transform-extensions" +import { transformI18nContent } from "../transforms/transform-i18n" +import { applyScriptTransforms } from "../transforms/transform-scripts" +import { applyBrandingTransforms } from "../transforms/transform-take-theirs" +import { applyWebTransforms } from "../transforms/transform-web" +import { warn, info } from "./logger" +import { compareVersions, parseVersion, type VersionInfo } from "./version" +import { isAncestor } from "./git" + +const url = "https://github.com/anomalyco/opencode.git" +const workflows = [".github/workflows/publish.yml", ".github/workflows/beta.yml"] + +export async function root() { + return (await $`git rev-parse --show-toplevel`.text()).trim() +} + +export function normalize(root: string, file: string) { + if (path.isAbsolute(file)) throw new Error("File must be relative to the repo root") + if (file.includes("\0")) throw new Error("File path contains a null byte") + + const abs = path.resolve(root, file) + const rel = path.relative(root, abs).replaceAll(path.sep, "/") + + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error("File must stay inside the repo") + return rel +} + +export async function remote() { + const result = await $`git remote get-url upstream`.quiet().nothrow() + if (result.exitCode === 0) return "upstream" + + warn(`No 'upstream' remote found; using ${url}`) + return url +} + +export async function last(): Promise { + const source = await remote() + + info(`Fetching upstream tags from ${source}...`) + const fetch = await $`git fetch ${source} --tags --force`.quiet().nothrow() + if (fetch.exitCode !== 0) throw new Error(`Failed to fetch upstream: ${fetch.stderr.toString()}`) + + const items = await versions(source) + for (const version of items) { + if (await isAncestor(version.commit, "HEAD")) return version + } + + throw new Error("Could not find a merged upstream tag in HEAD") +} + +export async function versions(source: string): Promise { + const result = await $`git ls-remote --tags ${source}`.quiet().nothrow() + if (result.exitCode !== 0) throw new Error(`Failed to list upstream tags: ${result.stderr.toString()}`) + + const found = new Map() + for (const line of result.stdout.toString().trim().split("\n")) { + const match = line.match(/^([a-f0-9]+)\s+refs\/tags\/([^^]+)(\^\{\})?$/) + if (!match) continue + + const commit = match[1] + const tag = match[2] + const peeled = Boolean(match[3]) + if (commit && tag && (peeled || !found.has(tag))) found.set(tag, commit) + } + + return [...found] + .flatMap(([tag, commit]) => { + const version = parseVersion(tag) + return version ? [{ version, tag, commit }] : [] + }) + .sort((a, b) => compareVersions(b.version, a.version)) +} + +export async function upstream(ref: string, file: string) { + const spec = `${ref}:${file}` + const result = await $`git show ${spec}`.quiet().nothrow() + if (result.exitCode === 0) return result.stdout.toString() + + const stderr = result.stderr.toString() + if (stderr.includes("exists on disk") || stderr.includes("does not exist") || stderr.includes("Path")) return null + throw new Error(`Failed to read ${file} from ${ref}: ${stderr}`) +} + +export async function translate(file: string, text: string) { + const names = applyPackageNameTransforms(text).result + const script = applyScriptTransforms(names).result + const branded = applyBrandingTransforms(script).result + const i18n = transformI18nContent(branded).result + const ext = applyExtensionTransforms(i18n, file).result + const web = applyWebTransforms(ext).result + + return workflow(file, web) +} + +function workflow(file: string, text: string) { + if (!workflows.includes(file)) return text + return text + .replace(/github\.repository == 'anomalyco\/opencode'/g, "github.repository == 'Kilo-Org/kilocode'") + .replace(/github\.repository == "anomalyco\/opencode"/g, 'github.repository == "Kilo-Org/kilocode"') + .replace(/\bopencode-ai\b/g, "@kilocode/cli") + .replace( + /GH_REPO:\s*\$\{\{ \(github\.ref_name == 'beta' && 'anomalyco\/opencode-beta'\) \|\| github\.repository \}\}/g, + "GH_REPO: ${{ github.repository }}", + ) +} From f206432a0c03cc9123b76dceb9d87f19ba7773b3 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:19:27 +0000 Subject: [PATCH 10/71] docs(upstream): generalize manual merge pitfalls --- script/upstream/README.md | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/script/upstream/README.md b/script/upstream/README.md index ea47c94144f..818df91245a 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -328,18 +328,16 @@ through before starting: 1. **Auto-merged code outside the conflict can depend on declarations inside it.** When picking between ours / theirs / hybrid, scan the non-conflicting parts of the same file for references whose declaration lives in the - conflict block. Example from v1.14.30: `sync.tsx` had an auto-merged call to - `sessionListQuery()` that used `kv.get(...)`, while `const kv = useKV()` sat - inside the conflict block on the upstream side. A naive `take-ours` - resolution would leave `kv` undeclared. Always run typecheck after each - decision batch to catch these. + conflict block. A naive resolution can leave callers pointing at removed or + renamed symbols. Always run typecheck after each decision batch to catch + these. 2. **Related files can need edits even when they are not listed as unmerged.** - Upstream refactors sometimes split a file into siblings (e.g. v1.14.30 split - `httpapi/permission.ts` into `groups/permission.ts` + `handlers/permission.ts`). - Kilo behavior may need to be ported into the new sibling even though git only - reports the original file as conflicted. Mention every touched sibling in the - final summary so reviewers can find the diff. + Upstream refactors sometimes split logic across sibling files or move the + relevant behavior to a new location. Kilo behavior may need to be ported into + the new shape even though git only reports the original file as conflicted. + Mention every touched sibling in the final summary so reviewers can find the + diff. 3. **`renamed` is stricter than it sounds.** Treat a resolution as `renamed` only when the Kilo behavior moves from the conflicted file to a different @@ -348,20 +346,14 @@ through before starting: 4. **Function signatures can drift across a conflict boundary.** Automerge can pick one side of a paired change without noticing that a non-conflicting - consumer relied on the other side's shape. Example from v1.14.30: our - `parse()` returned `tree.rootNode`, upstream's returns the full `tree` and - accesses `tree.rootNode` at the call site inside the conflict block. - Auto-merge kept the new return type, so the old call site would have been - broken if we had taken ours. Re-read call sites after resolving, not only - the conflict block itself. + consumer relied on the other side's shape. Re-read call sites and exported + contracts after resolving, not only the conflict block itself. 5. **Always run full turbo typecheck before declaring done.** Visually clean - resolutions can still break typing at an unrelated call site (e.g. taking - upstream's `Msg = string | ArrayBuffer | Uint8Array` broke `ws.send()` - because Uint8Array is not assignable to `BufferSource` on the queue replay - path). `bun run typecheck` from the repo root is the cheapest catch-all. - Targeted per-package typechecks are not enough -- the failing call site can - live in a non-conflicted file. + resolutions can still break typing at an unrelated call site. `bun run + typecheck` from the repo root is the cheapest catch-all. Targeted per-package + typechecks are not enough -- the failing call site can live in a + non-conflicted file. ## Rollback From b59dfaa6424164a59ae6d002427996da6fd94148 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:28:57 +0000 Subject: [PATCH 11/71] fix: preserve binary upstream resets --- script/upstream/README.md | 2 +- script/upstream/reset-to-upstream.ts | 38 +++++++++++++++++++++++++--- script/upstream/utils/upstream.ts | 7 ++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/script/upstream/README.md b/script/upstream/README.md index 9d7ce2ac03a..15537d4075e 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -254,7 +254,7 @@ Options: --dry-run Show what would change without writing the file ``` -The command finds the newest upstream tag already merged into `HEAD`, reads that upstream version of the file, applies the same branding transforms used by upstream merge automation, and writes the result to the working tree. If the file does not exist upstream, the local file is deleted. +The command finds the newest upstream tag already merged into `HEAD`, reads that upstream version of the file, applies the same branding transforms used by upstream merge automation for text files, and writes the result to the working tree. Binary files are restored as raw upstream bytes without text transforms. If the file does not exist upstream, the local file is deleted. ## Using Custom Base Branches diff --git a/script/upstream/reset-to-upstream.ts b/script/upstream/reset-to-upstream.ts index 0e93d6387b4..d70d7e6d025 100644 --- a/script/upstream/reset-to-upstream.ts +++ b/script/upstream/reset-to-upstream.ts @@ -11,7 +11,7 @@ import { rm } from "node:fs/promises" import path from "node:path" import { error, header, info, success, warn } from "./utils/logger" -import { last, normalize, root, translate, upstream } from "./utils/upstream" +import { last, normalize, root, translate, upstreamData } from "./utils/upstream" interface Args { file?: string @@ -28,13 +28,22 @@ Resets one file by: 3. Applying upstream merge branding transforms. 4. Writing the transformed upstream file to the working tree. -If the file does not exist upstream, the local file is deleted. +If the file does not exist upstream, the local file is deleted. Binary files are +written back as raw upstream bytes without text transforms. Options: --dry-run Show what would change without writing the file. --help Show this help message.`) } +function binary(data: Uint8Array) { + return data.includes(0) +} + +function same(left: Uint8Array, right: Uint8Array) { + return left.length === right.length && left.every((byte, index) => byte === right[index]) +} + function args(): Args { const raw = process.argv.slice(2) return { @@ -66,8 +75,8 @@ async function main() { const version = await last() success(`Last merged upstream: ${version.tag} (${version.commit.slice(0, 8)})`) - const base = await upstream(version.commit, file) - if (base === null) { + const data = await upstreamData(version.commit, file) + if (data === null) { warn(`${file} does not exist upstream`) if (opts.dryRun) { info(`[DRY-RUN] Would delete ${file}`) @@ -79,6 +88,27 @@ async function main() { return } + if (binary(data)) { + const current = await Bun.file(abs) + .arrayBuffer() + .then((buffer) => new Uint8Array(buffer)) + .catch(() => null) + if (current && same(current, data)) { + success(`${file} already matches upstream ${version.tag}`) + return + } + + if (opts.dryRun) { + info(`[DRY-RUN] Would reset binary ${file} to upstream ${version.tag}`) + return + } + + await Bun.write(abs, data) + success(`Reset binary ${file} to upstream ${version.tag}`) + return + } + + const base = new TextDecoder().decode(data) const next = await translate(file, base) const current = await Bun.file(abs) .text() diff --git a/script/upstream/utils/upstream.ts b/script/upstream/utils/upstream.ts index d80c7e84a3a..98959b2c23d 100644 --- a/script/upstream/utils/upstream.ts +++ b/script/upstream/utils/upstream.ts @@ -77,9 +77,14 @@ export async function versions(source: string): Promise { } export async function upstream(ref: string, file: string) { + const data = await upstreamData(ref, file) + return data === null ? null : data.toString() +} + +export async function upstreamData(ref: string, file: string) { const spec = `${ref}:${file}` const result = await $`git show ${spec}`.quiet().nothrow() - if (result.exitCode === 0) return result.stdout.toString() + if (result.exitCode === 0) return result.stdout const stderr = result.stderr.toString() if (stderr.includes("exists on disk") || stderr.includes("does not exist") || stderr.includes("Path")) return null From 4e1bae01ae45efe2e668b45b428fe222f6909c67 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 5 May 2026 08:30:11 +0000 Subject: [PATCH 12/71] release: v7.2.39 --- .changeset/restore-kilo-env-var.md | 5 ----- bun.lock | 28 +++++++++++++------------- package.json | 2 +- packages/core/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-indexing/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/CHANGELOG.md | 2 ++ packages/kilo-vscode/package.json | 2 +- packages/opencode/CHANGELOG.md | 6 ++++++ packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 22 files changed, 45 insertions(+), 42 deletions(-) delete mode 100644 .changeset/restore-kilo-env-var.md diff --git a/.changeset/restore-kilo-env-var.md b/.changeset/restore-kilo-env-var.md deleted file mode 100644 index 9c49549c0b4..00000000000 --- a/.changeset/restore-kilo-env-var.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Restore the `KILO=1` environment variable so plugins and tooling can distinguish the Kilo CLI from upstream OpenCode. diff --git a/bun.lock b/bun.lock index 9885e06182f..92ff3e4cd9f 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.2.36", + "version": "7.2.39", "bin": { "opencode": "./bin/opencode", }, @@ -68,7 +68,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -97,7 +97,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -133,7 +133,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.36", + "version": "7.2.39", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -143,7 +143,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -177,7 +177,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -191,7 +191,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/core": "workspace:*", @@ -226,7 +226,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.36", + "version": "7.2.39", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -446,7 +446,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -471,7 +471,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "semver": "^7.6.3", }, @@ -482,7 +482,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "cross-spawn": "catalog:", }, @@ -497,7 +497,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.36", + "version": "7.2.39", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -520,7 +520,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.36", + "version": "7.2.39", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 12817c335d0..1bdebd288a3 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.36", + "version": "7.2.39", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 55b45cd7440..88c8ec18208 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.36", + "version": "7.2.39", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index a29879efbe7..57135003f6a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.36" +version = "7.2.39" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.36/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.36/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.36/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.36/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.36/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 6c2732e0632..8ed19d6eda8 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.36", + "version": "7.2.39", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 6bd084d7d72..9d3e5d17203 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 91e03968e31..d6376f9a1b7 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 1756ca5c94a..556d1782627 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 426d0e47dac..374b36d8563 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 5c4a9b7b557..e5d2b98b578 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 6501d36809f..964a8a92c54 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,7 @@ # kilo-code +## 7.2.39 + ## 7.2.36 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index eaf7128ca87..339d21b9baf 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.36", + "version": "7.2.39", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index ce2aac14c53..b96e600c974 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,11 @@ # @kilocode/cli +## 7.2.39 + +### Patch Changes + +- [#9840](https://github.com/Kilo-Org/kilocode/pull/9840) [`db26be6`](https://github.com/Kilo-Org/kilocode/commit/db26be6b5d3ac77a729ea5242c8330b9146352a7) - Restore the `KILO=1` environment variable so plugins and tooling can distinguish the Kilo CLI from upstream OpenCode. + ## 7.2.36 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 352ec2668cd..2dd8e193951 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.36", + "version": "7.2.39", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 932f36702cd..a72c477fb8e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index b1bbd31044b..0cadc71a1bd 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.36", + "version": "7.2.39", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 7f209779923..cfcaec0eaf3 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index def5b6f17b5..7f8f0ade4e6 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.36", + "version": "7.2.39", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index ee1ce7da0c0..bd6ab6ca911 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.36", + "version": "7.2.39", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index 0722d2f2e67..5aca81178f1 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.36", + "version": "7.2.39", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index e0cde51d9cb..2f3c0208704 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.36", + "version": "7.2.39", "publisher": "sst-dev", "repository": { "type": "git", From f392debf0f06ee300466b4a47fc03f19dc8c3af3 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 5 May 2026 11:04:53 +0200 Subject: [PATCH 13/71] fix(cli): serialize OAuth callback port tests (#9870) * fix(cli): serialize OAuth callback port tests * fix(cli): close MCP OAuth browser test listener race The mock subprocess emitted its error via setTimeout(10ms), which raced the error listener attachment on slow Windows CI and left BrowserOpenFailed unpublished. Emit on newListener('error') via a microtask so the listener is always attached first. Revert the unrelated test-runner serialization. * fix(cli): deliver MCP OAuth mock error to listener directly Override subprocess.on in the mock so the error is queued to the registered listener as soon as it attaches. The previous newListener + emit chain still lost events on slow Windows CI because EventEmitter dispatch can race microtask draining. --- packages/opencode/test/mcp/oauth-browser.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index e4165e98e9c..62f611681fd 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -14,10 +14,18 @@ void mock.module("open", () => ({ // Return a mock subprocess that emits an error if openShouldFail is true const subprocess = new EventEmitter() if (openShouldFail) { - // Emit error asynchronously like a real subprocess would - setTimeout(() => { - subprocess.emit("error", new Error("spawn xdg-open ENOENT")) - }, 10) + // kilocode_change start - buffer the error until the consumer attaches + // its listener. The previous setTimeout(10) raced listener attachment + // on slow Windows CI; emit() before `.on("error", ...)` was silently + // lost and BrowserOpenFailed was never published. + const err = new Error("spawn xdg-open ENOENT") + const originalOn = subprocess.on.bind(subprocess) + subprocess.on = function (event, listener) { + const ret = originalOn(event, listener) + if (event === "error") queueMicrotask(() => (listener as (e: Error) => void).call(subprocess, err)) + return ret + } + // kilocode_change end } return subprocess }, From 8fcc77a2aec94a49f4c1b88f95a5a94248b1ab86 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 5 May 2026 09:05:27 +0000 Subject: [PATCH 14/71] release: v7.2.40 --- bun.lock | 28 +++++++++++++------------- package.json | 2 +- packages/core/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-indexing/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index 92ff3e4cd9f..5ea5e1bf75e 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.2.39", + "version": "7.2.40", "bin": { "opencode": "./bin/opencode", }, @@ -68,7 +68,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -97,7 +97,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -133,7 +133,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.39", + "version": "7.2.40", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -143,7 +143,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -177,7 +177,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -191,7 +191,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/core": "workspace:*", @@ -226,7 +226,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.39", + "version": "7.2.40", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -446,7 +446,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -471,7 +471,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "semver": "^7.6.3", }, @@ -482,7 +482,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "cross-spawn": "catalog:", }, @@ -497,7 +497,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.39", + "version": "7.2.40", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -520,7 +520,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.39", + "version": "7.2.40", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 1bdebd288a3..fe7327a31fc 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.39", + "version": "7.2.40", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 88c8ec18208..26ae1f6220f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.39", + "version": "7.2.40", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 57135003f6a..67b25c223f5 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.39" +version = "7.2.40" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.39/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.40/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 8ed19d6eda8..1623d1511fb 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.39", + "version": "7.2.40", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 9d3e5d17203..c75789b1dc0 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index d6376f9a1b7..688bbab96d6 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 556d1782627..7fd81080420 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 374b36d8563..37a44343553 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index e5d2b98b578..18729510a21 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 339d21b9baf..f10cdc91f3d 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.39", + "version": "7.2.40", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2dd8e193951..42d0e348285 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.39", + "version": "7.2.40", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a72c477fb8e..bd2a1fdb70f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 0cadc71a1bd..f1187963c64 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.39", + "version": "7.2.40", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index cfcaec0eaf3..a3773525b9c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 7f8f0ade4e6..9479207a61d 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.39", + "version": "7.2.40", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index bd6ab6ca911..258cca34400 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.39", + "version": "7.2.40", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index 5aca81178f1..41a72a927dc 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.39", + "version": "7.2.40", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 2f3c0208704..87babd17a5e 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.39", + "version": "7.2.40", "publisher": "sst-dev", "repository": { "type": "git", From 47cab6b3df704c7fc15a4c00b9a7f0ce39b15f47 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 09:12:27 +0000 Subject: [PATCH 15/71] fix: show release type in Slack notifications --- script/publish.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/publish.ts b/script/publish.ts index 8ab9f96e95c..3ca9b355ae6 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -99,7 +99,9 @@ if (Script.release) { // Use an absolute path for the CHANGELOG because the imported SDK build // script chdirs into packages/sdk/js, so a relative path would miss the file // and fall through to the "No notable changes" default. + const kind = Script.preview ? "pre-release" : "release" const flags = Script.preview ? ["--draft=false", "--prerelease"] : ["--draft=false"] + flags.push("--title", `v${Script.version} (${kind})`) const changelogPath = fileURLToPath(new URL("../packages/kilo-vscode/CHANGELOG.md", import.meta.url)) const changelog = await Bun.file(changelogPath) .text() From 27d14d432c33051e4bdd5863ea14b207758e9234 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 5 May 2026 11:24:34 +0200 Subject: [PATCH 16/71] fix(cli): keep env reads behind ask (#9843) * fix(cli): keep env reads behind ask * fix(cli): protect pending env reads * fix(cli): share permission rule helpers --- .changeset/quiet-envs-ask.md | 5 + .../opencode/src/kilocode/permission/drain.ts | 2 +- .../kilocode/permission/external-directory.ts | 15 +- .../opencode/src/kilocode/permission/read.ts | 19 +++ .../opencode/src/kilocode/permission/rule.ts | 17 ++ packages/opencode/src/permission/index.ts | 38 +++-- .../test/kilocode/permission/env-read.test.ts | 149 ++++++++++++++++++ 7 files changed, 221 insertions(+), 24 deletions(-) create mode 100644 .changeset/quiet-envs-ask.md create mode 100644 packages/opencode/src/kilocode/permission/read.ts create mode 100644 packages/opencode/src/kilocode/permission/rule.ts create mode 100644 packages/opencode/test/kilocode/permission/env-read.test.ts diff --git a/.changeset/quiet-envs-ask.md b/.changeset/quiet-envs-ask.md new file mode 100644 index 00000000000..e18627def83 --- /dev/null +++ b/.changeset/quiet-envs-ask.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prompt before reading `.env` files even after broad read permissions were previously approved. diff --git a/packages/opencode/src/kilocode/permission/drain.ts b/packages/opencode/src/kilocode/permission/drain.ts index 838f9e8f079..005b91dd795 100644 --- a/packages/opencode/src/kilocode/permission/drain.ts +++ b/packages/opencode/src/kilocode/permission/drain.ts @@ -27,7 +27,7 @@ export function drainCovered( // Never auto-resolve config file edit permissions if (ConfigProtection.isRequest(entry.info)) continue const actions = entry.info.patterns.map((pattern: string) => { - const rule = Permission.evaluate(entry.info.permission, pattern, entry.ruleset, approved) + const rule = Permission.resolve(entry.info.permission, pattern, entry.ruleset, approved) const hard = entry.hardRuleset ? Permission.evaluate(entry.info.permission, pattern, entry.hardRuleset) : undefined diff --git a/packages/opencode/src/kilocode/permission/external-directory.ts b/packages/opencode/src/kilocode/permission/external-directory.ts index 4fe5da865a0..08f74b208cd 100644 --- a/packages/opencode/src/kilocode/permission/external-directory.ts +++ b/packages/opencode/src/kilocode/permission/external-directory.ts @@ -1,21 +1,10 @@ import { evaluate as evalRule } from "@/permission/evaluate" - -type Rule = { - permission: string - pattern: string - action: "allow" | "deny" | "ask" -} - -type Ruleset = Rule[] - -function mode(rule: Rule) { - return rule.permission === "*" && rule.pattern === "*" && rule.action === "deny" -} +import { PermissionRule, type Ruleset } from "@/kilocode/permission/rule" function rules(permission: string, ruleset?: Ruleset) { if (!ruleset) return [] if (permission !== "external_directory") return ruleset - return ruleset.filter((rule) => !mode(rule)) + return ruleset.filter((rule) => !PermissionRule.mode(rule)) } export namespace ExternalDirectoryPermission { diff --git a/packages/opencode/src/kilocode/permission/read.ts b/packages/opencode/src/kilocode/permission/read.ts new file mode 100644 index 00000000000..96b58e489a4 --- /dev/null +++ b/packages/opencode/src/kilocode/permission/read.ts @@ -0,0 +1,19 @@ +import { Wildcard } from "@/util/wildcard" +import { PermissionRule, type Rule } from "@/kilocode/permission/rule" + +function guard(pattern: string) { + if (Wildcard.match(pattern, "*.env.example")) return + if (Wildcard.match(pattern, "*.env")) return "*.env" + if (Wildcard.match(pattern, "*.env.*")) return "*.env.*" +} + +export namespace ReadPermission { + export function harden(permission: string, pattern: string, rule: Rule): Rule { + if (permission !== "read") return rule + if (rule.action !== "allow") return rule + const match = guard(pattern) + if (!match) return rule + if (!PermissionRule.broad(rule)) return rule + return { permission, pattern: match, action: "ask" } + } +} diff --git a/packages/opencode/src/kilocode/permission/rule.ts b/packages/opencode/src/kilocode/permission/rule.ts new file mode 100644 index 00000000000..923ec87e991 --- /dev/null +++ b/packages/opencode/src/kilocode/permission/rule.ts @@ -0,0 +1,17 @@ +export type Rule = { + permission: string + pattern: string + action: "allow" | "deny" | "ask" +} + +export type Ruleset = Rule[] + +export namespace PermissionRule { + export function broad(rule: Rule) { + return rule.permission === "*" || rule.pattern === "*" + } + + export function mode(rule: Rule) { + return rule.permission === "*" && rule.pattern === "*" && rule.action === "deny" + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 8445a693b7b..361ff3f6459 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -21,6 +21,7 @@ import { makeRuntime } from "@/effect/run-service" // kilocode_change import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change import { Identifier } from "@/id/id" // kilocode_change import { drainCovered } from "@/kilocode/permission/drain" // kilocode_change +import { ReadPermission } from "@/kilocode/permission/read" // kilocode_change import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory" // kilocode_change const log = Log.create({ service: "permission" }) @@ -177,6 +178,22 @@ export function evaluate(permission: string, pattern: string, ...rulesets: Rules return evalRule(permission, pattern, ...rulesets) } +// kilocode_change start +export function resolve(permission: string, pattern: string, ruleset: Ruleset, ...overrides: Ruleset[]): Rule { + const evalFn = permission === "external_directory" ? ExternalDirectoryPermission.evaluate : evaluate + const base = ReadPermission.harden(permission, pattern, evalFn(permission, pattern, ruleset)) + const saved = evalFn(permission, pattern, ...overrides) + if (base.action === "deny") return base + if (saved.action === "deny") return saved + if (base.action === "ask") { + if (saved.action === "allow" && Wildcard.match(saved.pattern, base.pattern)) return saved + return base + } + if (saved.action === "allow") return saved + return base +} +// kilocode_change end + // kilocode_change start function veto(permission: string, pattern: string, ruleset?: Ruleset) { if (!ruleset) return false @@ -186,6 +203,14 @@ function veto(permission: string, pattern: string, ruleset?: Ruleset) { function subset(permission: string, ruleset: Ruleset) { return ruleset.filter((rule) => Wildcard.match(permission, rule.permission)) } + +function covered(entry: PendingEntry, approved: Ruleset, local: Ruleset) { + if (ConfigProtection.isRequest(entry.info)) return false + return entry.info.patterns.every((pattern) => { + if (veto(entry.info.permission, pattern, entry.hardRuleset)) return false + return resolve(entry.info.permission, pattern, entry.ruleset, approved, local).action === "allow" + }) +} // kilocode_change end export class Service extends Context.Service()("@opencode/Permission") {} @@ -230,11 +255,7 @@ export const layer = Layer.effect( // kilocode_change end for (const pattern of request.patterns) { - // kilocode_change start - external_directory allows must survive Ask/Plan hard rules - const rule = hardRuleset - ? ExternalDirectoryPermission.evaluate(request.permission, pattern, ruleset, approved, local) - : evaluate(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules - // kilocode_change end + const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules log.info("evaluated", { permission: request.permission, pattern, action: rule }) // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials if (veto(request.permission, pattern, hardRuleset)) { @@ -412,9 +433,7 @@ export const layer = Layer.effect( if (input.requestID) { const entry = s.pending.get(PermissionID.make(input.requestID)) - const ok = entry - ? entry.info.patterns.every((pattern) => !veto(entry.info.permission, pattern, entry.hardRuleset)) - : false // kilocode_change + const ok = entry ? covered(entry, s.approved, s.session[entry.info.sessionID] ?? []) : false // kilocode_change if (entry && ok && (!input.sessionID || entry.info.sessionID === input.sessionID)) { s.pending.delete(PermissionID.make(input.requestID)) yield* bus.publish(Event.Replied, { @@ -428,8 +447,7 @@ export const layer = Layer.effect( for (const [id, entry] of s.pending) { if (input.sessionID && entry.info.sessionID !== input.sessionID) continue - if (ConfigProtection.isRequest(entry.info)) continue - if (entry.info.patterns.some((pattern) => veto(entry.info.permission, pattern, entry.hardRuleset))) continue // kilocode_change + if (!covered(entry, s.approved, s.session[entry.info.sessionID] ?? [])) continue // kilocode_change s.pending.delete(id) yield* bus.publish(Event.Replied, { sessionID: entry.info.sessionID, diff --git a/packages/opencode/test/kilocode/permission/env-read.test.ts b/packages/opencode/test/kilocode/permission/env-read.test.ts new file mode 100644 index 00000000000..0ce6097c599 --- /dev/null +++ b/packages/opencode/test/kilocode/permission/env-read.test.ts @@ -0,0 +1,149 @@ +import { afterAll, describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Fiber, Layer } from "effect" +import { Bus } from "../../../src/bus" +import * as Config from "../../../src/config/config" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { Global } from "@opencode-ai/core/global" +import { Permission } from "../../../src/permission" +import { PermissionID } from "../../../src/permission/schema" +import { SessionID } from "../../../src/session/schema" +import { provideTmpdirInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const bus = Bus.layer +const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +const it = testEffect(env) + +afterAll(async () => { + const dir = Global.Path.config + for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) { + await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) + } + await Config.invalidate(true) +}) + +const ask = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask(input) + }) + +const reply = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.reply(input) + }) + +const allow = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.allowEverything(input) + }) + +const rejectAll = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (const req of yield* permission.list()) { + yield* permission.reply({ requestID: req.id, reply: "reject" }) + } + }) + +const waitForPending = (count: number) => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (let i = 0; i < 100; i++) { + const items = yield* permission.list() + if (items.length >= count) return items + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`)) + }) + +const rules = () => + Permission.fromConfig({ + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + }) + +function withDir(self: () => Effect.Effect) { + return provideTmpdirInstance(self, { git: true }) +} + +describe("env read permissions", () => { + it.live("broad read allow does not bypass env ask", () => + Effect.sync(() => { + const set = Permission.merge(rules(), Permission.fromConfig({ read: { "*": "allow" } })) + expect(Permission.resolve("read", "project/.env", set).action).toBe("ask") + expect(Permission.resolve("read", "project/.env.local", set).action).toBe("ask") + expect(Permission.resolve("read", "project/.env.example", set).action).toBe("allow") + }), + ) + + it.live("saved wildcard read approval does not bypass env ask", () => + withDir(() => + Effect.gen(function* () { + const session = SessionID.make("session_env") + const first = yield* ask({ + id: PermissionID.make("per_env_first"), + sessionID: session, + permission: "read", + patterns: ["README.md"], + metadata: {}, + always: ["*"], + ruleset: Permission.fromConfig({ read: "ask" }), + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("per_env_first"), reply: "always" }) + yield* Fiber.join(first) + + const second = yield* ask({ + id: PermissionID.make("per_env_second"), + sessionID: session, + permission: "read", + patterns: ["project/.env"], + metadata: {}, + always: ["*"], + ruleset: rules(), + }).pipe(Effect.forkScoped) + + const items = yield* waitForPending(1) + expect(items[0].id).toBe(PermissionID.make("per_env_second")) + + yield* rejectAll() + yield* Fiber.await(second) + }), + ), + ) + + it.live("allow everything does not resolve pending env reads", () => + withDir(() => + Effect.gen(function* () { + const asking = yield* ask({ + id: PermissionID.make("per_env_everything"), + sessionID: SessionID.make("session_env"), + permission: "read", + patterns: ["project/.env"], + metadata: {}, + always: ["*"], + ruleset: rules(), + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + yield* allow({ enable: true, requestID: "per_env_everything" }) + + const items = yield* waitForPending(1) + expect(items[0].id).toBe(PermissionID.make("per_env_everything")) + + yield* rejectAll() + yield* Fiber.await(asking) + }), + ), + ) +}) From 7be57483bd3d37b8af8b927d982bceca5b68cb5a Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 09:44:58 +0000 Subject: [PATCH 17/71] docs(upstream): keep manual merge prompt only --- .kilo/command/upstream-manual-merge.md | 11 ++--- script/upstream/README.md | 61 +------------------------- script/upstream/merge.ts | 5 +-- script/upstream/utils/git.ts | 8 ---- 4 files changed, 9 insertions(+), 76 deletions(-) diff --git a/.kilo/command/upstream-manual-merge.md b/.kilo/command/upstream-manual-merge.md index c3dbd90eb0c..6cf67bee377 100644 --- a/.kilo/command/upstream-manual-merge.md +++ b/.kilo/command/upstream-manual-merge.md @@ -68,8 +68,9 @@ again after verification. Be especially cautious when a decision is destructive, changes auth, billing, data deletion, public API compatibility, config schema behavior, migrations, provider routing, or security posture. -Read `script/upstream/README.md` -> `Common Pitfalls` before resolving. Watch -for auto-merged code referencing conflict-block declarations, related sibling -files that need edits but are not unmerged, when to prefer `hybrid` over -`renamed`, function signatures drifting across a conflict boundary, and why -full turbo typecheck is the right catch-all. +Common pitfalls to watch for: +- auto-merged code can reference declarations that still live inside conflict blocks +- related sibling files can need edits even when they are not listed as unmerged +- `renamed` should be used only when behavior moves to a different file +- function signatures can drift across conflict boundaries +- full repo typecheck is the catch-all for non-conflicted call-site breakage diff --git a/script/upstream/README.md b/script/upstream/README.md index 413fb80056c..15537d4075e 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -304,70 +304,13 @@ After running the merge script, you may have remaining conflicts. To resolve: 1. Open each conflicted file 2. Look for `kilocode_change` markers to identify Kilo-specific code -3. Review `upstream-merge-report-.md` when present for the conflict - summary and manual-resolution guidance. -4. From the merge branch worktree, optionally start the project slash command: - ```bash - kilo run --command upstream-manual-merge v1.1.50 - ``` - The command is defined in `.kilo/command/upstream-manual-merge.md` and is - discovered from the repository root. It is available when Kilo is started in - the merge worktree; it will not appear if Kilo is started from one of the - reference worktrees under `.worktrees/opencode-merge/`. -5. Resolve conflicts one file at a time. For each manual file, first present - the intended strategy and wait for user approval. Keep Kilo-specific changes - and favor upstream code and architecture when it is compatible with Kilo - behavior. -6. Verify each resolved file before moving on: confirm conflict markers are - gone, compare against the reference worktrees when present, run the smallest - practical check for the touched area, summarize the tradeoff and result, and - get user approval for the resolved file. -7. Stage and commit: +3. Resolve conflicts, keeping Kilo-specific changes +4. Stage and commit: ```bash git add -A git commit -m "resolve merge conflicts" ``` -During merge runs, the script sets `merge.conflictStyle=zdiff3` in the local -repo config so conflicts include the `|||||||` base section. Keep using those -base-aware markers for manual resolution: they help compare Kilo's side, -upstream's side, and the common ancestor without reconstructing the merge. - -### Common Pitfalls - -These come up repeatedly during manual resolution and are easy to miss. Read -through before starting: - -1. **Auto-merged code outside the conflict can depend on declarations inside - it.** When picking between ours / theirs / hybrid, scan the non-conflicting - parts of the same file for references whose declaration lives in the - conflict block. A naive resolution can leave callers pointing at removed or - renamed symbols. Always run typecheck after each decision batch to catch - these. - -2. **Related files can need edits even when they are not listed as unmerged.** - Upstream refactors sometimes split logic across sibling files or move the - relevant behavior to a new location. Kilo behavior may need to be ported into - the new shape even though git only reports the original file as conflicted. - Mention every touched sibling in the final summary so reviewers can find the - diff. - -3. **`renamed` is stricter than it sounds.** Treat a resolution as `renamed` - only when the Kilo behavior moves from the conflicted file to a different - file. If git already recorded the rename during automerge and the work is - just adapting content at the new path, use `hybrid`. - -4. **Function signatures can drift across a conflict boundary.** Automerge can - pick one side of a paired change without noticing that a non-conflicting - consumer relied on the other side's shape. Re-read call sites and exported - contracts after resolving, not only the conflict block itself. - -5. **Always run full turbo typecheck before declaring done.** Visually clean - resolutions can still break typing at an unrelated call site. `bun run - typecheck` from the repo root is the cheapest catch-all. Targeted per-package - typechecks are not enough -- the failing call site can live in a - non-conflicted file. - ## Rollback If something goes wrong: diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 857efe55dab..49b81da7ac8 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -253,9 +253,6 @@ async function main() { await git.ensureRerere() logger.info("git rerere enabled (resolutions will be recorded and reused automatically)") - await git.ensureConflictStyle() - logger.info("merge.conflictStyle set to zdiff3 (conflicts will include the merge base)") - // Train rerere from past upstream merge commits so the cache is populated // even on a fresh clone. This replays past merges to learn their resolutions. // The grep covers both the current convention ("merge: upstream vX.Y.Z") and the @@ -744,7 +741,7 @@ async function main() { logger.divider() logger.info("Next steps:") - logger.info(" 1. Resolve remaining conflicts manually or with /upstream-manual-merge") + logger.info(" 1. Resolve remaining conflicts manually") logger.info(" 2. git add -A && git commit -m 'resolve merge conflicts'") logger.info(` 3. git push ${config.originRemote} ${kiloBranch}`) logger.info(" 4. Create PR from " + kiloBranch + " to " + config.baseBranch) diff --git a/script/upstream/utils/git.ts b/script/upstream/utils/git.ts index be967f8c64e..a781276e983 100644 --- a/script/upstream/utils/git.ts +++ b/script/upstream/utils/git.ts @@ -267,14 +267,6 @@ export async function ensureRerere(): Promise { await $`git config rerere.autoupdate true`.quiet() } -/** - * Force zdiff3 conflict markers at the repo level so every git op during the - * merge run writes conflicts with a ||||||| base block. - */ -export async function ensureConflictStyle(): Promise { - await $`git config merge.conflictStyle zdiff3`.quiet() -} - /** * Train the rerere cache from past merge commits in the repo history. * Implements the same logic as git's contrib/rerere-train.sh: From 6986b2b879217a5b2a0fa705e7fa466bddc56fa6 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 09:43:31 +0000 Subject: [PATCH 18/71] chore(upstream): skip files Kilo has removed from upstream Add .github/TEAM_MEMBERS and other files Kilo has deleted to skipFiles so future upstream merges auto-remove them via git rm instead of surfacing delete-by-us conflicts that need manual resolution. Also drop the stale github/.gitignore entry from keepOurs (the file does not exist in Kilo main; keepOurs was a no-op) and move it to skipFiles so upstream's copy is dropped on merge instead of added. --- script/upstream/utils/config.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/script/upstream/utils/config.ts b/script/upstream/utils/config.ts index d8dd0c020a0..d3deee41c17 100644 --- a/script/upstream/utils/config.ts +++ b/script/upstream/utils/config.ts @@ -83,7 +83,6 @@ export const defaultConfig: MergeConfig = { // GitHub Action - Kilo version is fully ported and complete "github/action.yml", "github/README.md", - "github/.gitignore", "github/script/release", "github/script/publish", ], @@ -115,11 +114,26 @@ export const defaultConfig: MergeConfig = { "README.zht.md", // Stats file "STATS.md", + // Team members file (Kilo doesn't maintain this upstream list) + ".github/TEAM_MEMBERS", // Workflows that don't exist in Kilo ".github/workflows/update-nix-hashes.yml", ".github/workflows/deploy.yml", ".github/workflows/docs-update.yml", ".github/workflows/docs-locale-sync.yml", + // Workflows deleted in Kilo (replaced or no longer needed) + ".github/workflows/opencode.yml", + ".github/workflows/publish-vscode.yml", + // VS Code example configs (Kilo ships real .vscode/* files) + ".vscode/launch.example.json", + ".vscode/settings.example.json", + // Nix files for packages Kilo has removed / replaced with nix/kilo.nix + "nix/desktop.nix", + "nix/opencode.nix", + // opencode CLI bin (Kilo uses its own build output) + "packages/opencode/bin/opencode", + // Removed prompt file + "packages/opencode/src/session/prompt/build-switch.txt", // Vouch files (Kilo doesn't use Vouch). // Upstream currently ships VOUCHED.td (typo extension). The glob covers both // the current .td file and any future .md rename without another merge breaking. @@ -148,6 +162,7 @@ export const defaultConfig: MergeConfig = { "github/tsconfig.json", "github/bun.lock", "github/sst-env.d.ts", + "github/.gitignore", ], // Files that should take upstream version and apply Kilo branding transforms From 35dd6557c32a831aef0bc92940573c186a9ed6ad Mon Sep 17 00:00:00 2001 From: Scuttle Bot Date: Tue, 5 May 2026 07:18:02 -0400 Subject: [PATCH 19/71] docs: update Ask agent tool access to reflect read-only bash and MCP support (#7955) --- .../pages/code-with-ai/agents/using-agents.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md b/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md index dde8d394e53..8df51ee32c9 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md @@ -99,9 +99,9 @@ You can hold `shift` to move backwards through the list of modes, for example | Aspect | Details | |---|---| | **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | -| **Tool Access** | Read-only tools only (cannot edit files or run commands) | -| **Ideal For** | Code explanation, concept exploration, and technical learning | -| **Special Features** | Optimized for informative responses without modifying your project | +| **Tool Access** | Read-only tools (`read`, `glob`, `grep`, `list`), read-only bash commands (`cat`, `grep`, `git log`, `git diff`, `jq`, etc.), and MCP tools (with user approval). All write operations are blocked. | +| **Ideal For** | Code explanation, concept exploration, technical learning, and project investigation | +| **Special Features** | Can run read-only commands and inspect your project without modifying it. MCP tools require approval for each call. | ### plan @@ -155,9 +155,9 @@ The VSCode extension and CLI do not include a built-in Review agent. Code review | Aspect | Details | |---|---| | **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | -| **Tool Access** | Read-only tools only (cannot edit files or run commands) | -| **Ideal For** | Code explanation, concept exploration, and technical learning | -| **Special Features** | Optimized for informative responses without modifying your project | +| **Tool Access** | Read-only tools (`read`, `glob`, `grep`, `list`), read-only bash commands (`cat`, `grep`, `git log`, `git diff`, `jq`, etc.), and MCP tools (with user approval). All write operations are blocked. | +| **Ideal For** | Code explanation, concept exploration, technical learning, and project investigation | +| **Special Features** | Can run read-only commands and inspect your project without modifying it. MCP tools require approval for each call. | ### plan From 6b9b659b96d61ef229aa11bb5e1e2f8d344a559c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Tue, 5 May 2026 08:42:14 -0300 Subject: [PATCH 20/71] refactor: disable flasky windows test --- packages/opencode/test/session/prompt.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 02a492cbb48..81a90c68543 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -759,7 +759,7 @@ it.live( 3_000, ) -it.live( +unix( // kilocode_change - skip flaky cancel test on Windows CI "cancel records MessageAbortedError on interrupted process", () => provideTmpdirServer( From 279a86c2b67d76549daad2c718ad9328b10908b1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 5 May 2026 13:43:00 +0200 Subject: [PATCH 21/71] test(cli): quarantine flaky MCP OAuth browser tests --- packages/opencode/script/kilocode-test-skips.ts | 5 +++++ packages/opencode/script/test-runner.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/script/kilocode-test-skips.ts diff --git a/packages/opencode/script/kilocode-test-skips.ts b/packages/opencode/script/kilocode-test-skips.ts new file mode 100644 index 00000000000..bcc36564e43 --- /dev/null +++ b/packages/opencode/script/kilocode-test-skips.ts @@ -0,0 +1,5 @@ +export const skipped = new Set([ + // Upstream browser OAuth integration tests bind the fixed callback port and + // race with other parallel OAuth tests in CI. + "mcp/oauth-browser.test.ts", +]) diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts index 3d0bd916d07..f59acd37a53 100644 --- a/packages/opencode/script/test-runner.ts +++ b/packages/opencode/script/test-runner.ts @@ -7,6 +7,7 @@ import os from "os" import path from "path" import fs from "fs/promises" +import { skipped } from "./kilocode-test-skips" // kilocode_change const root = path.resolve(import.meta.dir, "..") const argv = process.argv.slice(2) @@ -80,8 +81,9 @@ const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s) const glob = new Bun.Glob("**/*.test.{ts,tsx}") const all = (await Array.fromAsync(glob.scan({ cwd: path.join(root, "test") }))).sort() -const files = +const matched = patterns.length > 0 ? all.filter((f) => patterns.some((p) => f.includes(p) || path.join("test", f).includes(p))) : all +const files = matched.filter((f) => !skipped.has(f)) // kilocode_change if (files.length === 0) { console.log("No test files found") From 3f2ea7188b9f4a1161d59057080531f1244b0c3a Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 5 May 2026 13:44:01 +0200 Subject: [PATCH 22/71] chore(upstream): add find-reset-candidates helper Bulk-finds files that have drifted insignificantly from the last merged upstream and (optionally) resets them. Extracts shared clean/changed marker helpers into utils/markers.ts and shared reset logic into utils/reset.ts so both the single-file reset helper and the new finder use the same classification pipeline. --- script/upstream/README.md | 36 ++ script/upstream/find-reset-candidates.ts | 281 +++++++++++++++ script/upstream/fix-kilocode-markers.ts | 418 +--------------------- script/upstream/index.ts | 2 + script/upstream/package.json | 1 + script/upstream/reset-to-upstream.ts | 52 +-- script/upstream/utils/markers.ts | 423 +++++++++++++++++++++++ script/upstream/utils/reset.ts | 137 ++++++++ 8 files changed, 897 insertions(+), 453 deletions(-) create mode 100644 script/upstream/find-reset-candidates.ts create mode 100644 script/upstream/utils/markers.ts create mode 100644 script/upstream/utils/reset.ts diff --git a/script/upstream/README.md b/script/upstream/README.md index 15537d4075e..d8c9371247a 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -36,6 +36,7 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1. | `analyze.ts` | Analyze changes without merging | | `fix-kilocode-markers.ts` | Rebuild `kilocode_change` markers for one file against the last merged upstream | | `reset-to-upstream.ts` | Reset one file to the transformed last merged upstream version | +| `find-reset-candidates.ts` | Bulk-find files that have drifted insignificantly from upstream and (optionally) reset them | ### Transform Scripts @@ -256,6 +257,41 @@ Options: The command finds the newest upstream tag already merged into `HEAD`, reads that upstream version of the file, applies the same branding transforms used by upstream merge automation for text files, and writes the result to the working tree. Binary files are restored as raw upstream bytes without text transforms. If the file does not exist upstream, the local file is deleted. +### find-reset-candidates.ts + +``` +Usage: + bun run script/upstream/find-reset-candidates.ts [path] [options] + +Arguments: + path Optional repo-relative subdirectory to scope to. + Defaults to all tracked shared paths. + +Options: + --review-limit Max non-marker, non-whitespace diff lines that + still auto-resets (default: 5). + --dry-run Classify and report only; do not write any files. + --concurrency Parallel classifications (default: 8). +``` + +The command pre-filters with `git diff --name-only ..HEAD`, excluding kilo-only paths (`packages/kilo-*`, `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, `script/upstream/`), then classifies each file against the transformed upstream baseline: + +| Bucket | Meaning | Action | +|---|---|---| +| `identical` | Local bytes already match transformed upstream (branding-only drift in raw git diff) | none | +| `markers-only` | Stripping `kilocode_change` markers makes local match upstream | reset | +| `whitespace-only` | Only non-marker diff is whitespace | reset | +| `small-diff` | ≤ `--review-limit` non-marker, non-whitespace diff lines | reset | +| `large-diff` | > `--review-limit` non-marker, non-whitespace diff lines | skipped | +| `upstream-missing` | File does not exist upstream (kilo-only, intentional) | skipped | +| `local-missing` | File tracked but missing locally (deleted in Kilo) | skipped | +| `binary-diff` | Binary file differs | skipped (use `reset-to-upstream.ts` per file) | +| `binary-identical` | Binary file already matches | none | + +`markers-only`, `whitespace-only`, and `small-diff` buckets are auto-reset unless `--dry-run` is passed. A markdown summary is printed to stdout so you can review what happened and spot-check the resulting `git diff`. All resets land as uncommitted working-tree changes; `git diff` / `git checkout` is your safety net. + +Tighten the blast radius with `--review-limit 0` (only `markers-only` and `whitespace-only`) or by scoping with a `path` argument (e.g. `packages/opencode/src/mcp`). + ## Using Custom Base Branches By default, upstream merges start from the `main` branch. However, you can use `--base-branch` to start from a different branch. This is useful for: diff --git a/script/upstream/find-reset-candidates.ts b/script/upstream/find-reset-candidates.ts new file mode 100644 index 00000000000..90d3dd2c9b0 --- /dev/null +++ b/script/upstream/find-reset-candidates.ts @@ -0,0 +1,281 @@ +#!/usr/bin/env bun +/** + * Find files whose drift from the last merged upstream is insignificant and + * (optionally) reset them back to upstream. + * + * Starts from `git diff --name-only ..HEAD` to pre-filter the + * working tree, then classifies each candidate: + * + * - identical : local bytes already match transformed upstream + * - markers-only : only diff is kilocode_change markers wrapping + * identical code (stale markers) + * - whitespace-only : only diff is whitespace + * - small-diff : <= --review-limit non-marker diff lines + * - large-diff : > --review-limit non-marker diff lines (skipped) + * - upstream-missing : file does not exist upstream (kilo-only, skipped) + * - local-missing : file tracked by git but missing locally (skipped) + * - binary-identical : binary file already matches (skipped) + * - binary-diff : binary file differs (skipped; use reset-to-upstream.ts + * per file if you want to reset binaries) + * + * markers-only, whitespace-only, and small-diff buckets are auto-reset unless + * --dry-run is passed. A markdown summary is printed to stdout at the end so + * you can review what happened and spot-check the resulting `git diff`. + * + * Usage: + * bun run script/upstream/find-reset-candidates.ts + * bun run script/upstream/find-reset-candidates.ts packages/opencode/src/agent + * bun run script/upstream/find-reset-candidates.ts --dry-run --review-limit 3 + */ + +import { $ } from "bun" +import { error, header, info, success, warn } from "./utils/logger" +import { classifyDrift, resetFile, type Bucket, type ClassifyResult } from "./utils/reset" +import { last, normalize, root } from "./utils/upstream" + +interface Args { + scope?: string + reviewLimit: number + dryRun: boolean + concurrency: number + help: boolean +} + +interface Entry extends ClassifyResult { + file: string + reset?: boolean +} + +const KILO_ONLY_PATHS = [ + "packages/kilo-docs", + "packages/kilo-gateway", + "packages/kilo-i18n", + "packages/kilo-indexing", + "packages/kilo-jetbrains", + "packages/kilo-telemetry", + "packages/kilo-ui", + "packages/kilo-vscode", + "packages/opencode/src/kilocode", + "packages/opencode/test/kilocode", + "script/upstream", +] + +const RESET_BUCKETS = new Set(["markers-only", "whitespace-only", "small-diff"]) + +const BUCKET_ORDER: Bucket[] = [ + "markers-only", + "whitespace-only", + "small-diff", + "large-diff", + "identical", + "binary-diff", + "binary-identical", + "upstream-missing", + "local-missing", +] + +function usage() { + console.log(`Usage: bun run script/upstream/find-reset-candidates.ts [path] [options] + +Arguments: + path Optional repo-relative subdirectory to scope to. + Defaults to all tracked shared paths. + +Options: + --review-limit Max non-marker diff lines that still auto-resets + (default: 5). + --dry-run Classify and report only; do not write any files. + --concurrency Parallel classifications (default: 8). + --help Show this help message.`) +} + +function args(): Args { + const raw = process.argv.slice(2) + const skip = new Set() + + const flagValue = (names: string[]) => { + const idx = raw.findIndex((a) => names.includes(a) || names.some((n) => a.startsWith(`${n}=`))) + if (idx === -1) return undefined + skip.add(idx) + const arg = raw[idx] + if (arg.includes("=")) return arg.slice(arg.indexOf("=") + 1) + skip.add(idx + 1) + return raw[idx + 1] + } + + const reviewRaw = flagValue(["--review-limit"]) + const reviewLimit = reviewRaw === undefined ? 5 : Number(reviewRaw) + if (!Number.isFinite(reviewLimit) || reviewLimit < 0) { + throw new Error("--review-limit requires a non-negative number") + } + + const concurrencyRaw = flagValue(["--concurrency"]) + const concurrency = concurrencyRaw === undefined ? 8 : Number(concurrencyRaw) + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error("--concurrency requires a positive integer") + } + + const positional = raw.filter((a, i) => !skip.has(i) && !a.startsWith("--")) + if (positional.length > 1) throw new Error(`Unexpected extra arguments: ${positional.slice(1).join(" ")}`) + + return { + scope: positional[0], + reviewLimit, + concurrency, + dryRun: raw.includes("--dry-run"), + help: raw.includes("--help") || raw.includes("-h"), + } +} + +async function candidates(commit: string, scope: string | undefined, top: string): Promise { + const pathspecs = [scope ?? ".", ...KILO_ONLY_PATHS.map((p) => `:(exclude)${p}`)] + const result = await $`git diff --name-only ${commit}..HEAD -- ${pathspecs}`.cwd(top).quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`Failed to list candidate files: ${result.stderr.toString()}`) + } + return result.stdout + .toString() + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +async function concurrent(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const results: R[] = Array.from({ length: items.length }) + let next = 0 + const worker = async () => { + while (next < items.length) { + const idx = next++ + results[idx] = await fn(items[idx], idx) + } + } + const workers = Array.from({ length: Math.min(limit, Math.max(1, items.length)) }, worker) + await Promise.all(workers) + return results +} + +function group(entries: Entry[]): Map { + const out = new Map() + for (const entry of entries) { + const bucket = out.get(entry.bucket) ?? [] + bucket.push(entry) + out.set(entry.bucket, bucket) + } + for (const bucket of out.values()) bucket.sort((a, b) => a.file.localeCompare(b.file)) + return out +} + +function describe(bucket: Bucket, count: number, dryRun: boolean): { label: string; action: string } { + if (bucket === "markers-only") return { label: `markers-only (${count})`, action: dryRun ? "would reset" : "reset" } + if (bucket === "whitespace-only") + return { label: `whitespace-only (${count})`, action: dryRun ? "would reset" : "reset" } + if (bucket === "small-diff") return { label: `small-diff (${count})`, action: dryRun ? "would reset" : "reset" } + if (bucket === "large-diff") return { label: `large-diff (${count})`, action: "skipped" } + if (bucket === "identical") return { label: `identical (${count})`, action: "nothing to do" } + if (bucket === "binary-diff") return { label: `binary-diff (${count})`, action: "skipped" } + if (bucket === "binary-identical") return { label: `binary-identical (${count})`, action: "nothing to do" } + if (bucket === "upstream-missing") return { label: `upstream-missing (${count})`, action: "skipped" } + return { label: `local-missing (${count})`, action: "skipped" } +} + +function report(entries: Entry[], dryRun: boolean, tag: string, commit: string, scope: string, limit: number) { + const grouped = group(entries) + const lines: string[] = [] + + lines.push(`# Reset-to-upstream candidate report`) + lines.push("") + lines.push(`- Last merged upstream: **${tag}** (\`${commit.slice(0, 8)}\`)`) + lines.push(`- Scope: \`${scope}\``) + lines.push(`- Review limit: ${limit} non-marker diff line(s)`) + lines.push(`- Mode: ${dryRun ? "dry-run (no writes)" : "auto-apply"}`) + lines.push(`- Total candidates: ${entries.length}`) + lines.push("") + + lines.push(`## Summary`) + lines.push("") + lines.push(`| Bucket | Count | Action |`) + lines.push(`|---|---|---|`) + for (const bucket of BUCKET_ORDER) { + const items = grouped.get(bucket) ?? [] + if (items.length === 0) continue + const info = describe(bucket, items.length, dryRun) + lines.push(`| ${bucket} | ${items.length} | ${info.action} |`) + } + lines.push("") + + for (const bucket of BUCKET_ORDER) { + const items = grouped.get(bucket) ?? [] + if (items.length === 0) continue + const info = describe(bucket, items.length, dryRun) + lines.push(`## ${info.label} — ${info.action}`) + lines.push("") + for (const entry of items) { + const suffix = entry.lines !== undefined ? ` (${entry.lines} line${entry.lines === 1 ? "" : "s"})` : "" + const note = entry.reset === false ? " [reset failed]" : "" + lines.push(`- \`${entry.file}\`${suffix}${note}`) + } + lines.push("") + } + + return lines.join("\n") +} + +async function main() { + const opts = args() + if (opts.help) { + usage() + return + } + + const top = await root() + process.chdir(top) + + const scope = opts.scope ? normalize(top, opts.scope) : undefined + + header("Find reset-to-upstream candidates") + + const version = await last() + success(`Last merged upstream: ${version.tag} (${version.commit.slice(0, 8)})`) + + info(`Scope: ${scope ?? "(all shared paths)"}`) + info(`Review limit: ${opts.reviewLimit} non-marker diff line(s)`) + info(`Mode: ${opts.dryRun ? "dry-run" : "auto-apply"}`) + + const files = await candidates(version.commit, scope, top) + if (files.length === 0) { + success("No files differ from upstream in scope. Nothing to do.") + return + } + info(`Candidate files: ${files.length}`) + + const entries = await concurrent(files, opts.concurrency, async (file, i) => { + const result = await classifyDrift({ + root: top, + file, + commit: version.commit, + reviewLimit: opts.reviewLimit, + }) + if ((i + 1) % 25 === 0 || i === files.length - 1) { + info(`Classified ${i + 1}/${files.length}`) + } + return { file, ...result } as Entry + }) + + if (!opts.dryRun) { + const resets = entries.filter((e) => RESET_BUCKETS.has(e.bucket)) + if (resets.length > 0) info(`Resetting ${resets.length} file(s) to upstream...`) + await concurrent(resets, opts.concurrency, async (entry) => { + const result = await resetFile({ root: top, file: entry.file, commit: version.commit }) + entry.reset = result.action !== "skipped" + if (result.action === "skipped") warn(`Skipped ${entry.file}: ${result.reason ?? "unknown"}`) + }) + } + + console.log("") + console.log(report(entries, opts.dryRun, version.tag, version.commit, scope ?? "(all shared paths)", opts.reviewLimit)) +} + +main().catch((err) => { + error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/script/upstream/fix-kilocode-markers.ts b/script/upstream/fix-kilocode-markers.ts index 5545e1c9ac6..4bfad200417 100644 --- a/script/upstream/fix-kilocode-markers.ts +++ b/script/upstream/fix-kilocode-markers.ts @@ -8,11 +8,17 @@ * bun run script/upstream/fix-kilocode-markers.ts packages/opencode/src/file.ts --dry-run */ -import { $ } from "bun" -import { mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" import path from "node:path" import { error, header, info, success, warn } from "./utils/logger" +import { + annotate, + annotates, + changed, + clean, + fresh, + ranges, + supported, +} from "./utils/markers" import { last, normalize, root, translate, upstream } from "./utils/upstream" interface Args { @@ -21,66 +27,6 @@ interface Args { help: boolean } -interface Text { - lines: string[] - eol: string - final: boolean -} - -interface Clean { - text: Text - marks: Marks -} - -interface Diff { - lines: Set - deleted: number -} - -interface Range { - start: number - end: number -} - -interface Block extends Range { - before: string - after: string -} - -interface Marks { - inline: Map - starts: Map - ends: Map - blocks: Block[] - file?: string -} - -type Style = "slash" | "hash" | "jsx" | "block" - -const standalone = [ - /^\s*\/\/\s*kilocode_change\b.*$/, - /^\s*#\s*kilocode_change\b.*$/, - /^\s*\{?\s*\/\*\s*kilocode_change\b.*\*\/\}?\s*$/, -] -const start = /\bkilocode_change\s+start\b/ -const end = /\bkilocode_change\s+end\b/ -const freshmark = /\bkilocode_change\s*-\s*new\s*file\b/ -const unsupported = new Set([".json", ".jsonc", ".lock", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]) -const styles = new Map([ - [".ts", "slash"], - [".tsx", "slash"], - [".js", "slash"], - [".jsx", "slash"], - [".css", "block"], - [".yml", "hash"], - [".yaml", "hash"], - [".toml", "hash"], - [".sh", "hash"], - [".bash", "hash"], - [".zsh", "hash"], -]) -const exempt = ["script/upstream/"] - function usage() { console.log(`Usage: bun run script/upstream/fix-kilocode-markers.ts [--dry-run] @@ -104,352 +50,6 @@ function args(): Args { } } -function ext(file: string) { - return path.extname(file).toLowerCase() -} - -function supported(file: string, text: string) { - const kind = ext(file) - if (unsupported.has(kind)) return false - if (styles.has(kind)) return true - return !kind && text.startsWith("#!") -} - -function annotates(file: string) { - return !exempt.some((scope) => file.startsWith(scope)) -} - -function split(text: string): Text { - const eol = text.includes("\r\n") ? "\r\n" : "\n" - const final = text.endsWith("\n") - const body = final ? text.slice(0, text.endsWith("\r\n") ? -2 : -1) : text - return { lines: body ? body.split(/\r?\n/) : [], eol, final } -} - -function join(text: Text) { - return text.lines.join(text.eol) + (text.final ? text.eol : "") -} - -function strip(file: string, line: string): { line: string | null; mark?: string } { - if (standalone.some((item) => item.test(line))) return { line: null } - if (style(file) === "hash") return comment(line, [/^#\s*kilocode_change\b/]) - return comment(line, [/^\{\/\*\s*kilocode_change\b/, /^\/\*\s*kilocode_change\b/, /^\/\/\s*kilocode_change\b/]) -} - -function comment(line: string, tokens: RegExp[]) { - let quote = "" - let escape = false - - for (let i = 0; i < line.length; i++) { - const char = line[i] - if (!char) continue - - if (quote) { - if (escape) { - escape = false - continue - } - if (char === "\\") { - escape = true - continue - } - if (char === quote) quote = "" - continue - } - - if (char === '"' || char === "'" || char === "`") { - quote = char - continue - } - - const rest = line.slice(i) - if (tokens.some((item) => item.test(rest))) { - const next = line.slice(0, i).trimEnd() - return { line: next, mark: line.slice(next.length) } - } - } - - return { line } -} - -function clean(file: string, text: string): Clean { - const parsed = split(text) - const marks: Marks = { inline: new Map(), starts: new Map(), ends: new Map(), blocks: [] } - const lines: string[] = [] - const opens: { before: string; start?: number }[] = [] - - for (const line of parsed.lines) { - if (standalone.some((item) => item.test(line))) { - if (freshmark.test(line)) marks.file = line - if (start.test(line)) { - opens.push({ before: line }) - continue - } - if (end.test(line)) { - const open = opens.pop() - const last = lines.length - 1 - if (open?.start !== undefined && last >= open.start) { - marks.ends.set(last, line) - marks.blocks.push({ start: open.start, end: last, before: open.before, after: line }) - } - if (!open && last >= 0) marks.ends.set(last, line) - continue - } - continue - } - - const next = strip(file, line) - if (next.line === null) continue - - const index = lines.length - lines.push(next.line) - - for (const open of opens) { - if (open.start !== undefined) continue - open.start = index - marks.starts.set(index, open.before) - } - - if (next.mark) marks.inline.set(index, next.mark) - } - - return { text: { ...parsed, lines }, marks } -} - -function style(file: string): Style { - const kind = ext(file) - return styles.get(kind) ?? "hash" -} - -function context(file: string, text: Text, range: Range): Style { - const base = style(file) - if (![".tsx", ".jsx"].includes(ext(file))) return base - - if (tag(text.lines, range.start)) return "block" - if (child(text.lines, range.start)) return "jsx" - return base -} - -function nearby(lines: string[], start: number, step: number) { - for (let i = start; i >= 0 && i < lines.length; i += step) { - const line = lines[i]?.trim() - if (line) return line - } - return "" -} - -function tag(lines: string[], start: number) { - const current = lines[start]?.trim() ?? "" - if (!current) return false - if (/^[A-Za-z_$][\w$.:/-]*(=|\s*=)/.test(current)) return true - - for (let i = start - 1; i >= Math.max(0, start - 20); i--) { - const line = lines[i]?.trim() ?? "" - if (!line) continue - if (line.includes(">")) return false - if (/^<\/?[A-Za-z]/.test(line)) return true - } - - return false -} - -function child(lines: string[], start: number) { - const current = lines[start]?.trim() ?? "" - const prev = nearby(lines, start - 1, -1) - const next = nearby(lines, start + 1, 1) - - if (prev.endsWith(">") && !prev.endsWith("=>")) return true - if (next.startsWith(" a.start - b.start) - .reduce((acc, item) => { - const prev = acc.at(-1) - if (prev && item.start <= prev.end + 1) { - prev.end = Math.max(prev.end, item.end) - return acc - } - acc.push({ ...item }) - return acc - }, []) -} - -function ranges(nums: Set): Range[] { - const sorted = [...nums].sort((a, b) => a - b) - return merge( - sorted.reduce((acc, num) => { - const prev = acc.at(-1) - if (prev && num === prev.end + 1) { - prev.end = num - return acc - } - acc.push({ start: num, end: num }) - return acc - }, []), - ) -} - -function expand(found: Range[], marks: Marks) { - return merge( - found.map((range) => { - const next = { ...range } - for (const block of marks.blocks) { - if (next.end < block.start || next.start > block.end) continue - next.start = Math.min(next.start, block.start) - next.end = Math.max(next.end, block.end) - } - return next - }), - ) -} - -function boundary(line: string | undefined, kind: RegExp) { - if (!line) return false - return standalone.some((item) => item.test(line)) && kind.test(line) -} - -function gap(lines: string[], index: number) { - const next = lines.slice(index).findIndex((line) => line.trim() !== "") - return next === -1 ? -1 : index + next -} - -function collapse(lines: string[]): string[] { - const index = lines.findIndex((line, pos) => { - if (!boundary(line, end)) return false - const next = gap(lines, pos + 1) - return next !== -1 && boundary(lines[next], start) - }) - if (index === -1) return lines - - const next = gap(lines, index + 1) - return collapse(lines.filter((_, pos) => pos !== index && pos !== next)) -} - -function saved(marks: Marks, range: Range) { - return marks.blocks.find((block) => block.start === range.start && block.end === range.end) -} - -function annotate(file: string, clean: Clean, found: Range[]) { - const text = clean.text - const marks = clean.marks - const lines = [...text.lines] - - for (const range of expand(found, marks).reverse()) { - const mode = context(file, text, range) - const prior = saved(marks, range) - const before = prior?.before ?? marks.starts.get(range.start) - const after = prior?.after ?? marks.ends.get(range.end) - - if (!before && !after && range.start === range.end && inline(file, text.lines, range, mode)) { - lines[range.start] = `${lines[range.start]}${marks.inline.get(range.start) ?? note(mode)}` - continue - } - - const pad = indent(text.lines[range.start] ?? "") - const fallback = block(mode, pad) - const pair = { - start: before ?? fallback.start, - end: after ?? fallback.end, - } - lines.splice(range.end + 1, 0, pair.end) - lines.splice(range.start, 0, pair.start) - } - - return join({ ...text, lines: collapse(lines) }) -} - -function fresh(file: string, clean: Clean) { - const lines = [...clean.text.lines] - const mode = style(file) - const line = clean.marks.file ?? (mode === "hash" ? "# kilocode_change - new file" : "// kilocode_change - new file") - const at = lines[0]?.startsWith("#!") ? 1 : 0 - lines.splice(at, 0, line) - return join({ ...clean.text, lines }) -} - -function patch(out: string): Diff { - const lines = new Set() - const state = { next: 0, deleted: 0, added: 0, removed: 0 } - const flush = () => { - if (state.removed > 0 && state.added === 0) state.deleted += state.removed - state.added = 0 - state.removed = 0 - } - - for (const line of out.split("\n")) { - const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) - if (hunk) { - flush() - state.next = Number(hunk[1]) - 1 - continue - } - - if (line.startsWith("+++") || line.startsWith("---")) continue - if (line.startsWith("+")) { - if (line.slice(1).trim()) lines.add(state.next) - state.added++ - state.next++ - continue - } - if (line.startsWith("-")) { - state.removed++ - continue - } - if (line.startsWith(" ")) state.next++ - } - - flush() - return { lines, deleted: state.deleted } -} - -async function changed(base: Text, head: Text): Promise { - const dir = await mkdtemp(path.join(tmpdir(), "kilo-markers-")) - const left = path.join(dir, "upstream") - const right = path.join(dir, "current") - - try { - await Bun.write(left, join({ ...base, eol: "\n" })) - await Bun.write(right, join({ ...head, eol: "\n" })) - - const result = await $`git diff --no-index --no-ext-diff --unified=0 -- ${left} ${right}`.quiet().nothrow() - if (result.exitCode === 0) return { lines: new Set(), deleted: 0 } - if (result.exitCode === 1) return patch(result.stdout.toString()) - throw new Error(result.stderr.toString()) - } finally { - await rm(dir, { recursive: true, force: true }) - } -} - async function main() { const opts = args() if (opts.help) { diff --git a/script/upstream/index.ts b/script/upstream/index.ts index 639f8cc7af8..c1c0b6abc0f 100644 --- a/script/upstream/index.ts +++ b/script/upstream/index.ts @@ -12,6 +12,8 @@ export * from "./utils/config" export * from "./utils/version" export * from "./utils/report" export * from "./utils/upstream" +export * from "./utils/markers" +export * from "./utils/reset" // Transforms export { transformAll as transformPackageNames, transformFile } from "./transforms/package-names" diff --git a/script/upstream/package.json b/script/upstream/package.json index 4c1fb62599d..645d9459820 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -15,6 +15,7 @@ "versions": "bun run transforms/preserve-versions.ts", "fix:markers": "bun run fix-kilocode-markers.ts", "reset:upstream": "bun run reset-to-upstream.ts", + "find:candidates": "bun run find-reset-candidates.ts", "keep-ours": "bun run transforms/keep-ours.ts" }, "dependencies": { diff --git a/script/upstream/reset-to-upstream.ts b/script/upstream/reset-to-upstream.ts index d70d7e6d025..516b37e327c 100644 --- a/script/upstream/reset-to-upstream.ts +++ b/script/upstream/reset-to-upstream.ts @@ -8,10 +8,9 @@ * bun run script/upstream/reset-to-upstream.ts packages/opencode/src/file.ts --dry-run */ -import { rm } from "node:fs/promises" -import path from "node:path" import { error, header, info, success, warn } from "./utils/logger" -import { last, normalize, root, translate, upstreamData } from "./utils/upstream" +import { resetFile } from "./utils/reset" +import { last, normalize, root } from "./utils/upstream" interface Args { file?: string @@ -36,14 +35,6 @@ Options: --help Show this help message.`) } -function binary(data: Uint8Array) { - return data.includes(0) -} - -function same(left: Uint8Array, right: Uint8Array) { - return left.length === right.length && left.every((byte, index) => byte === right[index]) -} - function args(): Args { const raw = process.argv.slice(2) return { @@ -68,52 +59,26 @@ async function main() { process.chdir(top) const file = normalize(top, opts.file) - const abs = path.join(top, file) header("Reset file to upstream") const version = await last() success(`Last merged upstream: ${version.tag} (${version.commit.slice(0, 8)})`) - const data = await upstreamData(version.commit, file) - if (data === null) { - warn(`${file} does not exist upstream`) + const result = await resetFile({ root: top, file, commit: version.commit, dryRun: opts.dryRun }) + + if (result.action === "deleted") { if (opts.dryRun) { + warn(`${file} does not exist upstream`) info(`[DRY-RUN] Would delete ${file}`) return } - - await rm(abs, { force: true }) + warn(`${file} does not exist upstream`) success(`Deleted ${file}`) return } - if (binary(data)) { - const current = await Bun.file(abs) - .arrayBuffer() - .then((buffer) => new Uint8Array(buffer)) - .catch(() => null) - if (current && same(current, data)) { - success(`${file} already matches upstream ${version.tag}`) - return - } - - if (opts.dryRun) { - info(`[DRY-RUN] Would reset binary ${file} to upstream ${version.tag}`) - return - } - - await Bun.write(abs, data) - success(`Reset binary ${file} to upstream ${version.tag}`) - return - } - - const base = new TextDecoder().decode(data) - const next = await translate(file, base) - const current = await Bun.file(abs) - .text() - .catch(() => null) - if (current === next) { + if (result.action === "identical") { success(`${file} already matches transformed upstream ${version.tag}`) return } @@ -123,7 +88,6 @@ async function main() { return } - await Bun.write(abs, next) success(`Reset ${file} to transformed upstream ${version.tag}`) } diff --git a/script/upstream/utils/markers.ts b/script/upstream/utils/markers.ts new file mode 100644 index 00000000000..8234f340fd1 --- /dev/null +++ b/script/upstream/utils/markers.ts @@ -0,0 +1,423 @@ +#!/usr/bin/env bun +/** + * Shared kilocode_change marker helpers used by both the marker fixer and the + * reset-candidate classifier. The logic here was originally inlined in + * fix-kilocode-markers.ts. + */ + +import { $ } from "bun" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" + +export interface Text { + lines: string[] + eol: string + final: boolean +} + +export interface Clean { + text: Text + marks: Marks +} + +export interface Diff { + lines: Set + deleted: number +} + +export interface Range { + start: number + end: number +} + +export interface Block extends Range { + before: string + after: string +} + +export interface Marks { + inline: Map + starts: Map + ends: Map + blocks: Block[] + file?: string +} + +export type Style = "slash" | "hash" | "jsx" | "block" + +export const standalone = [ + /^\s*\/\/\s*kilocode_change\b.*$/, + /^\s*#\s*kilocode_change\b.*$/, + /^\s*\{?\s*\/\*\s*kilocode_change\b.*\*\/\}?\s*$/, +] +export const start = /\bkilocode_change\s+start\b/ +export const end = /\bkilocode_change\s+end\b/ +export const freshmark = /\bkilocode_change\s*-\s*new\s*file\b/ +export const unsupported = new Set([".json", ".jsonc", ".lock", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]) +export const styles = new Map([ + [".ts", "slash"], + [".tsx", "slash"], + [".js", "slash"], + [".jsx", "slash"], + [".css", "block"], + [".yml", "hash"], + [".yaml", "hash"], + [".toml", "hash"], + [".sh", "hash"], + [".bash", "hash"], + [".zsh", "hash"], +]) +export const exempt = ["script/upstream/"] + +export function ext(file: string) { + return path.extname(file).toLowerCase() +} + +export function supported(file: string, text: string) { + const kind = ext(file) + if (unsupported.has(kind)) return false + if (styles.has(kind)) return true + return !kind && text.startsWith("#!") +} + +export function annotates(file: string) { + return !exempt.some((scope) => file.startsWith(scope)) +} + +export function binary(data: Uint8Array) { + return data.includes(0) +} + +export function split(text: string): Text { + const eol = text.includes("\r\n") ? "\r\n" : "\n" + const final = text.endsWith("\n") + const body = final ? text.slice(0, text.endsWith("\r\n") ? -2 : -1) : text + return { lines: body ? body.split(/\r?\n/) : [], eol, final } +} + +export function join(text: Text) { + return text.lines.join(text.eol) + (text.final ? text.eol : "") +} + +function strip(file: string, line: string): { line: string | null; mark?: string } { + if (standalone.some((item) => item.test(line))) return { line: null } + if (style(file) === "hash") return comment(line, [/^#\s*kilocode_change\b/]) + return comment(line, [/^\{\/\*\s*kilocode_change\b/, /^\/\*\s*kilocode_change\b/, /^\/\/\s*kilocode_change\b/]) +} + +function comment(line: string, tokens: RegExp[]) { + let quote = "" + let escape = false + + for (let i = 0; i < line.length; i++) { + const char = line[i] + if (!char) continue + + if (quote) { + if (escape) { + escape = false + continue + } + if (char === "\\") { + escape = true + continue + } + if (char === quote) quote = "" + continue + } + + if (char === '"' || char === "'" || char === "`") { + quote = char + continue + } + + const rest = line.slice(i) + if (tokens.some((item) => item.test(rest))) { + const next = line.slice(0, i).trimEnd() + return { line: next, mark: line.slice(next.length) } + } + } + + return { line } +} + +export function clean(file: string, text: string): Clean { + const parsed = split(text) + const marks: Marks = { inline: new Map(), starts: new Map(), ends: new Map(), blocks: [] } + const lines: string[] = [] + const opens: { before: string; start?: number }[] = [] + + for (const line of parsed.lines) { + if (standalone.some((item) => item.test(line))) { + if (freshmark.test(line)) marks.file = line + if (start.test(line)) { + opens.push({ before: line }) + continue + } + if (end.test(line)) { + const open = opens.pop() + const last = lines.length - 1 + if (open?.start !== undefined && last >= open.start) { + marks.ends.set(last, line) + marks.blocks.push({ start: open.start, end: last, before: open.before, after: line }) + } + if (!open && last >= 0) marks.ends.set(last, line) + continue + } + continue + } + + const next = strip(file, line) + if (next.line === null) continue + + const index = lines.length + lines.push(next.line) + + for (const open of opens) { + if (open.start !== undefined) continue + open.start = index + marks.starts.set(index, open.before) + } + + if (next.mark) marks.inline.set(index, next.mark) + } + + return { text: { ...parsed, lines }, marks } +} + +export function style(file: string): Style { + const kind = ext(file) + return styles.get(kind) ?? "hash" +} + +function context(file: string, text: Text, range: Range): Style { + const base = style(file) + if (![".tsx", ".jsx"].includes(ext(file))) return base + + if (tag(text.lines, range.start)) return "block" + if (child(text.lines, range.start)) return "jsx" + return base +} + +function nearby(lines: string[], start: number, step: number) { + for (let i = start; i >= 0 && i < lines.length; i += step) { + const line = lines[i]?.trim() + if (line) return line + } + return "" +} + +function tag(lines: string[], start: number) { + const current = lines[start]?.trim() ?? "" + if (!current) return false + if (/^[A-Za-z_$][\w$.:/-]*(=|\s*=)/.test(current)) return true + + for (let i = start - 1; i >= Math.max(0, start - 20); i--) { + const line = lines[i]?.trim() ?? "" + if (!line) continue + if (line.includes(">")) return false + if (/^<\/?[A-Za-z]/.test(line)) return true + } + + return false +} + +function child(lines: string[], start: number) { + const current = lines[start]?.trim() ?? "" + const prev = nearby(lines, start - 1, -1) + const next = nearby(lines, start + 1, 1) + + if (prev.endsWith(">") && !prev.endsWith("=>")) return true + if (next.startsWith(" a.start - b.start) + .reduce((acc, item) => { + const prev = acc.at(-1) + if (prev && item.start <= prev.end + 1) { + prev.end = Math.max(prev.end, item.end) + return acc + } + acc.push({ ...item }) + return acc + }, []) +} + +export function ranges(nums: Set): Range[] { + const sorted = [...nums].sort((a, b) => a - b) + return merge( + sorted.reduce((acc, num) => { + const prev = acc.at(-1) + if (prev && num === prev.end + 1) { + prev.end = num + return acc + } + acc.push({ start: num, end: num }) + return acc + }, []), + ) +} + +function expand(found: Range[], marks: Marks) { + return merge( + found.map((range) => { + const next = { ...range } + for (const block of marks.blocks) { + if (next.end < block.start || next.start > block.end) continue + next.start = Math.min(next.start, block.start) + next.end = Math.max(next.end, block.end) + } + return next + }), + ) +} + +function boundary(line: string | undefined, kind: RegExp) { + if (!line) return false + return standalone.some((item) => item.test(line)) && kind.test(line) +} + +function gap(lines: string[], index: number) { + const next = lines.slice(index).findIndex((line) => line.trim() !== "") + return next === -1 ? -1 : index + next +} + +function collapse(lines: string[]): string[] { + const index = lines.findIndex((line, pos) => { + if (!boundary(line, end)) return false + const next = gap(lines, pos + 1) + return next !== -1 && boundary(lines[next], start) + }) + if (index === -1) return lines + + const next = gap(lines, index + 1) + return collapse(lines.filter((_, pos) => pos !== index && pos !== next)) +} + +function saved(marks: Marks, range: Range) { + return marks.blocks.find((block) => block.start === range.start && block.end === range.end) +} + +export function annotate(file: string, clean: Clean, found: Range[]) { + const text = clean.text + const marks = clean.marks + const lines = [...text.lines] + + for (const range of expand(found, marks).reverse()) { + const mode = context(file, text, range) + const prior = saved(marks, range) + const before = prior?.before ?? marks.starts.get(range.start) + const after = prior?.after ?? marks.ends.get(range.end) + + if (!before && !after && range.start === range.end && inline(file, text.lines, range, mode)) { + lines[range.start] = `${lines[range.start]}${marks.inline.get(range.start) ?? note(mode)}` + continue + } + + const pad = indent(text.lines[range.start] ?? "") + const fallback = block(mode, pad) + const pair = { + start: before ?? fallback.start, + end: after ?? fallback.end, + } + lines.splice(range.end + 1, 0, pair.end) + lines.splice(range.start, 0, pair.start) + } + + return join({ ...text, lines: collapse(lines) }) +} + +export function fresh(file: string, clean: Clean) { + const lines = [...clean.text.lines] + const mode = style(file) + const line = clean.marks.file ?? (mode === "hash" ? "# kilocode_change - new file" : "// kilocode_change - new file") + const at = lines[0]?.startsWith("#!") ? 1 : 0 + lines.splice(at, 0, line) + return join({ ...clean.text, lines }) +} + +function patch(out: string): Diff { + const lines = new Set() + const state = { next: 0, deleted: 0, added: 0, removed: 0 } + const flush = () => { + if (state.removed > 0 && state.added === 0) state.deleted += state.removed + state.added = 0 + state.removed = 0 + } + + for (const line of out.split("\n")) { + const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) + if (hunk) { + flush() + state.next = Number(hunk[1]) - 1 + continue + } + + if (line.startsWith("+++") || line.startsWith("---")) continue + if (line.startsWith("+")) { + if (line.slice(1).trim()) lines.add(state.next) + state.added++ + state.next++ + continue + } + if (line.startsWith("-")) { + state.removed++ + continue + } + if (line.startsWith(" ")) state.next++ + } + + flush() + return { lines, deleted: state.deleted } +} + +export async function changed(base: Text, head: Text, opts?: { ignoreWhitespace?: boolean }): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "kilo-markers-")) + const left = path.join(dir, "upstream") + const right = path.join(dir, "current") + + try { + await Bun.write(left, join({ ...base, eol: "\n" })) + await Bun.write(right, join({ ...head, eol: "\n" })) + + const result = opts?.ignoreWhitespace + ? await $`git diff --no-index --no-ext-diff -w --unified=0 -- ${left} ${right}`.quiet().nothrow() + : await $`git diff --no-index --no-ext-diff --unified=0 -- ${left} ${right}`.quiet().nothrow() + if (result.exitCode === 0) return { lines: new Set(), deleted: 0 } + if (result.exitCode === 1) return patch(result.stdout.toString()) + throw new Error(result.stderr.toString()) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} diff --git a/script/upstream/utils/reset.ts b/script/upstream/utils/reset.ts new file mode 100644 index 00000000000..4a028b6b274 --- /dev/null +++ b/script/upstream/utils/reset.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env bun +/** + * Shared helpers for resetting a single file to the last merged upstream + * version and for classifying how far a file has drifted from upstream. + * + * Used by both reset-to-upstream.ts (single-file CLI) and + * find-reset-candidates.ts (bulk finder). + */ + +import { rm } from "node:fs/promises" +import path from "node:path" +import { binary, changed, clean, join } from "./markers" +import { translate, upstreamData } from "./upstream" + +export type ResetAction = "identical" | "deleted" | "written" | "skipped" + +export interface ResetResult { + action: ResetAction + reason?: string +} + +export interface ResetOptions { + /** Repo root absolute path. */ + root: string + /** Repo-relative file path. */ + file: string + /** Upstream commit SHA to read from. */ + commit: string + /** Do not write; report the action that would be taken. */ + dryRun?: boolean +} + +/** + * Reset a file to the transformed last merged upstream version. Binary files + * are restored as raw bytes without text transforms. Files that do not exist + * upstream are deleted from the working tree. + */ +export async function resetFile(opts: ResetOptions): Promise { + const abs = path.join(opts.root, opts.file) + const data = await upstreamData(opts.commit, opts.file) + + if (data === null) { + if (opts.dryRun) return { action: "deleted", reason: "dry-run" } + await rm(abs, { force: true }) + return { action: "deleted" } + } + + if (binary(data)) { + const current = await Bun.file(abs) + .arrayBuffer() + .then((buffer) => new Uint8Array(buffer)) + .catch(() => null) + if (current && same(current, data)) return { action: "identical" } + + if (opts.dryRun) return { action: "written", reason: "dry-run" } + await Bun.write(abs, data) + return { action: "written" } + } + + const base = new TextDecoder().decode(data) + const next = await translate(opts.file, base) + const current = await Bun.file(abs) + .text() + .catch(() => null) + if (current === next) return { action: "identical" } + + if (opts.dryRun) return { action: "written", reason: "dry-run" } + await Bun.write(abs, next) + return { action: "written" } +} + +export type Bucket = + | "identical" + | "markers-only" + | "whitespace-only" + | "small-diff" + | "large-diff" + | "upstream-missing" + | "binary-diff" + | "binary-identical" + | "local-missing" + +export interface ClassifyResult { + bucket: Bucket + /** Non-marker, non-whitespace diff line count for text diffs. */ + lines?: number +} + +export interface ClassifyOptions { + root: string + file: string + commit: string + /** Threshold for small-diff vs large-diff (inclusive upper bound for small). */ + reviewLimit: number +} + +/** + * Classify how a file compares to the transformed last merged upstream version. + * Does not touch the working tree. + */ +export async function classifyDrift(opts: ClassifyOptions): Promise { + const abs = path.join(opts.root, opts.file) + const data = await upstreamData(opts.commit, opts.file) + if (data === null) return { bucket: "upstream-missing" } + + if (binary(data)) { + const current = await Bun.file(abs) + .arrayBuffer() + .then((buffer) => new Uint8Array(buffer)) + .catch(() => null) + if (current === null) return { bucket: "local-missing" } + return same(current, data) ? { bucket: "binary-identical" } : { bucket: "binary-diff" } + } + + const upstreamText = new TextDecoder().decode(data) + const translated = await translate(opts.file, upstreamText) + const local = await Bun.file(abs) + .text() + .catch(() => null) + if (local === null) return { bucket: "local-missing" } + if (local === translated) return { bucket: "identical" } + + const cleanedLocal = clean(opts.file, local) + const cleanedUpstream = clean(opts.file, translated) + if (join(cleanedLocal.text) === join(cleanedUpstream.text)) return { bucket: "markers-only" } + + const wsDiff = await changed(cleanedUpstream.text, cleanedLocal.text, { ignoreWhitespace: true }) + if (wsDiff.lines.size === 0 && wsDiff.deleted === 0) return { bucket: "whitespace-only" } + + const count = wsDiff.lines.size + wsDiff.deleted + if (count <= opts.reviewLimit) return { bucket: "small-diff", lines: count } + return { bucket: "large-diff", lines: count } +} + +function same(left: Uint8Array, right: Uint8Array) { + return left.length === right.length && left.every((byte, index) => byte === right[index]) +} From c0c982befdcb6460e5e2c0b6897807cefd63a5c4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 5 May 2026 13:47:34 +0200 Subject: [PATCH 23/71] test(cli): allow explicit quarantined test runs --- packages/opencode/script/test-runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts index f59acd37a53..0ad7cf89007 100644 --- a/packages/opencode/script/test-runner.ts +++ b/packages/opencode/script/test-runner.ts @@ -83,7 +83,7 @@ const all = (await Array.fromAsync(glob.scan({ cwd: path.join(root, "test") }))) const matched = patterns.length > 0 ? all.filter((f) => patterns.some((p) => f.includes(p) || path.join("test", f).includes(p))) : all -const files = matched.filter((f) => !skipped.has(f)) // kilocode_change +const files = patterns.length > 0 ? matched : matched.filter((f) => !skipped.has(f)) // kilocode_change if (files.length === 0) { console.log("No test files found") From 008387a8acb0b30fb7d7e55b396f3dc8879d18fd Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 14:04:37 +0200 Subject: [PATCH 24/71] fix(cli): restore kilo branding in MCP auth toast and client name (#9892) * fix(cli): restore kilo branding in MCP auth toast and client name The 'Run: opencode mcp auth' popup / toast and the MCP protocol client 'name' field were reverted to 'opencode' by an upstream refactor (opencode PR #22913, pulled in via Kilo PR #9346). Re-apply the Kilo-branded strings with narrow kilocode_change markers and add a regression test in packages/opencode/test/kilocode/ so the next upstream merge fails a Kilo-owned test instead of shipping an 'opencode' popup. * fix: use kilocode_change block markers for startAuth signature --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> Co-authored-by: marius-kilocode --- packages/opencode/src/mcp/index.ts | 13 +++++--- .../test/kilocode/mcp-branding.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/kilocode/mcp-branding.test.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index c584c6ccb97..5a319209452 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -275,7 +275,7 @@ export const layer = Layer.effect( (t) => Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: InstallationVersion }) + const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change return withTimeout(client.connect(t), timeout).then(() => client) }, catch: (e) => (e instanceof Error ? e : new Error(String(e))), @@ -362,7 +362,7 @@ export const layer = Layer.effect( return bus .publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", - message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + message: `Server "${key}" requires authentication. Run: kilo mcp auth ${key}`, // kilocode_change variant: "warning", duration: 8000, }) @@ -742,7 +742,9 @@ export const layer = Layer.effect( return mcpConfig }) - const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, opts?: { callback?: boolean }) { // kilocode_change + // kilocode_change start - `opts?: { callback?: boolean }` parameter is Kilo-specific + const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, opts?: { callback?: boolean }) { + // kilocode_change end const mcpConfig = yield* getMcpConfig(mcpName) if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`) if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) @@ -783,7 +785,7 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => { - const client = new Client({ name: "opencode", version: InstallationVersion }) + const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change return client .connect(transport) .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) @@ -826,7 +828,8 @@ export const layer = Layer.effect( // kilocode_change start - bind only after redirect exists, and clean up if binding fails const mcpConfig = yield* getMcpConfig(mcpName) if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status - if (mcpConfig.type !== "remote") return { status: "failed", error: `MCP server ${mcpName} is not a remote server` } as Status + if (mcpConfig.type !== "remote") + return { status: "failed", error: `MCP server ${mcpName} is not a remote server` } as Status const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined const err = yield* Effect.tryPromise({ try: () => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri), diff --git a/packages/opencode/test/kilocode/mcp-branding.test.ts b/packages/opencode/test/kilocode/mcp-branding.test.ts new file mode 100644 index 00000000000..6e453a839fb --- /dev/null +++ b/packages/opencode/test/kilocode/mcp-branding.test.ts @@ -0,0 +1,33 @@ +import { describe, test, expect } from "bun:test" +import path from "path" + +// Regression guard for branding drift in user-facing MCP strings. +// +// History: upstream OpenCode has repeatedly overwritten the Kilo-branded +// toast message and MCP client `name` field during large refactors — most +// recently in upstream PR #22913 (commit 5fccdc9fc, "refactor: collapse mcp +// barrel into mcp/index.ts") which Kilo picked up via the v1.4.7 merge (PR +// #9346, commit 57630eaf1). The original fix was PR #7174. +// +// This test asserts the surviving Kilo-branded strings directly against the +// source so that the next upstream churn on this file fails the Kilo test +// suite instead of shipping an "opencode mcp auth" popup to end users. + +const mcpSource = path.join(__dirname, "..", "..", "src", "mcp", "index.ts") + +describe("Kilo MCP branding", () => { + test("auth toast tells the user to run `kilo mcp auth`, never `opencode mcp auth`", async () => { + const src = await Bun.file(mcpSource).text() + expect(src).toContain("Run: kilo mcp auth ${key}") + expect(src).not.toContain("Run: opencode mcp auth") + }) + + test("MCP `Client` instances identify themselves as `kilo`", async () => { + const src = await Bun.file(mcpSource).text() + // `name: "opencode"` is the upstream default and appears in the protocol + // handshake / client identification fields. Any new `new Client({ ... })` + // must use the Kilo brand. + const opencodeClientName = /name:\s*"opencode"/g + expect(src.match(opencodeClientName)).toBeNull() + }) +}) From 4737eedef258e2f64e1afe6699e524c3e6c08efc Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 5 May 2026 14:04:43 +0200 Subject: [PATCH 25/71] test(vscode): cover opening config files (#9864) --- .../tests/unit/open-config.test.ts | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 packages/kilo-vscode/tests/unit/open-config.test.ts diff --git a/packages/kilo-vscode/tests/unit/open-config.test.ts b/packages/kilo-vscode/tests/unit/open-config.test.ts new file mode 100644 index 00000000000..a48c4dbe302 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/open-config.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test" +import { mkdir, mkdtemp, rm, stat, writeFile } from "fs/promises" +import * as os from "os" +import * as path from "path" +import * as vscode from "vscode" +import { globalFiles, localFiles } from "../../src/kilo-provider/config-file" +import { openConfig } from "../../src/kilo-provider/open-config" + +type Uri = { fsPath: string } + +const dirs: string[] = [] + +const env = { + HOME: process.env.HOME, + KILO_CONFIG: process.env.KILO_CONFIG, + KILO_CONFIG_CONTENT: process.env.KILO_CONFIG_CONTENT, + KILO_CONFIG_DIR: process.env.KILO_CONFIG_DIR, + KILO_DISABLE_PROJECT_CONFIG: process.env.KILO_DISABLE_PROJECT_CONFIG, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, +} + +const labels = { + noWorkspace: "No workspace", + openFailed: "Open failed: {{message}}", + placeholder: "Choose config", + scope: "Scope", + sourceEnvContent: "Env content", + sourceEnvDir: "Env dir", + sourceEnvFile: "Env file", + sourceHomeKilo: "Home Kilo", + sourceHomeKilocode: "Home Kilocode", + sourceHomeOpencode: "Home Opencode", + sourceProjectKilo: "Project Kilo", + sourceProjectKilocode: "Project Kilocode", + sourceProjectOpencode: "Project Opencode", + sourceProjectRoot: "Project root", + sourceXdg: "XDG", + statusCreate: "Create", + statusLoaded: "Loaded", + statusLoadedLegacy: "Loaded legacy", + statusNotLoaded: "Not loaded", + title: "Open config", +} + +const win = vscode.window as unknown as { + showErrorMessage: ReturnType + showQuickPick: ReturnType + showTextDocument: ReturnType + showWarningMessage: ReturnType +} + +const workspace = vscode.workspace as unknown as { + fs: { + createDirectory: (uri: Uri) => Promise + stat: (uri: Uri) => Promise<{ type: number; ctime: number; mtime: number; size: number }> + writeFile: (uri: Uri, data: Uint8Array) => Promise + } + openTextDocument: ReturnType +} + +async function temp() { + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-config-")) + dirs.push(dir) + return dir +} + +async function file(name: string, body = "{}") { + await mkdir(path.dirname(name), { recursive: true }) + await writeFile(name, body) +} + +function restore() { + for (const key of Object.keys(env) as Array) { + const value = env[key] + if (value === undefined) delete process.env[key] + if (value !== undefined) process.env[key] = value + } +} + +function reset() { + win.showErrorMessage = mock(async () => undefined) + win.showQuickPick = mock(async (items: Array<{ item: unknown }>) => items[0]) + win.showTextDocument = mock(async () => undefined) + win.showWarningMessage = mock(async () => undefined) + workspace.openTextDocument = mock(async (uri: Uri) => ({ uri })) + workspace.fs.createDirectory = async (uri) => { + await mkdir(uri.fsPath, { recursive: true }) + } + workspace.fs.stat = async (uri) => { + const meta = await stat(uri.fsPath) + return { type: 1, ctime: meta.ctimeMs, mtime: meta.mtimeMs, size: meta.size } + } + workspace.fs.writeFile = async (uri, data) => { + await file(uri.fsPath, Buffer.from(data).toString()) + } +} + +afterEach(async () => { + restore() + reset() + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe("config file discovery", () => { + it("discovers global, env, legacy, and virtual config sources", async () => { + reset() + const root = await temp() + const home = path.join(root, "home") + const xdg = path.join(root, "xdg") + const extra = path.join(root, "extra") + const envfile = path.join(root, "env.jsonc") + const spy = spyOn(os, "homedir").mockReturnValue(home) + process.env.HOME = home + process.env.XDG_CONFIG_HOME = xdg + process.env.KILO_CONFIG = envfile + process.env.KILO_CONFIG_DIR = extra + process.env.KILO_CONFIG_CONTENT = "{}" + await file(path.join(xdg, "kilo", "kilo.json")) + await file(path.join(home, ".kilocode", "opencode.json")) + await file(path.join(home, ".opencode", "kilo.jsonc")) + await file(envfile) + + const list = globalFiles() + const sources = list.map((item) => item.source) + + expect(sources).toContain("sourceXdg") + expect(sources).toContain("sourceHomeKilocode") + expect(sources).toContain("sourceHomeOpencode") + expect(sources).toContain("sourceEnvFile") + expect(sources).toContain("sourceEnvDir") + expect(sources).toContain("sourceEnvContent") + expect(list.find((item) => item.source === "sourceEnvDir")?.recommended).toBe(true) + expect(list.find((item) => item.source === "sourceEnvContent")?.virtual).toBe(true) + spy.mockRestore() + }) + + it("marks project files unloaded when project config is disabled", async () => { + reset() + const root = await temp() + process.env.KILO_DISABLE_PROJECT_CONFIG = "1" + await file(path.join(root, "kilo.json")) + await file(path.join(root, ".opencode", "opencode.json")) + + const list = localFiles(root) + + expect(list.every((item) => !item.loaded)).toBe(true) + expect(list.some((item) => item.source === "sourceProjectRoot" && item.exists)).toBe(true) + expect(list.some((item) => item.source === "sourceProjectOpencode" && item.legacy)).toBe(true) + expect(list.find((item) => item.recommended)?.file).toBe(path.join(root, ".kilo", "kilo.jsonc")) + }) +}) + +describe("openConfig", () => { + it("reports local config requests without a workspace", async () => { + reset() + + await openConfig("local", labels) + + expect(win.showWarningMessage).toHaveBeenCalledWith("No workspace") + expect(win.showQuickPick).not.toHaveBeenCalled() + }) + + it("opens the only editable config without showing the picker", async () => { + reset() + const root = await temp() + const cfg = path.join(root, ".kilo", "kilo.jsonc") + await file(cfg) + + await openConfig("local", labels, root) + + expect(win.showQuickPick).not.toHaveBeenCalled() + expect(workspace.openTextDocument).toHaveBeenCalledWith(expect.objectContaining({ fsPath: cfg })) + expect(win.showTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ uri: expect.objectContaining({ fsPath: cfg }) }), + { + preview: false, + }, + ) + }) + + it("uses the picker for multiple editable configs and creates the selected recommended file", async () => { + reset() + const root = await temp() + const cfg = path.join(root, ".kilo", "kilo.jsonc") + await file(path.join(root, ".opencode", "opencode.json")) + win.showQuickPick = mock(async (items: Array<{ item: { recommended?: boolean } }>) => + items.find((item) => item.item.recommended), + ) + + await openConfig("local", labels, root) + + expect(win.showQuickPick).toHaveBeenCalled() + expect(await Bun.file(cfg).text()).toBe(`{ + "$schema": "https://app.kilo.ai/config.json" +} +`) + expect(workspace.openTextDocument).toHaveBeenCalledWith(expect.objectContaining({ fsPath: cfg })) + }) + + it("shows a localized error when opening the selected config fails", async () => { + reset() + const root = await temp() + const cfg = path.join(root, ".kilo", "kilo.jsonc") + const spy = spyOn(console, "error").mockImplementation(() => {}) + await file(cfg) + workspace.openTextDocument = mock(async () => { + throw new Error("disk denied") + }) + + await openConfig("local", labels, root) + + expect(win.showErrorMessage).toHaveBeenCalledWith("Open failed: disk denied") + spy.mockRestore() + }) +}) From 59e8effc3df8a03146f5ceddf95f79989b813417 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 5 May 2026 14:04:56 +0200 Subject: [PATCH 26/71] Make codebase indexing setting project-specific (#9855) * fix: make indexing enablement project-specific * chore: annotate indexing config changes * fix: save TUI indexing toggle per project * fix: move indexing scope logic to Kilo config --- .changeset/local-indexing-choice.md | 6 ++ packages/kilo-vscode/src/KiloProvider.ts | 7 +- .../tests/unit/config-scope.test.ts | 16 ++++ .../webview-ui/src/context/config.tsx | 18 +--- .../webview-ui/src/utils/config-scope.ts | 37 ++++++++ packages/opencode/src/config/config.ts | 19 ++-- .../kilocode/components/dialog-indexing.tsx | 11 ++- .../opencode/src/kilocode/config/config.ts | 15 ++++ .../test/kilocode/config/config.test.ts | 90 +++++++++++++++++++ 9 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 .changeset/local-indexing-choice.md create mode 100644 packages/kilo-vscode/tests/unit/config-scope.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/config-scope.ts create mode 100644 packages/opencode/test/kilocode/config/config.test.ts diff --git a/.changeset/local-indexing-choice.md b/.changeset/local-indexing-choice.md new file mode 100644 index 00000000000..a9d3b903614 --- /dev/null +++ b/.changeset/local-indexing-choice.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Respect project-specific semantic indexing decisions instead of enabling indexing globally across workspaces. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index f4ad6dd913b..a7af20b008b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2334,7 +2334,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (refreshProviders) await this.fetchAndSendProviders() } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) - const patch = { ...partial, ...project } + const patch = + partial.indexing === undefined && project.indexing === undefined + ? { ...partial, ...project } + : { ...partial, ...project, indexing: { ...(partial.indexing ?? {}), ...(project.indexing ?? {}) } } const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config const features = (this.cachedConfigMessage as { features?: unknown } | null)?.features const optimistic = @@ -2348,7 +2351,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.pending-- } } - private postConfigFailure(error: unknown): void { console.error("[Kilo New] KiloProvider: Failed to update config:", error) this.postMessage({ @@ -2357,7 +2359,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper details: getConfigErrorDetails(error), }) } - private async resolveSession(sessionID?: string, draftID?: string, context?: string) { if (!this.client) return undefined diff --git a/packages/kilo-vscode/tests/unit/config-scope.test.ts b/packages/kilo-vscode/tests/unit/config-scope.test.ts new file mode 100644 index 00000000000..8c353eb2b1a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/config-scope.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test" +import { splitConfigByScope } from "../../webview-ui/src/utils/config-scope" + +describe("splitConfigByScope", () => { + it("writes indexing enablement to project config only", () => { + const split = splitConfigByScope({ + indexing: { + enabled: true, + provider: "ollama", + }, + }) + + expect(split.global).toEqual({ indexing: { provider: "ollama" } }) + expect(split.project).toEqual({ indexing: { enabled: true } }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 29a61b8c708..70ff8087c16 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -13,21 +13,7 @@ import type { ParentComponent, Accessor } from "solid-js" import { useVSCode } from "./vscode" import type { Config, ExtensionMessage, FeatureFlags } from "../types/messages" import { deepMerge, stripNulls, resolveConfig } from "../utils/config-utils" - -// Top-level config keys that persist to the project's kilo.json rather than the -// global one. Settings that are inherently per-repository (e.g. commit message -// conventions) belong here so they don't leak across workspaces. -const PROJECT_SCOPED_KEYS: ReadonlySet = new Set(["commit_message"]) - -function splitByScope(draft: Partial) { - const global: Record = {} - const project: Record = {} - for (const [key, value] of Object.entries(draft)) { - if (PROJECT_SCOPED_KEYS.has(key)) project[key] = value - else global[key] = value - } - return { global: global as Partial, project: project as Partial } -} +import { splitConfigByScope } from "../utils/config-scope" export interface SaveError { message: string @@ -155,7 +141,7 @@ export const ConfigProvider: ParentComponent = (props) => { // Split so per-project settings (e.g. commit_message.prompt) land in the // workspace's kilo.json instead of the global one. Send one message so the // extension confirms only after both scopes are saved. - const split = splitByScope(changes) + const split = splitConfigByScope(changes) vscode.postMessage({ type: "updateConfig", config: split.global, projectConfig: split.project }) } diff --git a/packages/kilo-vscode/webview-ui/src/utils/config-scope.ts b/packages/kilo-vscode/webview-ui/src/utils/config-scope.ts new file mode 100644 index 00000000000..d55092b85aa --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/config-scope.ts @@ -0,0 +1,37 @@ +import type { Config } from "../types/messages" + +// Top-level config keys that persist to the project's kilo.json rather than the +// global one. Settings that are inherently per-repository (e.g. commit message +// conventions) belong here so they don't leak across workspaces. +const PROJECT_SCOPED_KEYS: ReadonlySet = new Set(["commit_message"]) +const PROJECT_INDEXING_KEYS: ReadonlySet = new Set(["enabled"]) + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function splitIndexing(value: unknown) { + if (!isRecord(value)) return { global: value, project: undefined } + const global = Object.fromEntries(Object.entries(value).filter(([key]) => !PROJECT_INDEXING_KEYS.has(key))) + const project = Object.fromEntries(Object.entries(value).filter(([key]) => PROJECT_INDEXING_KEYS.has(key))) + return { + global: Object.keys(global).length > 0 ? global : undefined, + project: Object.keys(project).length > 0 ? project : undefined, + } +} + +export function splitConfigByScope(draft: Partial) { + const global: Record = {} + const project: Record = {} + for (const [key, value] of Object.entries(draft)) { + if (key === "indexing") { + const scoped = splitIndexing(value) + if (scoped.global !== undefined) global[key] = scoped.global + if (scoped.project !== undefined) project[key] = scoped.project + continue + } + if (PROJECT_SCOPED_KEYS.has(key)) project[key] = value + else global[key] = value + } + return { global: global as Partial, project: project as Partial } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0537ede1834..e3c11d2afea 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -600,10 +600,14 @@ export const layer = Layer.effect( result.plugin_origins = plugins }) - const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { - result = mergeConfigConcatArrays(result, next) - return mergePluginOrigins(source, next.plugin, kind) - } + // kilocode_change start + const merge = Effect.fnUntraced(function* (source: string, next: Info, kind?: ConfigPlugin.Scope) { + const scope = kind ?? (yield* pluginScopeForSource(source)) + const scoped = KilocodeConfig.scopeIndexing(next, scope) + result = mergeConfigConcatArrays(result, scoped) + return yield* mergePluginOrigins(source, scoped.plugin, scope) + }) + // kilocode_change end for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { @@ -836,16 +840,19 @@ export const layer = Layer.effect( // kilocode_change end // macOS managed preferences (.mobileconfig deployed via MDM) override everything + // kilocode_change start const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) if (managed) { - result = mergeConfigConcatArrays( - result, + yield* merge( + managed.source, yield* loadConfig(managed.text, { dir: path.dirname(managed.source), source: managed.source, }), + "global", ) } + // kilocode_change end for (const [name, mode] of Object.entries(result.mode ?? {})) { result.agent = mergeDeep(result.agent ?? {}, { diff --git a/packages/opencode/src/kilocode/components/dialog-indexing.tsx b/packages/opencode/src/kilocode/components/dialog-indexing.tsx index 67e393a9c83..b09c1dd957a 100644 --- a/packages/opencode/src/kilocode/components/dialog-indexing.tsx +++ b/packages/opencode/src/kilocode/components/dialog-indexing.tsx @@ -76,8 +76,15 @@ async function saveIndexing( indexing: IndexingConfig, toast: ReturnType, ): Promise { - const response = await sdk.client.global.config.update({ config: { indexing } }) - if (response.error) { + const global = { ...indexing } + delete global.enabled + const responses = await Promise.all([ + ...(Object.keys(global).length > 0 ? [sdk.client.global.config.update({ config: { indexing: global } })] : []), + ...(indexing.enabled !== undefined + ? [sdk.client.config.update({ config: { indexing: { enabled: indexing.enabled } } })] + : []), + ]) + if (responses.some((response) => response.error)) { toast.show({ message: "Failed to save indexing config", variant: "error" }) return false } diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 875a7ec878d..893d991be0c 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -109,6 +109,21 @@ export namespace KilocodeConfig { yield* input.fs.writeWithDirs(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) }) + export function scopeIndexing(info: Config.Info, scope: "global" | "local"): Config.Info { + if (scope !== "global") return info + return stripGlobalIndexing(info) + } + + function stripGlobalIndexing(info: Config.Info): Config.Info { + // Indexing provider/storage settings can be global, but enablement is a per-project decision. + if (info.indexing?.enabled === undefined) return info + const indexing = Object.fromEntries(Object.entries(info.indexing).filter(([key]) => key !== "enabled")) + if (Object.keys(indexing).length > 0) return { ...info, indexing } + const copy = { ...info } + delete copy.indexing + return copy + } + // ── Warning helpers ────────────────────────────────────────────────── /** Convert known config-loading error types into a Warning. Returns undefined for unknown errors. */ diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts new file mode 100644 index 00000000000..820fe88757b --- /dev/null +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -0,0 +1,90 @@ +// kilocode_change - new file +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, Layer, Option } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import path from "path" +import { Global } from "@opencode-ai/core/global" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { Npm } from "@opencode-ai/core/npm" +import { toIndexingConfigInput } from "@kilocode/kilo-indexing/config" +import { Account } from "../../../src/account/account" +import { Auth } from "../../../src/auth" +import { Config } from "../../../src/config/config" +import { Env } from "../../../src/env" +import { Instance } from "../../../src/project/instance" +import { Filesystem } from "../../../src/util/filesystem" +import { tmpdir } from "../../fixture/fixture" + +const infra = CrossSpawnSpawner.defaultLayer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) +const emptyAuth = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + which: () => Effect.succeed(Option.none()), +}) +const layer = Config.layer.pipe( + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), +) + +const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) +const clear = (wait = false) => + Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) + +async function writeConfig(dir: string, config: object, name = "kilo.json") { + await Filesystem.write(path.join(dir, name), JSON.stringify(config)) +} + +describe("kilocode indexing config", () => { + afterEach(async () => { + await Instance.disposeAll() + await clear(true) + }) + + test("does not inherit global indexing enabled into project config", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir() + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear(true) + + try { + await writeConfig(globalTmp.path, { + $schema: "https://app.kilo.ai/config.json", + indexing: { + enabled: true, + provider: "ollama", + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.indexing?.provider).toBe("ollama") + expect(config.indexing?.enabled).toBeUndefined() + expect(toIndexingConfigInput(config.indexing).enabled).toBe(false) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } + }) +}) From 080faf4df88ad77762092a6c05a3c0b10297bb96 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 12:15:40 +0000 Subject: [PATCH 27/71] fix(vscode): append trailing space after @ mention autocomplete When autocompleting a file, folder, or special mention via the @ menu in the chat textarea, insert a single trailing space (unless the next char is already whitespace) so typing immediately after selection does not get appended to the path and break attachment parsing. --- .../mention-autocomplete-trailing-space.md | 5 ++ .../tests/unit/file-mention-utils.test.ts | 55 +++++++++++++++++-- .../src/hooks/file-mention-utils.ts | 6 +- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 .changeset/mention-autocomplete-trailing-space.md diff --git a/.changeset/mention-autocomplete-trailing-space.md b/.changeset/mention-autocomplete-trailing-space.md new file mode 100644 index 00000000000..621c0c55a80 --- /dev/null +++ b/.changeset/mention-autocomplete-trailing-space.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Append a trailing space when autocompleting a file, folder, or special mention (terminal, git changes) via the `@` menu in the chat textarea, so typing immediately after selection no longer breaks the attachment. diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 2ac24237642..a35bb14822e 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -127,20 +127,67 @@ describe("buildTextAfterMentionSelect", () => { expect(result).toBe("hello @src/component.ts world") }) - it("handles @mention at start of string", () => { + it("handles @mention at start of string and appends trailing space", () => { const result = buildTextAfterMentionSelect("@par", "", "foo.ts") - expect(result).toBe("@foo.ts") + expect(result).toBe("@foo.ts ") }) - it("preserves space prefix before @mention", () => { + it("preserves space prefix before @mention and appends trailing space", () => { const result = buildTextAfterMentionSelect("text @par", "", "foo.ts") - expect(result).toBe("text @foo.ts") + expect(result).toBe("text @foo.ts ") }) it("appends suffix after replacement", () => { const result = buildTextAfterMentionSelect("before @q", " after text", "file.ts") expect(result).toContain("after text") }) + + it("appends a trailing space when there is no text after the cursor", () => { + const result = buildTextAfterMentionSelect("hello @par", "", "src/foo.ts") + expect(result).toBe("hello @src/foo.ts ") + }) + + it("appends a trailing space when the next char is not whitespace", () => { + const result = buildTextAfterMentionSelect("hello @par", "tail", "src/foo.ts") + expect(result).toBe("hello @src/foo.ts tail") + }) + + it("does not double-space when a space already follows the cursor", () => { + const result = buildTextAfterMentionSelect("hello @par", " tail", "src/foo.ts") + expect(result).toBe("hello @src/foo.ts tail") + }) + + it("does not add a space when a newline follows the cursor", () => { + const result = buildTextAfterMentionSelect("hello @par", "\nnext line", "src/foo.ts") + expect(result).toBe("hello @src/foo.ts\nnext line") + }) + + it("does not add a space when a tab follows the cursor", () => { + const result = buildTextAfterMentionSelect("hello @par", "\tnext", "src/foo.ts") + expect(result).toBe("hello @src/foo.ts\tnext") + }) + + it("works consistently for special mention tokens (terminal)", () => { + const result = buildTextAfterMentionSelect("hello @term", "", "terminal") + expect(result).toBe("hello @terminal ") + }) + + it("works consistently for special mention tokens (git-changes)", () => { + const result = buildTextAfterMentionSelect("hello @git", "", "git-changes") + expect(result).toBe("hello @git-changes ") + }) + + it("places inserted space before the original suffix so cursor lands naturally", () => { + // selectMention computes cursor position as text.length - after.length, + // which places the cursor at the start of the original `after` segment. + // Verify that an inserted space lives between the path and the cursor. + const before = "hello @par" + const after = "tail" + const result = buildTextAfterMentionSelect(before, after, "foo.ts") + const cursor = result.length - after.length + expect(result.slice(cursor - 1, cursor)).toBe(" ") + expect(result.slice(cursor)).toBe("tail") + }) }) describe("buildFileAttachments", () => { diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 5a541162fab..9617fcac5ee 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -72,6 +72,9 @@ export function syncMentionedPaths(prev: Set, text: string): Set /** * Replace the @mention pattern before the cursor with the selected path. + * Appends a trailing space after the inserted @mention unless the text + * immediately after the cursor already starts with whitespace, so the user + * can keep typing without breaking the attachment parsing. * Returns the new text string. */ export function buildTextAfterMentionSelect(before: string, after: string, path: string): string { @@ -79,7 +82,8 @@ export function buildTextAfterMentionSelect(before: string, after: string, path: const prefix = match.startsWith(" ") ? " " : "" return `${prefix}@${path}` }) - return replaced + after + const suffix = /^\s/.test(after) ? "" : " " + return replaced + suffix + after } /** From c1accfa58873bd7945d03969bf634b178217199b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 5 May 2026 14:17:33 +0200 Subject: [PATCH 28/71] fix(upstream): stop finder hanging on big assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the subprocess-based line diff in classifyDrift with an in-process multiset diff (approxDiff) so concurrent classifications don't deadlock on big text files like sprite.svg via Bun $.quiet() pipe-buffer stalls. Also filter non-code assets (SVG, PNG, fonts, archives, lock files, etc.) before classification so they don't bloat the report or stress git subprocesses. Switch kilo-only path excludes to glob pathspecs so every packages/kilo-*/ and **/kilocode/** dir is excluded without maintaining a hand list. Rename the 'whitespace-only' bucket to 'cosmetic-only' — with the multiset diff it also catches line reordering, so the old label was misleading. --- script/upstream/README.md | 14 +-- script/upstream/find-reset-candidates.ts | 114 ++++++++++++++++++----- script/upstream/utils/markers.ts | 25 +++++ script/upstream/utils/reset.ts | 16 ++-- 4 files changed, 131 insertions(+), 38 deletions(-) diff --git a/script/upstream/README.md b/script/upstream/README.md index d8c9371247a..cc4ccbee1d7 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -274,23 +274,25 @@ Options: --concurrency Parallel classifications (default: 8). ``` -The command pre-filters with `git diff --name-only ..HEAD`, excluding kilo-only paths (`packages/kilo-*`, `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, `script/upstream/`), then classifies each file against the transformed upstream baseline: +The command pre-filters with `git diff --name-only ..HEAD`, excluding kilo-only paths (anything under `packages/kilo-*/`, any `**/kilocode/**` subdir, `script/upstream/`) and non-code assets (SVG, PNG, fonts, archives, lock files, etc. — see `SKIP_EXTENSIONS` / `SKIP_FILENAMES` in the script). The remaining text files are classified against the transformed upstream baseline: | Bucket | Meaning | Action | |---|---|---| | `identical` | Local bytes already match transformed upstream (branding-only drift in raw git diff) | none | | `markers-only` | Stripping `kilocode_change` markers makes local match upstream | reset | -| `whitespace-only` | Only non-marker diff is whitespace | reset | -| `small-diff` | ≤ `--review-limit` non-marker, non-whitespace diff lines | reset | -| `large-diff` | > `--review-limit` non-marker, non-whitespace diff lines | skipped | +| `cosmetic-only` | Non-marker diff is only whitespace or reordered lines (the line multiset is identical) | reset | +| `small-diff` | ≤ `--review-limit` non-marker, non-cosmetic diff lines | reset | +| `large-diff` | > `--review-limit` non-marker, non-cosmetic diff lines | skipped | | `upstream-missing` | File does not exist upstream (kilo-only, intentional) | skipped | | `local-missing` | File tracked but missing locally (deleted in Kilo) | skipped | | `binary-diff` | Binary file differs | skipped (use `reset-to-upstream.ts` per file) | | `binary-identical` | Binary file already matches | none | -`markers-only`, `whitespace-only`, and `small-diff` buckets are auto-reset unless `--dry-run` is passed. A markdown summary is printed to stdout so you can review what happened and spot-check the resulting `git diff`. All resets land as uncommitted working-tree changes; `git diff` / `git checkout` is your safety net. +Line counting uses an in-process multiset diff (pure JS, no subprocess) for speed and robustness against concurrent git output stalls on big files. Moved/reordered lines therefore count as zero drift, which is usually what you want for "is this file meaningfully different from upstream". -Tighten the blast radius with `--review-limit 0` (only `markers-only` and `whitespace-only`) or by scoping with a `path` argument (e.g. `packages/opencode/src/mcp`). +`markers-only`, `cosmetic-only`, and `small-diff` buckets are auto-reset unless `--dry-run` is passed. A markdown summary is printed to stdout so you can review what happened and spot-check the resulting `git diff`. All resets land as uncommitted working-tree changes; `git diff` / `git checkout` is your safety net. + +Tighten the blast radius with `--review-limit 0` (only `markers-only` and `cosmetic-only`) or by scoping with a `path` argument (e.g. `packages/opencode/src/mcp`). ## Using Custom Base Branches diff --git a/script/upstream/find-reset-candidates.ts b/script/upstream/find-reset-candidates.ts index 90d3dd2c9b0..85932b6aa6a 100644 --- a/script/upstream/find-reset-candidates.ts +++ b/script/upstream/find-reset-candidates.ts @@ -46,25 +46,52 @@ interface Entry extends ClassifyResult { reset?: boolean } -const KILO_ONLY_PATHS = [ - "packages/kilo-docs", - "packages/kilo-gateway", - "packages/kilo-i18n", - "packages/kilo-indexing", - "packages/kilo-jetbrains", - "packages/kilo-telemetry", - "packages/kilo-ui", - "packages/kilo-vscode", - "packages/opencode/src/kilocode", - "packages/opencode/test/kilocode", - "script/upstream", +const KILO_ONLY_PATHSPECS = [ + ":(exclude,glob)packages/kilo-*/**", + ":(exclude,glob)**/kilocode/**", + ":(exclude)script/upstream", ] -const RESET_BUCKETS = new Set(["markers-only", "whitespace-only", "small-diff"]) +// Non-code assets never make sense to bulk-reset. Big binary-ish files (large +// SVG sprites, icons, fonts, archives) also stress concurrent git subprocesses +// and hide real drift in the report. Use reset-to-upstream.ts per file if you +// really want to restore one of these. +const SKIP_EXTENSIONS = new Set([ + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".avif", + ".ico", + ".bmp", + ".woff", + ".woff2", + ".ttf", + ".otf", + ".eot", + ".zip", + ".tar", + ".gz", + ".br", + ".wasm", + ".bin", + ".db", + ".sqlite", + ".mp3", + ".mp4", + ".mov", + ".pdf", +]) + +const SKIP_FILENAMES = new Set(["bun.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock"]) + +const RESET_BUCKETS = new Set(["markers-only", "cosmetic-only", "small-diff"]) const BUCKET_ORDER: Bucket[] = [ "markers-only", - "whitespace-only", + "cosmetic-only", "small-diff", "large-diff", "identical", @@ -127,17 +154,37 @@ function args(): Args { } } -async function candidates(commit: string, scope: string | undefined, top: string): Promise { - const pathspecs = [scope ?? ".", ...KILO_ONLY_PATHS.map((p) => `:(exclude)${p}`)] +async function candidates( + commit: string, + scope: string | undefined, + top: string, +): Promise<{ files: string[]; skippedAssets: string[] }> { + const pathspecs = [scope ?? ".", ...KILO_ONLY_PATHSPECS] const result = await $`git diff --name-only ${commit}..HEAD -- ${pathspecs}`.cwd(top).quiet().nothrow() if (result.exitCode !== 0) { throw new Error(`Failed to list candidate files: ${result.stderr.toString()}`) } - return result.stdout + const all = result.stdout .toString() .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) + + const files: string[] = [] + const skippedAssets: string[] = [] + for (const file of all) { + if (asset(file)) skippedAssets.push(file) + else files.push(file) + } + return { files, skippedAssets } +} + +function asset(file: string): boolean { + const base = file.slice(file.lastIndexOf("/") + 1) + if (SKIP_FILENAMES.has(base)) return true + const dot = base.lastIndexOf(".") + if (dot === -1) return false + return SKIP_EXTENSIONS.has(base.slice(dot).toLowerCase()) } async function concurrent(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { @@ -167,8 +214,8 @@ function group(entries: Entry[]): Map { function describe(bucket: Bucket, count: number, dryRun: boolean): { label: string; action: string } { if (bucket === "markers-only") return { label: `markers-only (${count})`, action: dryRun ? "would reset" : "reset" } - if (bucket === "whitespace-only") - return { label: `whitespace-only (${count})`, action: dryRun ? "would reset" : "reset" } + if (bucket === "cosmetic-only") + return { label: `cosmetic-only (${count})`, action: dryRun ? "would reset" : "reset" } if (bucket === "small-diff") return { label: `small-diff (${count})`, action: dryRun ? "would reset" : "reset" } if (bucket === "large-diff") return { label: `large-diff (${count})`, action: "skipped" } if (bucket === "identical") return { label: `identical (${count})`, action: "nothing to do" } @@ -178,7 +225,15 @@ function describe(bucket: Bucket, count: number, dryRun: boolean): { label: stri return { label: `local-missing (${count})`, action: "skipped" } } -function report(entries: Entry[], dryRun: boolean, tag: string, commit: string, scope: string, limit: number) { +function report( + entries: Entry[], + skippedAssets: string[], + dryRun: boolean, + tag: string, + commit: string, + scope: string, + limit: number, +) { const grouped = group(entries) const lines: string[] = [] @@ -189,6 +244,7 @@ function report(entries: Entry[], dryRun: boolean, tag: string, commit: string, lines.push(`- Review limit: ${limit} non-marker diff line(s)`) lines.push(`- Mode: ${dryRun ? "dry-run (no writes)" : "auto-apply"}`) lines.push(`- Total candidates: ${entries.length}`) + if (skippedAssets.length > 0) lines.push(`- Non-code assets skipped: ${skippedAssets.length}`) lines.push("") lines.push(`## Summary`) @@ -201,6 +257,7 @@ function report(entries: Entry[], dryRun: boolean, tag: string, commit: string, const info = describe(bucket, items.length, dryRun) lines.push(`| ${bucket} | ${items.length} | ${info.action} |`) } + if (skippedAssets.length > 0) lines.push(`| non-code-asset | ${skippedAssets.length} | skipped |`) lines.push("") for (const bucket of BUCKET_ORDER) { @@ -241,9 +298,10 @@ async function main() { info(`Review limit: ${opts.reviewLimit} non-marker diff line(s)`) info(`Mode: ${opts.dryRun ? "dry-run" : "auto-apply"}`) - const files = await candidates(version.commit, scope, top) + const { files, skippedAssets } = await candidates(version.commit, scope, top) + if (skippedAssets.length > 0) info(`Skipping ${skippedAssets.length} non-code asset(s)`) if (files.length === 0) { - success("No files differ from upstream in scope. Nothing to do.") + success("No code files differ from upstream in scope. Nothing to do.") return } info(`Candidate files: ${files.length}`) @@ -272,7 +330,17 @@ async function main() { } console.log("") - console.log(report(entries, opts.dryRun, version.tag, version.commit, scope ?? "(all shared paths)", opts.reviewLimit)) + console.log( + report( + entries, + skippedAssets, + opts.dryRun, + version.tag, + version.commit, + scope ?? "(all shared paths)", + opts.reviewLimit, + ), + ) } main().catch((err) => { diff --git a/script/upstream/utils/markers.ts b/script/upstream/utils/markers.ts index 8234f340fd1..4f2f9dc9d86 100644 --- a/script/upstream/utils/markers.ts +++ b/script/upstream/utils/markers.ts @@ -421,3 +421,28 @@ export async function changed(base: Text, head: Text, opts?: { ignoreWhitespace? await rm(dir, { recursive: true, force: true }) } } + +/** + * Pure in-process line-diff used by bulk classifiers. Returns the number of + * non-matching lines between two texts using a multiset approach (moving a line + * around doesn't count as drift). Whitespace can optionally be ignored. + * + * Unlike `changed()`, this spawns no subprocesses so it is safe to run + * concurrently without risking pipe-buffer deadlocks on large inputs. + */ +export function approxDiff(base: string, head: string, opts?: { ignoreWhitespace?: boolean }): number { + if (base === head) return 0 + const norm = opts?.ignoreWhitespace ? (line: string) => line.replace(/\s+/g, " ").trim() : (line: string) => line + const counts = new Map() + for (const line of base.split(/\r?\n/)) { + const key = norm(line) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + for (const line of head.split(/\r?\n/)) { + const key = norm(line) + counts.set(key, (counts.get(key) ?? 0) - 1) + } + let total = 0 + for (const v of counts.values()) total += Math.abs(v) + return total +} diff --git a/script/upstream/utils/reset.ts b/script/upstream/utils/reset.ts index 4a028b6b274..2e8f078e363 100644 --- a/script/upstream/utils/reset.ts +++ b/script/upstream/utils/reset.ts @@ -9,7 +9,7 @@ import { rm } from "node:fs/promises" import path from "node:path" -import { binary, changed, clean, join } from "./markers" +import { approxDiff, binary, clean, join } from "./markers" import { translate, upstreamData } from "./upstream" export type ResetAction = "identical" | "deleted" | "written" | "skipped" @@ -72,7 +72,7 @@ export async function resetFile(opts: ResetOptions): Promise { export type Bucket = | "identical" | "markers-only" - | "whitespace-only" + | "cosmetic-only" | "small-diff" | "large-diff" | "upstream-missing" @@ -120,14 +120,12 @@ export async function classifyDrift(opts: ClassifyOptions): Promise Date: Tue, 5 May 2026 14:31:32 +0200 Subject: [PATCH 29/71] Some resets found by this script --- packages/ui/src/pierre/index.ts | 2 +- packages/ui/src/pierre/worker.ts | 4 ++-- packages/ui/sst-env.d.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index 9caee11a415..8696be34753 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -161,7 +161,7 @@ ${lineCommentStyles} export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) { return { - theme: "Kilo", // kilocode_change + theme: "Kilo", themeType: "system", disableLineNumbers: false, overflow: "wrap", diff --git a/packages/ui/src/pierre/worker.ts b/packages/ui/src/pierre/worker.ts index b39392c469a..d3097e4b120 100644 --- a/packages/ui/src/pierre/worker.ts +++ b/packages/ui/src/pierre/worker.ts @@ -13,13 +13,13 @@ function createPool(lineDiffType: "none" | "word-alt") { workerFactory, // poolSize defaults to 8. More workers = more parallelism but // also more memory. Too many can actually slow things down. - // NOTE: 2 is probably better for OpenCode, as I think 8 might be + // NOTE: 2 is probably better for Kilo, as I think 8 might be // a bit overkill, especially because Safari has a significantly slower // boot up time for workers poolSize: 2, }, { - theme: "Kilo", // kilocode_change + theme: "Kilo", lineDiffType, preferredHighlighter: "shiki-wasm", }, diff --git a/packages/ui/sst-env.d.ts b/packages/ui/sst-env.d.ts index f25b9714550..64441936d7a 100644 --- a/packages/ui/sst-env.d.ts +++ b/packages/ui/sst-env.d.ts @@ -7,4 +7,4 @@ /// import "sst" -export {} +export {} \ No newline at end of file From b7657d1aef37cefefe1557b981822535e41ea82c Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 5 May 2026 14:43:53 +0200 Subject: [PATCH 30/71] Add webview font size setting (#9824) * feat(vscode): add webview font size setting * test(vscode): guard webview font size tokens * fix(vscode): add font size locale keys * fix(vscode): preserve default webview font rendering * fix(vscode): keep provider under line cap * chore: update kilo-vscode visual regression baselines --------- Co-authored-by: github-actions[bot] --- .changeset/webview-font-size.md | 5 + .../mcp-tool-expanded-chromium-linux.png | 4 +- .../agent-behaviour-agents-chromium-linux.png | 4 +- ...aviour-edit-custom-mode-chromium-linux.png | 4 +- ...xing-provider-blur-race-chromium-linux.png | 4 +- .../mode-edit-export-chromium-linux.png | 4 +- .../mode-edit-permissions-chromium-linux.png | 4 +- .../providers-configure-chromium-linux.png | 4 +- .../settings-panel-chromium-linux.png | 4 +- packages/kilo-ui/src/components/accordion.css | 2 +- .../src/components/auto-approve-bar.css | 2 +- .../kilo-ui/src/components/basic-tool.css | 16 +-- packages/kilo-ui/src/components/button.css | 22 +-- .../kilo-ui/src/components/chat-input.css | 4 +- packages/kilo-ui/src/components/checkbox.css | 2 +- packages/kilo-ui/src/components/code.css | 2 +- packages/kilo-ui/src/components/code.tsx | 30 ++++- .../kilo-ui/src/components/context-menu.css | 2 +- packages/kilo-ui/src/components/dialog.css | 2 +- .../kilo-ui/src/components/diff-changes.css | 4 +- .../kilo-ui/src/components/dropdown-menu.css | 2 +- .../kilo-ui/src/components/error-details.css | 6 +- .../kilo-ui/src/components/inline-input.css | 2 +- packages/kilo-ui/src/components/list.css | 10 +- packages/kilo-ui/src/components/markdown.css | 2 + .../kilo-ui/src/components/message-row.css | 8 +- .../src/components/model-info-card.css | 8 +- .../kilo-ui/src/components/model-selector.css | 10 +- .../kilo-ui/src/components/prompt-input.css | 10 +- packages/kilo-ui/src/components/select.css | 16 +-- .../src/components/status-indicator.css | 4 +- packages/kilo-ui/src/components/switch.css | 2 +- packages/kilo-ui/src/components/tabs.css | 10 +- packages/kilo-ui/src/components/tag.css | 4 +- .../kilo-ui/src/components/task-header.css | 4 +- .../kilo-ui/src/components/text-field.css | 14 +- packages/kilo-ui/src/components/tooltip.css | 2 +- packages/kilo-ui/src/styles/globals.css | 25 +++- packages/kilo-vscode/package.json | 7 + .../kilo-vscode/src/DiffViewerProvider.ts | 10 +- .../kilo-vscode/src/DiffVirtualProvider.ts | 10 +- packages/kilo-vscode/src/KiloProvider.ts | 6 +- .../src/kilo-provider/font-size.ts | 13 ++ .../src/kiloclaw/KiloClawProvider.ts | 3 + packages/kilo-vscode/src/kiloclaw/types.ts | 1 + packages/kilo-vscode/src/utils.ts | 26 ++++ .../tests/unit/font-size-arch.test.ts | 115 ++++++++++++++++ .../agent-manager/agent-manager-review.css | 10 +- .../agent-manager/agent-manager.css | 125 +++++++++--------- .../agent-manager/terminal/TerminalTab.tsx | 35 +++-- .../webview-ui/kiloclaw/context/claw.tsx | 5 + .../webview-ui/kiloclaw/kiloclaw.css | 24 ++-- .../webview-ui/kiloclaw/lib/types.ts | 1 + .../components/marketplace/marketplace.css | 22 +-- .../src/components/migration/migration.css | 50 +++---- .../src/components/profile/DeviceAuthCard.tsx | 28 ++-- .../src/components/profile/ProfileView.tsx | 16 ++- .../components/settings/AboutKiloCodeTab.tsx | 26 ++-- .../components/settings/AgentBehaviourTab.tsx | 38 +++--- .../src/components/settings/BrowserTab.tsx | 2 +- .../src/components/settings/ContextTab.tsx | 4 +- .../settings/CustomProviderDialog.tsx | 36 +++-- .../settings/CustomProviderModelCard.tsx | 22 ++- .../src/components/settings/DisplayTab.tsx | 18 +++ .../src/components/settings/LanguageTab.tsx | 12 +- .../src/components/settings/McpEditView.tsx | 6 +- .../components/settings/ModeCreateView.tsx | 4 +- .../src/components/settings/ModeEditView.tsx | 16 +-- .../components/settings/PermissionEditor.tsx | 22 +-- .../settings/ProviderSelectDialog.tsx | 8 +- .../src/components/settings/ProvidersTab.tsx | 38 ++++-- .../src/components/settings/Settings.tsx | 2 +- .../settings/agent-behaviour/WorkflowsTab.tsx | 10 +- .../src/components/shared/ModeSwitcher.tsx | 2 +- .../webview-ui/src/context/display.tsx | 35 ++++- .../webview-ui/src/context/server.tsx | 7 + .../kilo-vscode/webview-ui/src/font-size.ts | 27 ++++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 + .../webview-ui/src/styles/banners.css | 16 +-- .../webview-ui/src/styles/chat-layout.css | 11 +- .../webview-ui/src/styles/dialogs.css | 6 +- .../webview-ui/src/styles/history.css | 6 +- .../webview-ui/src/styles/model-selector.css | 32 ++--- .../webview-ui/src/styles/notifications.css | 12 +- .../webview-ui/src/styles/permission-dock.css | 24 ++-- .../src/styles/prompt-dropdowns.css | 14 +- .../webview-ui/src/styles/prompt-input.css | 36 ++--- .../webview-ui/src/styles/question-dock.css | 14 +- .../webview-ui/src/styles/session-actions.css | 6 +- .../webview-ui/src/styles/settings.css | 32 ++++- .../webview-ui/src/styles/suggest-bar.css | 2 +- .../webview-ui/src/styles/task-header.css | 8 +- .../webview-ui/src/styles/welcome.css | 20 +-- .../src/types/messages/extension-messages.ts | 7 + 112 files changed, 932 insertions(+), 461 deletions(-) create mode 100644 .changeset/webview-font-size.md create mode 100644 packages/kilo-vscode/src/kilo-provider/font-size.ts create mode 100644 packages/kilo-vscode/tests/unit/font-size-arch.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/font-size.ts diff --git a/.changeset/webview-font-size.md b/.changeset/webview-font-size.md new file mode 100644 index 00000000000..dd7b6b8d028 --- /dev/null +++ b/.changeset/webview-font-size.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Add a Display setting for adjusting the Kilo webview UI font size. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png index 4aa53ac29e4..e4954fe821a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c715bd9f470078fea565d3884395009981235a5e6de068c7c529f77f9dd9ebec -size 26962 +oid sha256:f0af006973871a81b4c0ae2fc6c98c88151655afbd9e63d77e970c18db861355 +size 26371 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png index 795674c1ad6..9a61e5d6f18 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e9f04f72f6227b08bff321ab4af7a794fa754dc58bd504eb88259cd244ba51b -size 27146 +oid sha256:182b21d0047fc054243eee3c21914d20fd125f946c9766fbc203b3817797d668 +size 26645 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png index eac24c76ff1..19e7ee32a14 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44f3c539accb9ef8b41ed54181990f23dba2447db0b0fafee1775522d19b7ab2 -size 46878 +oid sha256:b74f0ca7296bb81df65fe6628bc8709d743bf46f99b4c7cda67d5d1ae7c55d22 +size 47647 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png index af2c78e53c1..2283986b267 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed6e2cb028e565af2022447c99651bdab8874183a06e64d6ecab6187acffeb92 -size 57319 +oid sha256:f2130db1ea14a5e8fbf879e96529198592a785b8460d29e12746175e102d50d5 +size 53617 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png index 0b37bc8089e..171ce19dea7 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d7505312deae1654e37f2916d26f82b47eb6dc7eb2831e11481de6197172a94 -size 46679 +oid sha256:1eaae02845079c9955e95b258a137a5aae7718a0aa369f59a037a73a613514bb +size 51958 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png index f6e37711f27..2a22e74f26d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-permissions-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7dc43983d81b4cc88568dd71b7b312e73336df029f445a9c00175c9393f4331c -size 56736 +oid sha256:801756a5707206d70b5c3fdcc994df12727c7488cfbfa3e7385e0452110d2e12 +size 54350 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png index 996569f48fb..cbcae5fd2ad 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c87ac5179843ad5dadf73023f87d1f0b0589a81420e9ef406534a335598a0a2 -size 28082 +oid sha256:25f9a2c9499801c7526c6e0dd60a17ecacb873e69e02e7073f33e07b4c96841d +size 28094 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png index 2a9f410b3bb..77079da8613 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16e602e6a870e4b66a466a0895f92e9df117f6137809c79ed797f24d2b6a1e88 -size 31028 +oid sha256:73a34a22b24e42731510f9ecd3ea8827a0e39fa9b335294c45c4e8780df9a1f4 +size 35853 diff --git a/packages/kilo-ui/src/components/accordion.css b/packages/kilo-ui/src/components/accordion.css index cc21b6f88e8..ec51bc85e56 100644 --- a/packages/kilo-ui/src/components/accordion.css +++ b/packages/kilo-ui/src/components/accordion.css @@ -1,7 +1,7 @@ /* Kilo Accordion overrides */ [data-component="accordion"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); [data-slot="accordion-trigger"] { cursor: pointer; diff --git a/packages/kilo-ui/src/components/auto-approve-bar.css b/packages/kilo-ui/src/components/auto-approve-bar.css index 517e05d0846..1e4cf27bc86 100644 --- a/packages/kilo-ui/src/components/auto-approve-bar.css +++ b/packages/kilo-ui/src/components/auto-approve-bar.css @@ -13,7 +13,7 @@ } [data-slot="auto-approve-label"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); color: var(--text-base); flex: 1; } diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index 17cb5d9d07c..7ea63c439e2 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -7,7 +7,7 @@ } [data-slot="basic-tool-tool-info"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); text-align: start; } @@ -34,19 +34,19 @@ } [data-slot="basic-tool-tool-subtitle"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); } [data-slot="basic-tool-tool-title"] { - font-size: 14px; + font-size: var(--kilo-font-size-14); } [data-slot="message-part-title-text"] { - font-size: 14px; + font-size: var(--kilo-font-size-14); } [data-slot="message-part-title-filename"] { - font-size: 12px; + font-size: var(--kilo-font-size-12); } [data-slot="message-part-meta-line"] { @@ -57,7 +57,7 @@ white-space: nowrap; min-width: 0; flex-shrink: 1; - font-size: 12px; + font-size: var(--kilo-font-size-12); &.clickable { cursor: pointer; @@ -88,7 +88,7 @@ text-align: left; flex-shrink: 1; min-width: 0; - font-size: 12px; + font-size: var(--kilo-font-size-12); } [data-slot="webfetch-meta"] { @@ -164,7 +164,7 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty [data-slot="mcp-section-label"] { padding: 6px 12px 0; - font-size: 11px; + font-size: var(--kilo-font-size-11); color: var(--text-weak, var(--vscode-descriptionForeground)); } diff --git a/packages/kilo-ui/src/components/button.css b/packages/kilo-ui/src/components/button.css index 2ddd442620f..6b5b884a380 100644 --- a/packages/kilo-ui/src/components/button.css +++ b/packages/kilo-ui/src/components/button.css @@ -2,8 +2,8 @@ [data-component="button"] { font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); border-radius: 2px; cursor: pointer; border-color: transparent; @@ -86,11 +86,11 @@ } &[data-size="normal"] { - height: 32px; + min-height: 32px; padding: 6px 16px; gap: 6px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); &[data-icon] { padding: 6px 16px 6px 12px; @@ -98,11 +98,11 @@ } &[data-size="large"] { - height: 32px; + min-height: 32px; padding: 6px 16px; gap: 6px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); &[data-icon] { padding: 6px 16px 6px 12px; @@ -110,11 +110,11 @@ } &[data-size="small"] { - height: 24px; + min-height: 24px; padding: 4px 12px; gap: 4px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); &[data-icon] { padding: 4px 12px 4px 8px; diff --git a/packages/kilo-ui/src/components/chat-input.css b/packages/kilo-ui/src/components/chat-input.css index bfd29fc7028..a454b22bd7e 100644 --- a/packages/kilo-ui/src/components/chat-input.css +++ b/packages/kilo-ui/src/components/chat-input.css @@ -15,13 +15,13 @@ [data-slot="input-input"] { min-height: 80px; padding: 9px; - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; } [data-slot="input-helper-text"] { color: var(--text-weak); - font-size: 11px; + font-size: var(--kilo-font-size-11); padding: 4px 9px; } } diff --git a/packages/kilo-ui/src/components/checkbox.css b/packages/kilo-ui/src/components/checkbox.css index d4bce11bfac..97b8260f4cb 100644 --- a/packages/kilo-ui/src/components/checkbox.css +++ b/packages/kilo-ui/src/components/checkbox.css @@ -21,7 +21,7 @@ } [data-slot="checkbox-checkbox-label"] { - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; padding-inline-start: 10px; margin-inline-end: 10px; diff --git a/packages/kilo-ui/src/components/code.css b/packages/kilo-ui/src/components/code.css index 4deb4adb66a..81a7ea8da18 100644 --- a/packages/kilo-ui/src/components/code.css +++ b/packages/kilo-ui/src/components/code.css @@ -2,7 +2,7 @@ [data-component="code"] { font-family: var(--vscode-editor-font-family, Menlo, Monaco, "Courier New", monospace); - font-size: var(--vscode-editor-font-size, 13px); + font-size: var(--font-size-base); content-visibility: auto; overflow: hidden; } diff --git a/packages/kilo-ui/src/components/code.tsx b/packages/kilo-ui/src/components/code.tsx index f3f4bbf49ec..c5ff061f33f 100644 --- a/packages/kilo-ui/src/components/code.tsx +++ b/packages/kilo-ui/src/components/code.tsx @@ -22,6 +22,17 @@ const codeMetrics = { fileGap: 0, } satisfies Partial +const codeStyle = { + ...styleVariables, + "--diffs-line-height": "var(--kilo-font-size-24)", +} + +function lineHeight() { + if (typeof window === "undefined") return codeMetrics.lineHeight + const value = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--kilo-font-size-24")) + return Number.isFinite(value) ? value : codeMetrics.lineHeight +} + type SelectionSide = "additions" | "deletions" export type CodeProps = FileOptions & { @@ -172,6 +183,7 @@ export function Code(props: CodeProps) { const [findQuery, setFindQuery] = createSignal("") const [findIndex, setFindIndex] = createSignal(0) const [findCount, setFindCount] = createSignal(0) + const [height, setHeight] = createSignal(lineHeight()) let findMode: "highlights" | "overlay" = "overlay" let findHits: Range[] = [] @@ -914,7 +926,8 @@ export function Code(props: CodeProps) { return virtualizer })() - instance = isVirtual && v ? new VirtualizedFile(opts, v, codeMetrics, workerPool) : new File(opts, workerPool) + const metrics = { ...codeMetrics, lineHeight: height() } + instance = isVirtual && v ? new VirtualizedFile(opts, v, metrics, workerPool) : new File(opts, workerPool) container.innerHTML = "" const value = text() @@ -942,6 +955,19 @@ export function Code(props: CodeProps) { onCleanup(() => monitor.disconnect()) }) + createEffect(() => { + if (typeof document === "undefined") return + if (typeof MutationObserver === "undefined") return + + const root = document.documentElement + const update = () => setHeight(lineHeight()) + const monitor = new MutationObserver(update) + monitor.observe(root, { attributes: true, attributeFilter: ["style"] }) + update() + + onCleanup(() => monitor.disconnect()) + }) + createEffect(() => { rendered() const ranges = local.commentedLines ?? [] @@ -1063,7 +1089,7 @@ export function Code(props: CodeProps) { return (
h3 { - font-size: 16px; + font-size: var(--kilo-font-size-16); font-weight: 400; margin: 16px 0; color: var(--vscode-foreground, var(--text-strong)); diff --git a/packages/kilo-ui/src/components/tag.css b/packages/kilo-ui/src/components/tag.css index 416c7a08072..71389ce17a7 100644 --- a/packages/kilo-ui/src/components/tag.css +++ b/packages/kilo-ui/src/components/tag.css @@ -3,8 +3,8 @@ [data-component="tag"], [data-component="badge"] { border-radius: 2px; - font-size: 11px; - line-height: 16px; + font-size: var(--kilo-font-size-11); + line-height: var(--kilo-font-size-16); font-weight: 600; padding: 2px 4px; border: 1px solid transparent; diff --git a/packages/kilo-ui/src/components/task-header.css b/packages/kilo-ui/src/components/task-header.css index df7ddc96766..9f44c093e22 100644 --- a/packages/kilo-ui/src/components/task-header.css +++ b/packages/kilo-ui/src/components/task-header.css @@ -12,7 +12,7 @@ [data-slot="task-header-title"] { flex: 1; - font-size: 14px; + font-size: var(--kilo-font-size-14); font-weight: 500; color: var(--text-strong); overflow: hidden; @@ -25,7 +25,7 @@ align-items: center; gap: 12px; flex-shrink: 0; - font-size: 13px; + font-size: var(--kilo-font-size-13); color: var(--text-weak); white-space: nowrap; } diff --git a/packages/kilo-ui/src/components/text-field.css b/packages/kilo-ui/src/components/text-field.css index 7919d4fc3ca..a8579590cfc 100644 --- a/packages/kilo-ui/src/components/text-field.css +++ b/packages/kilo-ui/src/components/text-field.css @@ -2,8 +2,8 @@ [data-component="input"] { &[data-variant="normal"] [data-slot="input-label"] { - font-size: 12px; - line-height: 14px; + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-14); font-weight: var(--font-weight-regular); color: var(--text-base); } @@ -36,10 +36,10 @@ } &[data-variant="normal"] [data-slot="input-input"] { - height: 32px; + min-height: 32px; padding: 6px 12px; - font-size: 13px; - line-height: 16px; + font-size: var(--kilo-font-size-13); + line-height: var(--kilo-font-size-16); font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); color: var(--text-base); @@ -55,8 +55,8 @@ } &[data-variant="normal"] [data-slot="input-error"] { - font-size: 12px; - line-height: 14px; + font-size: var(--kilo-font-size-12); + line-height: var(--kilo-font-size-14); font-weight: var(--font-weight-regular); color: var(--text-on-critical-base); } diff --git a/packages/kilo-ui/src/components/tooltip.css b/packages/kilo-ui/src/components/tooltip.css index f2c6f81a133..5c38e2d8d62 100644 --- a/packages/kilo-ui/src/components/tooltip.css +++ b/packages/kilo-ui/src/components/tooltip.css @@ -2,7 +2,7 @@ [data-component="tooltip"] { border-radius: 0; - font-size: 13px; + font-size: var(--kilo-font-size-13); line-height: normal; font-weight: 400; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.36); diff --git a/packages/kilo-ui/src/styles/globals.css b/packages/kilo-ui/src/styles/globals.css index b995c31af2f..eb946b3785d 100644 --- a/packages/kilo-ui/src/styles/globals.css +++ b/packages/kilo-ui/src/styles/globals.css @@ -8,9 +8,26 @@ /* ===== Global design tokens ===== */ :root { - --font-size-small: 11px; - --font-size-base: 13px; - --font-size-large: 16px; + --kilo-font-size-10: 10px; + --kilo-font-size-11: 11px; + --kilo-font-size-12: 12px; + --kilo-font-size-13: 13px; + --kilo-font-size-14: 14px; + --kilo-font-size-15: 15px; + --kilo-font-size-16: 16px; + --kilo-font-size-17: 17px; + --kilo-font-size-18: 18px; + --kilo-font-size-19: 19px; + --kilo-font-size-20: 20px; + --kilo-font-size-21: 21px; + --kilo-font-size-22: 22px; + --kilo-font-size-23: 23px; + --kilo-font-size-24: 24px; + --kilo-font-scale: 1; + --font-size-x-small: var(--kilo-font-size-10); + --font-size-small: var(--kilo-font-size-11); + --font-size-base: var(--kilo-font-size-13); + --font-size-large: var(--kilo-font-size-16); /* Tight border radius (~2px) */ --radius-xs: 2px; @@ -131,7 +148,7 @@ html[data-theme="kilo-vscode"] { --font-family-sans: var(--vscode-font-family); --font-family-mono: var(--vscode-editor-font-family, Menlo, Monaco, "Courier New", monospace); font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); + font-size: calc(var(--vscode-font-size, 16px) * var(--kilo-font-scale, 1)); font-weight: var(--vscode-font-weight); ::-webkit-scrollbar-button { diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 30ae7b9caa5..96d635740df 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -791,6 +791,13 @@ "default": false, "description": "Start Kilo Code with the main auto-approve toggle enabled. When enabled, permission prompts are approved automatically." }, + "kilo-code.new.fontSize": { + "type": "number", + "default": 13, + "minimum": 10, + "maximum": 24, + "description": "Font size in pixels for the Kilo Code webview UI." + }, "kilo-code.new.browserAutomation.enabled": { "type": "boolean", "default": false, diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts index be434af5157..a81b51650ba 100644 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/DiffViewerProvider.ts @@ -1,7 +1,8 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "./services/cli-backend" -import { buildWebviewHtml } from "./utils" +import { buildWebviewHtml, getWebviewFontSize } from "./utils" import { GitOps } from "./agent-manager/GitOps" +import { watchFontSizeConfig } from "./kilo-provider/font-size" import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client" import { appendOutput, @@ -25,6 +26,7 @@ export class DiffViewerProvider implements vscode.Disposable { private cachedDiffTarget: DiffTarget | undefined private gitOps: GitOps private outputChannel: vscode.OutputChannel + private fontConfigDisposable: vscode.Disposable | undefined private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined constructor( @@ -73,10 +75,14 @@ export class DiffViewerProvider implements vscode.Disposable { panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg), undefined, []) panel.webview.html = this.getHtml(panel.webview) + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = watchFontSizeConfig((msg) => this.post(msg)) panel.onDidDispose(() => { this.log("Panel disposed") this.stopDiffPolling() + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined this.panel = undefined }) } @@ -89,6 +95,7 @@ export class DiffViewerProvider implements vscode.Disposable { type: "ready", vscodeLanguage: vscode.env.language, languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), + fontSize: getWebviewFontSize(), workspaceDirectory: getWorkspaceRoot(), }) this.post({ type: "diffViewer.markdownRender", render: getDiffMarkdownRender() }) @@ -255,6 +262,7 @@ export class DiffViewerProvider implements vscode.Disposable { public dispose(): void { this.stopDiffPolling() + this.fontConfigDisposable?.dispose() this.gitOps.dispose() this.panel?.dispose() this.outputChannel.dispose() diff --git a/packages/kilo-vscode/src/DiffVirtualProvider.ts b/packages/kilo-vscode/src/DiffVirtualProvider.ts index 53e8962ccb6..0e2954bbf76 100644 --- a/packages/kilo-vscode/src/DiffVirtualProvider.ts +++ b/packages/kilo-vscode/src/DiffVirtualProvider.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" -import { buildWebviewHtml } from "./utils" +import { buildWebviewHtml, getWebviewFontSize } from "./utils" +import { watchFontSizeConfig } from "./kilo-provider/font-size" import { appendOutput, getWorkspaceRoot } from "./review-utils" import { getDiffMarkdownRender, setDiffMarkdownRender } from "./review-settings" @@ -21,6 +22,7 @@ export class DiffVirtualProvider implements vscode.Disposable { private panel: vscode.WebviewPanel | undefined private pending: DiffVirtualFile | undefined private outputChannel: vscode.OutputChannel + private fontConfigDisposable: vscode.Disposable | undefined constructor(private readonly extensionUri: vscode.Uri) { this.outputChannel = vscode.window.createOutputChannel("Kilo Diff Virtual") @@ -55,8 +57,12 @@ export class DiffVirtualProvider implements vscode.Disposable { panel.webview.html = this.getHtml(panel.webview) panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg)) + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = watchFontSizeConfig((msg) => this.post(msg)) panel.onDidDispose(() => { this.log("Panel disposed") + this.fontConfigDisposable?.dispose() + this.fontConfigDisposable = undefined this.panel = undefined this.pending = undefined }) @@ -72,6 +78,7 @@ export class DiffVirtualProvider implements vscode.Disposable { type: "ready", vscodeLanguage: vscode.env.language, languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get("language"), + fontSize: getWebviewFontSize(), workspaceDirectory: getWorkspaceRoot(), }) this.pushData() @@ -113,6 +120,7 @@ export class DiffVirtualProvider implements vscode.Disposable { } public dispose(): void { + this.fontConfigDisposable?.dispose() this.panel?.dispose() this.outputChannel.dispose() } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a7af20b008b..cf4c6d4a15c 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -15,7 +15,7 @@ import { type KiloConnectionService, ServerStartupError } from "./services/cli-b import type { EditorContext, IndexingStatus } from "./services/cli-backend/types" import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController" import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" -import { buildWebviewHtml } from "./utils" +import { buildWebviewHtml, getWebviewFontSize } from "./utils" import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry" import { sessionToWebview, @@ -49,6 +49,7 @@ import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files" import { handleFileSearch } from "./kilo-provider/file-search" +import { watchFontSizeConfig } from "./kilo-provider/font-size" import { getTerminalContents } from "./services/terminal/context" import { disposeGitChangesTarget } from "./kilo-provider/git-changes-target" import { interceptMessage } from "./kilo-provider/git-changes-request" @@ -1091,6 +1092,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } }) + this.webviewMessageDisposable = watchFontSizeConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) } private openExternal(url: unknown): void { @@ -1246,10 +1248,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper extensionVersion: this.extensionVersion, vscodeLanguage: vscode.env.language, languageOverride: langConfig.get("language"), + fontSize: getWebviewFontSize(), workspaceDirectory: this.getProjectDirectory(this.currentSession?.id), }) } - this.postMessage({ type: "connectionState", state: this.connectionState }) // connect() can resolve after SSE reaches "connected" but before this diff --git a/packages/kilo-vscode/src/kilo-provider/font-size.ts b/packages/kilo-vscode/src/kilo-provider/font-size.ts new file mode 100644 index 00000000000..1338cbe3832 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/font-size.ts @@ -0,0 +1,13 @@ +import * as vscode from "vscode" +import { getWebviewFontSize } from "../utils" + +export function watchFontSizeConfig( + post: (msg: { type: "fontSizeChanged"; fontSize: number }) => void, + next?: vscode.Disposable, +) { + const font = vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("kilo-code.new.fontSize")) + post({ type: "fontSizeChanged", fontSize: getWebviewFontSize() }) + }) + return next ? vscode.Disposable.from(font, next) : font +} diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index aa5868bea75..4def9b0fda7 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -10,6 +10,7 @@ import { homedir } from "os" import type { KiloConnectionService } from "../services/cli-backend" import type { KiloClient } from "@kilocode/sdk/v2/client" import { buildWebviewHtml } from "../utils" +import { watchFontSizeConfig } from "../kilo-provider/font-size" import { connect, history, presence, type ClawChatClient } from "./chat-client" import type { KiloClawInMessage, @@ -117,6 +118,8 @@ export class KiloClawProvider implements vscode.Disposable { this.post({ type: "kiloclaw.locale", locale }) }) this.subs.push(unsub) + const font = watchFontSizeConfig((msg) => this.post(msg)) + this.subs.push(() => font.dispose()) } private post(msg: KiloClawOutMessage): void { diff --git a/packages/kilo-vscode/src/kiloclaw/types.ts b/packages/kilo-vscode/src/kiloclaw/types.ts index 0b0dd9ab71b..51f4e28631f 100644 --- a/packages/kilo-vscode/src/kiloclaw/types.ts +++ b/packages/kilo-vscode/src/kiloclaw/types.ts @@ -67,3 +67,4 @@ export type KiloClawOutMessage = | { type: "kiloclaw.status"; data: ClawStatus | null } | { type: "kiloclaw.locale"; locale: string } | { type: "kiloclaw.error"; error: string } + | { type: "fontSizeChanged"; fontSize: number } diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 6bdcfe065d6..1936575c222 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -6,6 +6,31 @@ function getNonce(): string { return crypto.randomBytes(16).toString("hex") } +const SIZES = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24] + +function clamp(size: number) { + if (!Number.isFinite(size)) return 13 + return Math.min(24, Math.max(10, Math.round(size))) +} + +export function getWebviewFontSize(): number { + const raw = vscode.workspace.getConfiguration("kilo-code.new").get("fontSize", 13) + return clamp(raw) +} + +function fontStyle(): string { + const base = getWebviewFontSize() + const vars = SIZES.map((size) => `--kilo-font-size-${size}: ${(base * size) / 13}px;`).join("\n ") + return `:root { + ${vars} + --kilo-font-scale: ${base / 13}; + --font-size-x-small: var(--kilo-font-size-10); + --font-size-small: var(--kilo-font-size-11); + --font-size-base: var(--kilo-font-size-13); + --font-size-large: var(--kilo-font-size-16); + }` +} + export function buildWebviewHtml( webview: vscode.Webview, opts: { @@ -29,6 +54,7 @@ export function buildWebviewHtml( ${opts.title}