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
This commit is contained in:
Marius
2026-05-05 14:04:56 +02:00
committed by GitHub
parent 4737eedef2
commit 59e8effc3d
9 changed files with 192 additions and 27 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Respect project-specific semantic indexing decisions instead of enabling indexing globally across workspaces.
+4 -3
View File
@@ -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
@@ -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 } })
})
})
@@ -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<string> = new Set(["commit_message"])
function splitByScope(draft: Partial<Config>) {
const global: Record<string, unknown> = {}
const project: Record<string, unknown> = {}
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<Config>, project: project as Partial<Config> }
}
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 })
}
@@ -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<string> = new Set(["commit_message"])
const PROJECT_INDEXING_KEYS: ReadonlySet<string> = new Set(["enabled"])
function isRecord(value: unknown): value is Record<string, unknown> {
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<Config>) {
const global: Record<string, unknown> = {}
const project: Record<string, unknown> = {}
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<Config>, project: project as Partial<Config> }
}
+13 -6
View File
@@ -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 ?? {}, {
@@ -76,8 +76,15 @@ async function saveIndexing(
indexing: IndexingConfig,
toast: ReturnType<typeof useToast>,
): Promise<boolean> {
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
}
@@ -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. */
@@ -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)
}
})
})