fix: code indexing on kilo core

This commit is contained in:
Catriel Müller
2026-06-11 12:12:16 -03:00
parent 35e3cd1803
commit d5112edf90
72 changed files with 3974 additions and 924 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stabilize code indexing workers, retry Kilo model catalog downloads, reduce progress log noise, and show indexing failures as TUI notifications instead of writing over the terminal interface.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Support configuring code indexing separately for global and project settings in Kilo Console, the CLI TUI, and VS Code.
+11 -1
View File
@@ -23,11 +23,13 @@ test("config writes include the selected directory", async () => {
await client.saveConfig(query, { permission: { edit: { "*": "allow" } } })
await client.unsetConfig(query, [["permission", "edit"]])
await client.patchConfig(query, { indexing: { provider: "ollama" } }, [["indexing", "model"]])
expect(calls).toHaveLength(2)
expect(calls).toHaveLength(3)
const save = calls[0]
const unset = calls[1]
const patch = calls[2]
expect(save.method).toBe("PATCH")
expect(new URL(save.url).searchParams.get("directory")).toBe("/tmp/project")
expect(save.body).toEqual({ scope: "project", set: { permission: { edit: { "*": "allow" } } } })
@@ -35,4 +37,12 @@ test("config writes include the selected directory", async () => {
expect(unset.method).toBe("PATCH")
expect(new URL(unset.url).searchParams.get("directory")).toBe("/tmp/project")
expect(unset.body).toEqual({ scope: "project", unset: [["permission", "edit"]] })
expect(patch.method).toBe("PATCH")
expect(new URL(patch.url).searchParams.get("directory")).toBe("/tmp/project")
expect(patch.body).toEqual({
scope: "project",
set: { indexing: { provider: "ollama" } },
unset: [["indexing", "model"]],
})
})
+18 -5
View File
@@ -10,6 +10,7 @@ import type {
FormatterStatusResponse,
GlobalHealthResponse,
GlobalEvent,
KiloEmbeddingModelCatalog,
LspStatusResponse,
McpStatusResponse,
Pty as PtyInfo,
@@ -400,6 +401,10 @@ export async function load(input: Query): Promise<Snapshot> {
}
}
export async function loadEmbeddingModels(input: Query): Promise<KiloEmbeddingModelCatalog> {
return demand("Kilo embedding models", await client(input).indexing.models())
}
export async function loadProjects(input: ProjectQuery): Promise<ProjectItem[]> {
const sdk = client(input)
const dir = value(input.dir)
@@ -611,16 +616,24 @@ export function ptyWsUrl(input: Query, pty: string, cursor = 0) {
return url.toString()
}
export async function saveConfig(input: Query, patch: Partial<ConfigPatch>) {
export async function patchConfig(input: Query, patch: Partial<ConfigPatch>, unset?: ConfigUnset) {
const sdk = client(input)
const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, set: patch })
const set = Object.keys(patch).length ? patch : undefined
const result = await sdk.config.overlayUpdate({
directory: value(input.dir),
scope: input.scope,
set,
unset,
})
return demand("Update config", result)
}
export async function saveConfig(input: Query, patch: Partial<ConfigPatch>) {
return patchConfig(input, patch)
}
export async function unsetConfig(input: Query, unset: ConfigUnset) {
const sdk = client(input)
const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, unset })
return demand("Update config", result)
return patchConfig(input, {}, unset)
}
export async function saveModelState(input: Query, favorite: ModelRef[]) {
@@ -6,6 +6,7 @@ import {
load,
loadCached,
loadProjects,
patchConfig,
resolveServer,
saveCached,
saveConfig,
@@ -133,6 +134,10 @@ export function ConfigProvider(props: { children?: JSX.Element }) {
run("Saving config", () => saveConfig(target(), patch))
}
function patch(update: Partial<ConfigPatch>, unset?: ConfigUnset) {
run("Saving config", () => patchConfig(target(), update, unset))
}
function unset(paths: ConfigUnset) {
run("Saving config", () => unsetConfig(target(), paths))
}
@@ -150,6 +155,7 @@ export function ConfigProvider(props: { children?: JSX.Element }) {
fail,
run,
save,
patch,
unset,
tui,
}
@@ -16,6 +16,7 @@ export type Ctx = {
fail: (message: string) => void
run: (label: string, job: () => Promise<unknown>, task?: Task) => void
save: (patch: Partial<ConfigPatch>) => void
patch: (patch: Partial<ConfigPatch>, unset?: ConfigUnset) => void
unset: (paths: ConfigUnset) => void
tui: (patch: TuiPatch) => void
}
@@ -4,13 +4,12 @@ import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { useConfig } from "../../context/config"
import { settings } from "../../shared/navigation"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { ActionSelect, label as actionLabel, tone as actionTone } from "./PermissionsRoute"
import { agentEditable, agentTitle, snippets, useAgentBuilder, type AgentEntry, type AgentItem } from "./state/agents"
import type { PermissionAction } from "./state/permissions"
@@ -1,7 +1,6 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { ConfigPage } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag } from "./ConfigPage"
import { useTuiNotificationSettings } from "./state/ui"
function Toggle(props: {
@@ -1,10 +1,9 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag } from "./ConfigPage"
import { type Theme, themeTitle, useTuiUiSettings } from "./state/ui"
const diffs = [
@@ -1,6 +1,8 @@
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { SourceBadge as UiSourceBadge } from "@kilocode/kilo-web-ui/console"
import { ConfigCountTag, ConfigTag, SourceBadge as UiSourceBadge } from "@kilocode/kilo-web-ui/console"
export { ConfigCountTag, ConfigTag }
export function ConfigPage(props: {
title: JSX.Element
@@ -2,8 +2,7 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { useFormatterSettings, type ToolRow } from "./state/formatters"
type Kind = "formatter" | "lsp"
@@ -0,0 +1,546 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import type { IndexingConfig } from "@kilocode/sdk/v2/client"
import { For, Show, createEffect, createMemo, createResource, createSignal, type JSX } from "solid-js"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { loadEmbeddingModels } from "../../client"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { clean, clone, merge, providerPatch, removed, shouldSync, validate } from "./state/indexing"
type Provider = NonNullable<IndexingConfig["provider"]>
type ProviderValue = Provider | ""
type Store = NonNullable<IndexingConfig["vectorStore"]>
type Field = { key: string; label: string; placeholder: string; secret?: boolean }
const providers = [
{ value: "", label: "Automatic" },
{ value: "kilo", label: "Kilo" },
{ value: "openai", label: "OpenAI" },
{ value: "ollama", label: "Ollama (local)" },
{ value: "openai-compatible", label: "OpenAI-compatible" },
{ value: "gemini", label: "Gemini" },
{ value: "mistral", label: "Mistral" },
{ value: "vercel-ai-gateway", label: "Vercel AI Gateway" },
{ value: "bedrock", label: "AWS Bedrock" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "voyage", label: "Voyage" },
] satisfies SelectOption<ProviderValue>[]
const stores = [
{ value: "lancedb", label: "LanceDB (default)" },
{ value: "qdrant", label: "Qdrant" },
] satisfies SelectOption<Store>[]
const fields: Record<Provider, Field[]> = {
kilo: [],
openai: [{ key: "apiKey", label: "API key", placeholder: "sk-...", secret: true }],
ollama: [{ key: "baseUrl", label: "Base URL", placeholder: "http://localhost:11434" }],
"openai-compatible": [
{ key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" },
{ key: "apiKey", label: "API key", placeholder: "sk-...", secret: true },
],
gemini: [{ key: "apiKey", label: "API key", placeholder: "AI...", secret: true }],
mistral: [{ key: "apiKey", label: "API key", placeholder: "...", secret: true }],
"vercel-ai-gateway": [{ key: "apiKey", label: "API key", placeholder: "...", secret: true }],
bedrock: [
{ key: "region", label: "AWS region", placeholder: "us-east-1" },
{ key: "profile", label: "AWS profile", placeholder: "default" },
],
openrouter: [
{ key: "apiKey", label: "API key", placeholder: "sk-or-...", secret: true },
{ key: "specificProvider", label: "Specific provider", placeholder: "Optional routing provider" },
],
voyage: [{ key: "apiKey", label: "API key", placeholder: "pa-...", secret: true }],
}
function options(input: IndexingConfig, provider: Provider) {
const value = input[provider]
if (!value || typeof value !== "object") return {}
return value as Record<string, string | undefined>
}
function FieldCard(props: { label: string; description?: string; actions?: JSX.Element; children: JSX.Element }) {
return (
<div class="ui-field agent-builder-field">
<div class="agent-builder-field-head">
<div>
<span>{props.label}</span>
<Show when={props.description}>{(description) => <small>{description()}</small>}</Show>
</div>
<Show when={props.actions}>{(actions) => <div class="agent-builder-field-actions">{actions()}</div>}</Show>
</div>
<div class="agent-builder-control">{props.children}</div>
</div>
)
}
function Toggle(props: {
label: string
description: string
checked: boolean
disabled?: boolean
source?: string
inherited?: boolean
overridden?: boolean
onChange: () => void
}) {
return (
<button
class="ui-toggle"
classList={{ selected: props.checked }}
type="button"
aria-pressed={props.checked}
disabled={props.disabled}
onClick={props.onChange}
>
<span>
<strong>{props.label}</strong>
<small>{props.description}</small>
</span>
<span class="indexing-toggle-tags">
<SourceBadge source={props.source} inherited={props.inherited} overridden={props.overridden} />
<Tag tone={props.checked ? "success" : "neutral"}>{props.checked ? "On" : "Off"}</Tag>
</span>
</button>
)
}
export function IndexingRoute() {
const ctx = useConfig()
const [draft, setDraft] = createSignal<IndexingConfig>({})
const [source, setSource] = createSignal("")
const [dirty, setDirty] = createSignal(false)
const scope = () => ctx.query()?.scope ?? "global"
const [selected, setSelected] = createSignal(scope())
const project = () => scope() === "project"
const global = createMemo(() => ctx.data()?.overlay.global.indexing ?? {})
const local = createMemo(() => {
const overlay = ctx.data()?.overlay
if (!overlay) return {}
return (project() ? overlay.project.indexing : overlay.global.indexing) ?? {}
})
const view = createMemo(() => (project() ? merge(global(), draft()) : draft()))
const provider = createMemo(() => view().provider)
const store = createMemo<Store>(() => view().vectorStore ?? "lancedb")
const [catalog] = createResource(ctx.query, loadEmbeddingModels)
const kiloModels = createMemo<SelectOption<string>[]>(() => {
const models = catalog()?.models ?? []
if (models.length === 0) return [{ value: "", label: "No Kilo embedding models available", disabled: true }]
return models.map((model) => ({
value: model.id,
label: `${model.name} (${model.note ? `${model.note}, ` : ""}${model.dimension}d)`,
}))
})
const kiloModel = createMemo(() => {
const data = catalog()
if (!data) return ""
const model = view().model ?? data.defaultModel
return data.aliases[model] ?? model
})
const errors = createMemo(() => validate(clean(draft())))
const overridden = createMemo(() => Object.keys(local()).length > 0)
createEffect(() => {
const current = scope()
const next = local()
const key = JSON.stringify(next)
if (!shouldSync(selected(), current, dirty(), source(), key)) return
setSelected(current)
setSource(key)
setDraft(clone(next))
setDirty(false)
})
function field(path: string) {
return ctx.data()?.overlay.fields[`indexing.${path}`]
}
function update(patch: IndexingConfig) {
setDraft((current) => merge(current, patch))
setDirty(true)
}
function text(key: keyof IndexingConfig, value: string) {
update({ [key]: value || undefined })
}
function number(key: keyof IndexingConfig, value: string) {
update({ [key]: value ? Number(value) : undefined })
}
function providerField(group: Provider, key: string, value: string) {
update({ [group]: { ...options(draft(), group), [key]: value || undefined } })
}
function selectProvider(value: ProviderValue) {
update(providerPatch(value, catalog()?.defaultModel))
}
function save() {
const next = clean(draft())
const unset = removed(local(), next)
ctx.patch({ indexing: next }, unset.length ? unset : undefined)
setDraft(next)
setSource(JSON.stringify(next))
setDirty(false)
}
function reset() {
ctx.unset([["indexing"]])
setDraft({})
setSource("{}")
setDirty(false)
}
return (
<ConfigPage
title="Code Indexing"
description={
project()
? "Configure semantic code search for this project. Unchanged values inherit from global settings."
: "Configure semantic code search defaults for every project."
}
actions={
<>
<Show when={overridden()}>
<Button variant="secondary" disabled={Boolean(ctx.saving())} onClick={reset}>
{project() ? "Use global settings" : "Clear settings"}
</Button>
</Show>
<Button variant="primary" disabled={Boolean(ctx.saving()) || !dirty() || errors().length > 0} onClick={save}>
Save
</Button>
</>
}
>
<div class="builder indexing-builder">
<section class="builder-form agent-builder-stack">
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Indexing</h2>
<p>Build and maintain an embedding index used by semantic search.</p>
</div>
<Tag>{project() ? "Project" : "Global"}</Tag>
</header>
<div class="ui-form agent-builder-form">
<Toggle
label="Enable indexing"
description={
project() && draft().enabled === undefined
? `Inherited from global settings (${global().enabled ? "on" : "off"}).`
: "Scan source files and keep their semantic index up to date."
}
checked={view().enabled ?? false}
disabled={Boolean(ctx.saving())}
source={field("enabled")?.source}
inherited={field("enabled")?.inherited}
overridden={field("enabled")?.overridden}
onChange={() => update({ enabled: !(view().enabled ?? false) })}
/>
</div>
</Card>
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Embeddings</h2>
<p>Select the provider and model used to turn code into searchable vectors.</p>
</div>
</header>
<div class="ui-form agent-builder-form">
<FieldCard
label="Provider"
description="Automatic uses Kilo when signed in, otherwise the provider runtime default."
actions={
<SourceBadge
source={field("provider")?.source}
inherited={field("provider")?.inherited}
overridden={field("provider")?.overridden}
/>
}
>
<CustomSelect
class="indexing-select"
label="Embedding provider"
value={provider() ?? ""}
options={providers}
disabled={Boolean(ctx.saving())}
onSelect={selectProvider}
/>
</FieldCard>
<FieldCard
label="Model"
description={
provider() === "kilo"
? "Select a Kilo-hosted embedding model."
: "Leave empty to use the provider's default embedding model."
}
actions={
<SourceBadge
source={field("model")?.source}
inherited={field("model")?.inherited}
overridden={field("model")?.overridden}
/>
}
>
<Show
when={provider() === "kilo"}
fallback={
<input
value={view().model ?? ""}
placeholder="Provider default"
disabled={Boolean(ctx.saving())}
onInput={(event) => text("model", event.currentTarget.value)}
/>
}
>
<CustomSelect
class="indexing-select"
label="Kilo embedding model"
value={kiloModel()}
options={kiloModels()}
disabled={Boolean(ctx.saving()) || !catalog()?.models.length}
onSelect={(value) => update({ model: value, dimension: undefined })}
/>
</Show>
</FieldCard>
<FieldCard
label="Vector dimension"
description="Leave empty to derive the dimension from known model metadata."
actions={
<SourceBadge
source={field("dimension")?.source}
inherited={field("dimension")?.inherited}
overridden={field("dimension")?.overridden}
/>
}
>
<input
type="number"
min="1"
step="1"
value={view().dimension ?? ""}
placeholder="Auto-detect"
disabled={Boolean(ctx.saving()) || provider() === "kilo"}
onInput={(event) => number("dimension", event.currentTarget.value)}
/>
</FieldCard>
<Show when={provider() === "kilo"}>
<div class="indexing-note">
Kilo embeddings use the account currently signed in to this Kilo server. Model dimensions are supplied
by the catalog.
</div>
</Show>
<Show when={provider()} keyed>
{(group) => (
<For each={fields[group]}>
{(item) => {
const meta = () => field(`${group}.${item.key}`)
return (
<FieldCard
label={item.label}
description="Provider-specific connection setting."
actions={
<SourceBadge
source={meta()?.source}
inherited={meta()?.inherited}
overridden={meta()?.overridden}
/>
}
>
<input
type={item.secret ? "password" : "text"}
value={options(view(), group)[item.key] ?? ""}
placeholder={item.placeholder}
spellcheck={false}
disabled={Boolean(ctx.saving())}
onInput={(event) => providerField(group, item.key, event.currentTarget.value)}
/>
</FieldCard>
)
}}
</For>
)}
</Show>
</div>
</Card>
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Vector Store</h2>
<p>Choose where indexed embeddings and metadata are stored.</p>
</div>
</header>
<div class="ui-form agent-builder-form">
<FieldCard
label="Backend"
description="LanceDB stores vectors locally by default. Qdrant connects to an external service."
actions={
<SourceBadge
source={field("vectorStore")?.source}
inherited={field("vectorStore")?.inherited}
overridden={field("vectorStore")?.overridden}
/>
}
>
<CustomSelect
class="indexing-select"
label="Vector store"
value={store()}
options={stores}
disabled={Boolean(ctx.saving())}
onSelect={(value) => update({ vectorStore: value })}
/>
</FieldCard>
<Show
when={store() === "qdrant"}
fallback={
<FieldCard
label="LanceDB directory"
description="Optional directory for local LanceDB storage."
actions={
<SourceBadge
source={field("lancedb.directory")?.source}
inherited={field("lancedb.directory")?.inherited}
overridden={field("lancedb.directory")?.overridden}
/>
}
>
<input
value={view().lancedb?.directory ?? ""}
placeholder="Default Kilo state directory"
disabled={Boolean(ctx.saving())}
onInput={(event) =>
update({ lancedb: { ...draft().lancedb, directory: event.currentTarget.value || undefined } })
}
/>
</FieldCard>
}
>
<FieldCard
label="Qdrant URL"
description="Server URL for the Qdrant instance."
actions={
<SourceBadge
source={field("qdrant.url")?.source}
inherited={field("qdrant.url")?.inherited}
overridden={field("qdrant.url")?.overridden}
/>
}
>
<input
value={view().qdrant?.url ?? ""}
placeholder="http://localhost:6333"
disabled={Boolean(ctx.saving())}
onInput={(event) =>
update({ qdrant: { ...draft().qdrant, url: event.currentTarget.value || undefined } })
}
/>
</FieldCard>
<FieldCard
label="Qdrant API key"
description="Optional API key for authenticated Qdrant instances."
actions={
<SourceBadge
source={field("qdrant.apiKey")?.source}
inherited={field("qdrant.apiKey")?.inherited}
overridden={field("qdrant.apiKey")?.overridden}
/>
}
>
<input
type="password"
value={view().qdrant?.apiKey ?? ""}
placeholder="Optional API key"
disabled={Boolean(ctx.saving())}
onInput={(event) =>
update({ qdrant: { ...draft().qdrant, apiKey: event.currentTarget.value || undefined } })
}
/>
</FieldCard>
</Show>
</div>
</Card>
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Search and Scanning</h2>
<p>Tune result filtering, batching, and retries.</p>
</div>
</header>
<div class="ui-form agent-builder-form">
<FieldCard
label="Minimum search score"
description="Similarity threshold from 0 to 1. Default is model-specific or 0.4."
>
<input
type="number"
min="0"
max="1"
step="0.05"
value={view().searchMinScore ?? ""}
placeholder="0.4"
disabled={Boolean(ctx.saving())}
onInput={(event) => number("searchMinScore", event.currentTarget.value)}
/>
</FieldCard>
<FieldCard
label="Maximum search results"
description="Maximum number of semantic matches returned per search."
>
<input
type="number"
min="1"
step="1"
value={view().searchMaxResults ?? ""}
placeholder="50"
disabled={Boolean(ctx.saving())}
onInput={(event) => number("searchMaxResults", event.currentTarget.value)}
/>
</FieldCard>
<FieldCard
label="Embedding batch size"
description="Number of code segments sent in each embedding batch."
>
<input
type="number"
min="1"
step="1"
value={view().embeddingBatchSize ?? ""}
placeholder="60"
disabled={Boolean(ctx.saving())}
onInput={(event) => number("embeddingBatchSize", event.currentTarget.value)}
/>
</FieldCard>
<FieldCard label="Scanner retry limit" description="Maximum retry attempts for a failed embedding batch.">
<input
type="number"
min="1"
step="1"
value={view().scannerMaxBatchRetries ?? ""}
placeholder="3"
disabled={Boolean(ctx.saving())}
onInput={(event) => number("scannerMaxBatchRetries", event.currentTarget.value)}
/>
</FieldCard>
</div>
<Show when={errors().length > 0}>
<footer class="indexing-errors">
<For each={errors()}>{(error) => <span>{error}</span>}</For>
</footer>
</Show>
</Card>
</section>
</div>
</ConfigPage>
)
}
@@ -2,9 +2,8 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { useKeybindSettings } from "./state/keybinds"
export function KeybindsRoute() {
@@ -2,12 +2,11 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { StatusTag } from "@kilocode/kilo-web-ui/status-tag"
import { ConfirmDialog } from "../../components/ConfirmDialog"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { useMcpSettings } from "./state/mcp"
type StatusFilter = "all" | "installed" | "notInstalled"
@@ -1,11 +1,10 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import type { Model } from "@kilocode/sdk/v2/client"
import { SearchField } from "../../components/SearchField"
import { text } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { type Capability, type ModelField, useModelSettings } from "./state/models"
function money(n: number) {
@@ -2,9 +2,8 @@ import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
import { actions, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions"
export function tone(action: PermissionAction) {
@@ -3,12 +3,11 @@ import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { ProviderIcon } from "@kilocode/kilo-web-ui/provider-icon"
import { CountTag } from "@kilocode/kilo-web-ui/tag"
import { StatusTag } from "@kilocode/kilo-web-ui/status-tag"
import { ConfirmDialog } from "../../components/ConfirmDialog"
import { CustomSelect } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage, SourceBadge } from "./ConfigPage"
import { useProviderSettings } from "./state/providers"
export function ProvidersRoute() {
@@ -1,9 +1,8 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { For, Show, createMemo, createSignal } from "solid-js"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigToolbar } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag, ConfigToolbar } from "./ConfigPage"
type Server = {
id: string
@@ -1,7 +1,6 @@
import { For, Show } from "solid-js"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigToolbar } from "./ConfigPage"
import { ConfigPage, ConfigTag as Tag, ConfigToolbar } from "./ConfigPage"
export function SourcesRoute() {
const ctx = useConfig()
@@ -1,10 +1,9 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console"
import { CountTag } from "@kilocode/kilo-web-ui/tag"
import { SearchField } from "../../components/SearchField"
import { useConfig } from "../../context/config"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage } from "./ConfigPage"
import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage"
export function ToolsRoute() {
const ctx = useConfig()
@@ -4,6 +4,7 @@ import { AgentBuilderRoute, AgentsRoute } from "./AgentsRoute"
import { CliNotificationsRoute } from "./CliNotificationsRoute"
import { CliUiRoute } from "./CliUiRoute"
import { FormattersRoute, LspRoute } from "./FormattersRoute"
import { IndexingRoute } from "./IndexingRoute"
import { KeybindsRoute } from "./KeybindsRoute"
import { McpRoute } from "./McpRoute"
import { ModelsAvailableRoute, ModelsDefaultRoute, ModelsRoute } from "./ModelsRoute"
@@ -92,7 +93,21 @@ export const configNav: ConfigNode[] = [
{
id: "behaviour",
label: "Behaviour",
items: [agents, tools, permissions, mcp, formatters, lsp],
items: [
agents,
tools,
permissions,
mcp,
formatters,
lsp,
{
path: "/indexing",
href: "/settings/indexing",
icon: "circuit-board",
label: "Code Indexing",
component: IndexingRoute,
},
],
},
{
id: "cli",
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import { clean, merge, providerPatch, removed, shouldSync, validate } from "./indexing"
describe("indexing config state", () => {
test("merges project settings over nested global settings", () => {
expect(
merge(
{ enabled: true, provider: "openai", openai: { apiKey: "global" }, qdrant: { url: "http://global" } },
{ enabled: false, provider: "ollama", qdrant: { apiKey: "project" } },
),
).toEqual({
enabled: false,
provider: "ollama",
openai: { apiKey: "global" },
qdrant: { url: "http://global", apiKey: "project" },
})
})
test("cleans empty fields and returns unset paths", () => {
const before = {
provider: "openai" as const,
model: "text-embedding-3-small",
openai: { apiKey: "secret" },
}
const after = clean({ provider: "openai", model: "", openai: { apiKey: "" } })
expect(after).toEqual({ provider: "openai" })
expect(removed(before, after)).toEqual([
["indexing", "model"],
["indexing", "openai"],
])
})
test("resyncs dirty drafts when the scope changes", () => {
expect(shouldSync("global", "global", true, "global", "updated")).toBe(false)
expect(shouldSync("global", "project", true, "global", "project")).toBe(true)
expect(shouldSync("global", "project", true, "shared", "shared")).toBe(true)
})
test("builds provider patches for custom selector changes", () => {
expect(providerPatch("kilo", "default-embedding")).toEqual({
provider: "kilo",
model: "default-embedding",
dimension: undefined,
})
expect(providerPatch("ollama", "ignored")).toEqual({
provider: "ollama",
model: undefined,
dimension: undefined,
})
expect(providerPatch("")).toEqual({ provider: undefined, model: undefined, dimension: undefined })
})
test("validates numeric settings", () => {
expect(validate({ dimension: 0, searchMinScore: 2, searchMaxResults: 0, embeddingBatchSize: 1.5 })).toEqual([
"Vector dimension must be a positive integer.",
"Search minimum score must be between 0 and 1.",
"Search maximum results must be a positive integer.",
"Embedding batch size must be a positive integer.",
])
})
})
@@ -0,0 +1,84 @@
import type { IndexingConfig } from "@kilocode/sdk/v2/client"
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}
export function clone(input: IndexingConfig | undefined): IndexingConfig {
return structuredClone(input ?? {})
}
export function shouldSync(selected: string, current: string, dirty: boolean, source: string, next: string) {
return selected !== current || (!dirty && source !== next)
}
export function merge(base: IndexingConfig | undefined, patch: IndexingConfig | undefined): IndexingConfig {
const result: Record<string, unknown> = { ...(base ?? {}) }
for (const [key, value] of Object.entries(patch ?? {})) {
if (record(value) && record(result[key])) {
result[key] = { ...result[key], ...value }
continue
}
result[key] = value
}
return result as IndexingConfig
}
function prune(input: unknown): unknown {
if (typeof input === "string") return input.trim() || undefined
if (!record(input)) return input ?? undefined
const entries = Object.entries(input).flatMap(([key, value]) => {
const next = prune(value)
return next === undefined ? [] : [[key, next] as const]
})
if (entries.length === 0) return undefined
return Object.fromEntries(entries)
}
export function clean(input: IndexingConfig): IndexingConfig {
return (prune(input) ?? {}) as IndexingConfig
}
export function providerPatch(provider: IndexingConfig["provider"] | "", model?: string): IndexingConfig {
return {
provider: provider || undefined,
model: provider === "kilo" ? model || undefined : undefined,
dimension: undefined,
}
}
function paths(before: unknown, after: unknown, prefix: string[]): string[][] {
if (!record(before)) return []
const next = record(after) ? after : {}
return Object.entries(before).flatMap(([key, value]) => {
const path = [...prefix, key]
if (!(key in next)) return [path]
if (record(value) && record(next[key])) return paths(value, next[key], path)
return []
})
}
export function removed(before: IndexingConfig, after: IndexingConfig): string[][] {
return paths(before, after, ["indexing"])
}
export function validate(input: IndexingConfig): string[] {
const errors: string[] = []
if (input.dimension !== undefined && input.dimension !== null) {
if (!Number.isInteger(input.dimension) || input.dimension <= 0)
errors.push("Vector dimension must be a positive integer.")
}
if (input.searchMinScore !== undefined && (input.searchMinScore < 0 || input.searchMinScore > 1)) {
errors.push("Search minimum score must be between 0 and 1.")
}
const integers = [
["Search maximum results", input.searchMaxResults],
["Embedding batch size", input.embeddingBatchSize],
["Scanner maximum retries", input.scannerMaxBatchRetries],
] as const
for (const [label, value] of integers) {
if (value !== undefined && (!Number.isInteger(value) || value <= 0))
errors.push(`${label} must be a positive integer.`)
}
return errors
}
+1
View File
@@ -10,6 +10,7 @@
@import "./styles/formatters.css";
@import "./styles/keybinds.css";
@import "./styles/models.css";
@import "./styles/indexing.css";
@import "./styles/agents-tools.css";
@import "./styles/projects.css";
@import "./styles/project-console.css";
@@ -180,8 +180,8 @@
min-width: 0;
}
.kilo-console .agent-builder-field-head span,
.kilo-console .agent-builder-field > span {
.kilo-console .agent-builder-field-head span:not([data-component="tag"]),
.kilo-console .agent-builder-field > span:not([data-component="tag"]) {
color: var(--foreground);
font-size: 0.75rem;
font-weight: 500;
@@ -0,0 +1,50 @@
.kilo-console .indexing-builder .agent-builder-card:has(.indexing-select[open]) {
position: relative;
z-index: 40;
overflow: visible;
}
.kilo-console .indexing-select[open] {
z-index: 41;
}
.kilo-console .indexing-toggle-tags {
display: inline-flex;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
}
.kilo-console .indexing-field-title {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.kilo-console .indexing-field-title > :first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .indexing-note {
grid-column: 1 / -1;
border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--border));
border-radius: var(--radius-md);
background: color-mix(in oklab, var(--primary) 8%, transparent);
color: var(--muted-foreground);
font-size: 0.6875rem;
line-height: 1.5;
padding: 0.625rem;
}
.kilo-console .indexing-errors {
display: grid;
gap: 0.25rem;
border-top: 1px solid var(--border);
color: var(--destructive);
font-size: 0.6875rem;
padding: 0.75rem;
}
@@ -15,6 +15,12 @@ export type KiloEmbeddingModelCatalog = {
aliases: Record<string, string>
}
export type KiloEmbeddingModelCatalogIssue = {
code: "http" | "invalid-response" | "network"
message: string
status?: number
}
export const EMPTY_KILO_EMBEDDING_MODEL_CATALOG: KiloEmbeddingModelCatalog = {
defaultModel: "",
models: [],
@@ -39,25 +45,66 @@ type Options = {
baseURL?: string
token?: string
signal?: AbortSignal
attempts?: number
onError?: (issue: KiloEmbeddingModelCatalogIssue) => void
}
const retryable = (status: number) => status === 408 || status === 425 || status === 429 || status >= 500
function wait(ms: number, signal?: AbortSignal) {
if (signal?.aborted) return Promise.reject(signal.reason)
return new Promise<void>((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
reject(signal?.reason)
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort)
resolve()
}, ms)
signal?.addEventListener("abort", abort, { once: true })
})
}
export async function fetchKiloEmbeddingModelCatalog(options: Options = {}): Promise<KiloEmbeddingModelCatalog> {
const url = new URL("embedding-models", resolveKiloGatewayBaseUrl({ baseURL: options.baseURL, token: options.token }))
const requested = options.attempts ?? 3
const attempts = Number.isFinite(requested) ? Math.min(3, Math.max(1, Math.floor(requested))) : 3
const issue = { current: undefined as KiloEmbeddingModelCatalogIssue | undefined }
try {
const response = await fetch(url, { signal: options.signal })
if (!response.ok) {
console.warn(`[Kilo Gateway] Failed to fetch embedding model catalog: ${response.status}`)
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
for (const attempt of Array.from({ length: attempts }, (_, index) => index)) {
if (options.signal?.aborted) throw options.signal.reason
try {
const response = await fetch(url, { signal: options.signal, redirect: "error" })
if (!response.ok) {
issue.current = {
code: "http",
message: `Unable to load Kilo embedding models (HTTP ${response.status}).`,
status: response.status,
}
if (!retryable(response.status) || attempt === attempts - 1) break
await wait(200 * 2 ** attempt, options.signal)
continue
}
const body = await response.json().catch(() => undefined)
const parsed = catalog.safeParse(body)
if (parsed.success) return parsed.data
issue.current = {
code: "invalid-response",
message: "Kilo returned an invalid embedding model catalog.",
}
break
} catch (err) {
if (options.signal?.aborted) throw options.signal.reason
issue.current = {
code: "network",
message: "Unable to connect to Kilo to load embedding models. Check your network connection and try again.",
}
if (attempt === attempts - 1) break
await wait(200 * 2 ** attempt, options.signal)
}
const parsed = catalog.safeParse(await response.json())
if (!parsed.success) {
console.warn("[Kilo Gateway] Embedding model catalog response validation failed:", parsed.error.format())
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
}
return parsed.data
} catch (err) {
console.warn("[Kilo Gateway] Error fetching embedding model catalog:", err)
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
}
if (issue.current) options.onError?.(issue.current)
return EMPTY_KILO_EMBEDDING_MODEL_CATALOG
}
+1
View File
@@ -39,6 +39,7 @@ export {
fetchKiloEmbeddingModelCatalog,
type KiloEmbeddingModel,
type KiloEmbeddingModelCatalog,
type KiloEmbeddingModelCatalogIssue,
} from "./api/embedding-models.js"
export { resolveKiloGatewayBaseUrl, resolveKiloOpenRouterBaseUrl } from "./api/url.js"
export {
@@ -1,43 +1,81 @@
import { describe, expect, mock, test } from "bun:test"
import { describe, expect, mock, spyOn, test } from "bun:test"
import { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog } from "../../src/api/embedding-models"
const response = () =>
new Response(
JSON.stringify({
defaultModel: "provider/model",
models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }],
aliases: { model: "provider/model" },
}),
)
describe("fetchKiloEmbeddingModelCatalog", () => {
test("fetches catalog from Kilo Gateway", async () => {
const prev = global.fetch
const fn = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
defaultModel: "provider/model",
models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }],
aliases: { model: "provider/model" },
}),
),
),
) as unknown as typeof fetch
const fn = mock(() => Promise.resolve(response())) as unknown as typeof fetch
global.fetch = fn
try {
const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })
expect(catalog.defaultModel).toBe("provider/model")
expect((fn as unknown as { mock: { calls: Array<[URL]> } }).mock.calls[0]?.[0].toString()).toBe(
"https://example.test/api/gateway/embedding-models",
)
const call = (fn as unknown as { mock: { calls: Array<[URL, RequestInit]> } }).mock.calls[0]
expect(call?.[0].toString()).toBe("https://example.test/api/gateway/embedding-models")
expect(call?.[1].redirect).toBe("error")
} finally {
global.fetch = prev
}
})
test("falls back when the request fails", async () => {
test("retries transient transport failures", async () => {
const prev = global.fetch
const fn = mock(() => Promise.reject(new TypeError("fetch failed")))
fn.mockImplementationOnce(() => Promise.reject(new TypeError("fetch failed")))
fn.mockImplementationOnce(() => Promise.resolve(response()))
global.fetch = fn as unknown as typeof fetch
try {
const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })
expect(catalog.models).toHaveLength(1)
expect(fn).toHaveBeenCalledTimes(2)
} finally {
global.fetch = prev
}
})
test("bounds caller-controlled retry attempts", async () => {
const prev = global.fetch
const fn = mock(() => Promise.resolve(new Response("nope", { status: 500 })))
global.fetch = fn as unknown as typeof fetch
try {
await fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: Number.POSITIVE_INFINITY })
expect(fn).toHaveBeenCalledTimes(3)
} finally {
global.fetch = prev
}
})
test("reports a final failure without writing to the console", async () => {
const prev = global.fetch
const warn = spyOn(console, "warn").mockImplementation(() => undefined)
const issue = mock(() => undefined)
global.fetch = mock(() => Promise.resolve(new Response("nope", { status: 500 }))) as unknown as typeof fetch
try {
await expect(fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test" })).resolves.toEqual(
EMPTY_KILO_EMBEDDING_MODEL_CATALOG,
)
await expect(
fetchKiloEmbeddingModelCatalog({ baseURL: "https://example.test", attempts: 1, onError: issue }),
).resolves.toEqual(EMPTY_KILO_EMBEDDING_MODEL_CATALOG)
expect(issue).toHaveBeenCalledWith({
code: "http",
message: "Unable to load Kilo embedding models (HTTP 500).",
status: 500,
})
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
global.fetch = prev
}
})
+52 -15
View File
@@ -993,7 +993,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
break
case "updateConfig":
await this.handleUpdateConfig(message.config, message.projectConfig)
await this.handleUpdateConfig(
message.config,
message.projectConfig,
message.globalUnset,
message.projectUnset,
)
break
case "openSettingsTab":
if (message.tab === "indexing") {
@@ -2062,16 +2067,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data: config } = await retry(() =>
this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }),
)
const { data: global } = await this.client.global.config.get({ throwOnError: true })
const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
const message = {
type: "configLoaded",
config,
globalConfig: global,
projectConfig: overlay?.project,
features: configFeatures(config),
}
this.cachedConfigMessage = message
@@ -2156,16 +2163,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (!this.client || this.connectionState !== "connected") return
try {
const dir = this.getWorkspaceDirectory()
const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
const { data: global } = await this.client.global.config.get({ throwOnError: true })
const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
this.cachedConfigMessage = {
type: "configLoaded",
config,
globalConfig: global,
projectConfig: overlay?.project,
features: configFeatures(config),
}
this.postMessage({ type: "configUpdated", config, globalConfig: global, features: configFeatures(config) })
this.postMessage({
type: "configUpdated",
config,
globalConfig: global,
projectConfig: overlay?.project,
features: configFeatures(config),
})
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error)
}
@@ -2321,7 +2338,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return getBusySessionCount(this.sessionStatusMap)
}
private async handleUpdateConfig(partial: Partial<Config>, project: Partial<Config> = {}): Promise<void> {
private async handleUpdateConfig(
partial: Partial<Config>,
project: Partial<Config> = {},
globalUnset: string[][] = [],
projectUnset: string[][] = [],
): Promise<void> {
if (!this.client || this.connectionState !== "connected") {
this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" })
return
@@ -2336,16 +2358,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
partial.agent !== undefined ||
project.default_agent !== undefined ||
project.agent !== undefined
const hasGlobal = Object.keys(partial).length > 0
const hasProject = Object.keys(project).length > 0
const hasGlobal = Object.keys(partial).length > 0 || globalUnset.length > 0
const hasProject = Object.keys(project).length > 0 || projectUnset.length > 0
this.pending++
const dir = this.getWorkspaceDirectory()
try {
await this.connectionService.drainPendingPrompts()
if (hasGlobal) await this.client.global.config.update({ config: partial }, { throwOnError: true })
if (hasProject) await this.client.config.update({ config: project, directory: dir }, { throwOnError: true })
if (hasGlobal) {
await this.client.config.overlayUpdate(
{ scope: "global", set: partial, unset: globalUnset, directory: dir },
{ throwOnError: true },
)
}
if (hasProject) {
await this.client.config.overlayUpdate(
{ scope: "project", set: project, unset: projectUnset, directory: dir },
{ throwOnError: true },
)
}
} catch (error) {
this.postConfigFailure(error)
this.pending--
@@ -2353,19 +2385,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
try {
const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
const { data: global } = await this.client.global.config.get({ throwOnError: true })
const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
this.cachedConfigMessage = {
type: "configLoaded",
config: merged,
globalConfig: global,
projectConfig: overlay?.project,
features: configFeatures(merged),
}
this.postMessage({
type: "configUpdated",
config: merged,
globalConfig: global,
projectConfig: overlay?.project,
features: configFeatures(merged),
})
await Promise.all([
@@ -718,5 +718,5 @@ export function isEventFromForeignProject(event: StreamEvent, expectedProjectID:
}
if (event.name !== "session.updated.1") return false
const project = event.data.info.projectID
return project != null && project !== expectedProjectID
return project !== undefined && project !== expectedProjectID
}
@@ -12,6 +12,7 @@ const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
const STORY_ID = "settings--indexing-provider-blur-race"
const KILO_STORY_ID = "settings--indexing-kilo-model-preset"
const KILO_LOADING_STORY_ID = "settings--indexing-kilo-catalog-loading"
const SCOPE_STORY_ID = "settings--indexing-scope-switch"
type Saved = {
provider?: string
@@ -19,6 +20,7 @@ type Saved = {
dimension?: number | null
openai?: { apiKey?: string }
gemini?: { apiKey?: string }
qdrant?: { url?: string; apiKey?: string }
}
function storyUrl(id = STORY_ID) {
@@ -76,6 +78,41 @@ test("provider switch writes to selected provider bucket", async ({ page }) => {
await expect(model).toHaveAttribute("placeholder", "Enter model ID")
})
test("scope switching preserves raw overrides and commits blur to the original scope", async ({ page }) => {
await page.setViewportSize({ width: 420, height: 720 })
await page.goto(storyUrl(SCOPE_STORY_ID), { waitUntil: "load" })
await disableAnimations(page)
await page.waitForSelector("#storybook-root *", { state: "attached" })
await expect(page.locator('[data-slot="settings-row"] [data-component="tag"]')).toHaveCount(0)
const url = field(page, "Qdrant URL").first()
await url.fill("http://edited-global:6333")
await page.getByRole("button", { name: "Local", exact: true }).click()
const global = page.getByTestId("indexing-global-save")
await expect
.poll(async () => {
const cfg = JSON.parse(((await global.textContent()) ?? "{}").trim()) as Saved
return cfg.qdrant?.url
})
.toBe("http://edited-global:6333")
const project = JSON.parse(((await page.getByTestId("indexing-project-save").textContent()) ?? "{}").trim()) as Saved
expect(project.qdrant?.url).toBeUndefined()
await expect(field(page, "Embedding model").first()).toHaveValue("")
await expect(url).toHaveValue("http://edited-global:6333")
const urlRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant URL" })
const keyRow = page.locator('[data-slot="settings-row"]', { hasText: "Qdrant API key" })
const modelRow = page.locator('[data-slot="settings-row"]', { hasText: "Embedding model" })
const tuningRow = page.locator('[data-slot="settings-row"]', { hasText: "Search max results" })
await expect(urlRow.locator('[data-component="tag"]')).toHaveText("Global")
await expect(keyRow.locator('[data-component="tag"]')).toHaveText("Local")
await expect(modelRow.locator('[data-component="tag"]')).toHaveText("Local")
await expect(tuningRow.locator('[data-component="tag"]')).toHaveText("Default")
})
test("Kilo exposes only supported embedding model presets", async ({ page }) => {
await page.setViewportSize({ width: 420, height: 720 })
await page.goto(storyUrl(KILO_STORY_ID), { waitUntil: "load" })
@@ -110,11 +147,18 @@ test("enabling Kilo before its catalog loads does not store an empty model", asy
await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" })
await disableAnimations(page)
await page.waitForSelector("#storybook-root *", { state: "attached" })
await page.locator('[data-component="switch"] [data-slot="switch-control"]').nth(1).click()
await page.getByRole("button", { name: "Local", exact: true }).click()
await page
.locator('[data-slot="settings-row"]', { hasText: "Enable for this project" })
.locator('[data-slot="switch-control"]')
.click()
await verify()
await page.goto(storyUrl(KILO_LOADING_STORY_ID), { waitUntil: "load" })
await page.waitForSelector("#storybook-root *", { state: "attached" })
await page.locator('[data-component="switch"] [data-slot="switch-control"]').first().click()
await page
.locator('[data-slot="settings-row"]', { hasText: "Enable globally" })
.locator('[data-slot="switch-control"]')
.click()
await verify()
})
@@ -1,5 +1,12 @@
import { describe, it, expect } from "bun:test"
import { deepMerge, stripNulls, ConfigState } from "../../webview-ui/src/utils/config-utils"
import {
configUnsetPaths,
ConfigState,
deepMerge,
mergeScopedConfig,
pruneConfigSet,
stripNulls,
} from "../../webview-ui/src/utils/config-utils"
import type { Config } from "../../webview-ui/src/types/messages"
// ---------------------------------------------------------------------------
@@ -42,6 +49,38 @@ describe("deepMerge", () => {
})
})
describe("scoped config normalization", () => {
it("preserves indexing null overrides while stripping unrelated nulls", () => {
const target = { username: "alice", indexing: { model: "global", dimension: 1024 } } as Config
const source = { username: null, indexing: { model: null, dimension: null } } as unknown as Partial<Config>
expect(mergeScopedConfig(target, source)).toEqual({ indexing: { model: null, dimension: null } })
})
it("builds clean set and unset payloads while preserving indexing null overrides", () => {
const patch = {
formatter: {},
username: null,
indexing: {
model: null,
dimension: null,
searchMinScore: undefined,
qdrant: { apiKey: undefined },
},
}
expect(pruneConfigSet(patch)).toEqual({
formatter: {},
indexing: { model: null, dimension: null },
})
expect(configUnsetPaths(patch)).toEqual([
["username"],
["indexing", "searchMinScore"],
["indexing", "qdrant", "apiKey"],
])
})
})
describe("stripNulls", () => {
it("removes null values", () => {
const cfg = { snapshot: true, username: null } as unknown as Config
@@ -0,0 +1,129 @@
import { describe, expect, it } from "bun:test"
import {
indexingConfig,
indexingDescription,
indexingEnabled,
indexingEnabledInherited,
indexingInheritance,
indexingSource,
indexingUpdate,
} from "../../webview-ui/src/components/settings/indexing-tab-state"
describe("indexing tab scope state", () => {
it("uses the global value when project enablement is inherited", () => {
expect(indexingEnabled("project", { enabled: true }, {})).toBe(true)
expect(indexingEnabled("project", { enabled: false }, {})).toBe(false)
expect(indexingEnabledInherited("project", { enabled: true }, {})).toBe(true)
expect(indexingEnabledInherited("project", { enabled: false }, {})).toBe(true)
})
it("uses explicit project overrides", () => {
expect(indexingEnabled("project", { enabled: true }, { enabled: false })).toBe(false)
expect(indexingEnabled("project", { enabled: false }, { enabled: true })).toBe(true)
expect(indexingEnabledInherited("project", { enabled: true }, { enabled: false })).toBe(false)
})
it("ignores project values in global scope", () => {
const global = { enabled: false, provider: "openai" as const, openai: { apiKey: "global" } }
const project = { enabled: true, provider: "ollama" as const, ollama: { baseUrl: "http://project" } }
expect(indexingEnabled("global", global, project)).toBe(false)
expect(indexingEnabledInherited("global", global, {})).toBe(false)
expect(indexingConfig("global", global, project)).toEqual(global)
})
it("keeps inherited values out of project updates", () => {
expect(
indexingUpdate(
"project",
{ enabled: true, provider: "openai", openai: { apiKey: "global" } },
{ qdrant: { url: "http://project" } },
{ enabled: false },
),
).toEqual({ enabled: false, qdrant: { url: "http://project" } })
})
it("preserves explicit null overrides and recursively inherits undefined leaves", () => {
expect(
indexingConfig(
"project",
{
model: "global-model",
dimension: 1024,
qdrant: { url: "http://global", apiKey: "global-secret" },
},
{
model: null,
dimension: null,
qdrant: { url: "http://project", apiKey: undefined },
},
),
).toEqual({
model: null,
dimension: null,
qdrant: { url: "http://project", apiKey: "global-secret" },
})
})
it("classifies inherited and partially inherited fields", () => {
const global = {
provider: "openai-compatible" as const,
model: "global-model",
dimension: 1024,
"openai-compatible": { baseUrl: "https://global.test", apiKey: "secret" },
}
const project = {
model: null,
"openai-compatible": { baseUrl: "https://project.test" },
}
expect(indexingInheritance("project", global, project, [["provider"]])).toBe("inherited")
expect(indexingInheritance("project", global, project, [["model"]])).toBe("none")
expect(indexingInheritance("project", global, project, [["dimension"]])).toBe("inherited")
expect(
indexingInheritance("project", global, project, [
["openai-compatible", "baseUrl"],
["openai-compatible", "apiKey"],
]),
).toBe("partial")
expect(indexingInheritance("global", global, project, [["provider"]])).toBe("none")
expect(indexingInheritance("project", {}, {}, [["vectorStore"]])).toBe("none")
expect(indexingSource("project", global, project, [["provider"]])).toBe("global")
expect(indexingSource("project", global, project, [["model"]])).toBe("local")
expect(
indexingSource("project", global, project, [
["openai-compatible", "baseUrl"],
["openai-compatible", "apiKey"],
]),
).toBe("mixed")
expect(indexingSource("project", {}, {}, [["vectorStore"]])).toBe("default")
expect(indexingSource("global", global, project, [["provider"]])).toBe("none")
expect(indexingDescription("Configure this value.", "inherited")).toBe(
"Configure this value. Inherited from global config.",
)
})
it("merges inherited values with project overrides", () => {
expect(
indexingConfig(
"project",
{
enabled: true,
provider: "openai",
model: "global-model",
vectorStore: "qdrant",
openai: { apiKey: "global" },
qdrant: { url: "http://global", apiKey: "global-secret" },
},
{ provider: "ollama", qdrant: { url: "http://project" } },
),
).toEqual({
enabled: true,
provider: "ollama",
model: "global-model",
vectorStore: "qdrant",
openai: { apiKey: "global" },
qdrant: { url: "http://project", apiKey: "global-secret" },
})
})
})
@@ -10,7 +10,12 @@ type Internals = {
cachedIndexingStatusMessage: unknown
handleEvent: (event: unknown, directory?: string) => void
reloadAfterAuthChange: () => Promise<void>
handleUpdateConfig: (partial: Partial<Config>) => Promise<void>
handleUpdateConfig: (
partial: Partial<Config>,
project?: Partial<Config>,
globalUnset?: string[][],
projectUnset?: string[][],
) => Promise<void>
fetchAndSendConfig: () => Promise<void>
fetchAndSendProviders: () => Promise<void>
fetchAndSendAgents: () => Promise<void>
@@ -22,6 +27,7 @@ type Internals = {
function createConnection() {
let drains = 0
const patches: unknown[] = []
const client = {
global: {
config: {
@@ -32,11 +38,17 @@ function createConnection() {
config: {
get: async () => ({ data: {} }),
update: async () => ({ data: {} }),
overlay: async () => ({ data: { project: {} } }),
overlayUpdate: async (patch: unknown) => {
patches.push(patch)
return { data: {} }
},
},
}
return {
drains: () => drains,
patches: () => patches,
service: {
drainPendingPrompts: async () => {
drains += 1
@@ -97,6 +109,33 @@ describe("KiloProvider indexing refresh", () => {
expect(indexing).toBe(0)
})
it("passes scoped unset paths to the config overlay endpoint", async () => {
const conn = createConnection()
const provider = new KiloProvider({} as never, conn.service as never)
const internal = provider as unknown as Internals
internal.connectionState = "connected"
await internal.handleUpdateConfig(
{ indexing: { qdrant: { apiKey: undefined } } },
{ indexing: { searchMinScore: undefined } },
[["indexing", "qdrant", "apiKey"]],
[["indexing", "searchMinScore"]],
)
expect(conn.patches()).toEqual([
expect.objectContaining({
scope: "global",
set: { indexing: { qdrant: { apiKey: undefined } } },
unset: [["indexing", "qdrant", "apiKey"]],
}),
expect.objectContaining({
scope: "project",
set: { indexing: { searchMinScore: undefined } },
unset: [["indexing", "searchMinScore"]],
}),
])
})
it("fetchAndSendIndexingStatus uses current session directory header", async () => {
const worktree = "/repo/.kilo/.kilocode/worktrees/feature"
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
@@ -1,11 +1,11 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Card } from "@kilocode/kilo-ui/card"
import { DEFAULT_VECTOR_STORE } from "@kilocode/kilo-indexing/config"
import { formatKiloEmbeddingModelLabel, getKiloEmbeddingModel } from "@kilocode/kilo-indexing/embedding-models"
import { Select } from "@kilocode/kilo-ui/select"
import { Switch } from "@kilocode/kilo-ui/switch"
import { TextField } from "@kilocode/kilo-ui/text-field"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useConfig } from "../../context/config"
import { formatIndexingLabel, useIndexing } from "../../context/indexing"
import { useKiloEmbeddingModels } from "../../context/kilo-embedding-models"
@@ -15,6 +15,17 @@ import { useServer } from "../../context/server"
import type { IndexingConfig, IndexingProvider as ProviderId } from "../../types/messages"
import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
import SettingsRow from "./SettingsRow"
import {
indexingConfig,
indexingDescription,
indexingEnabled,
indexingEnabledInherited,
indexingInheritance,
indexingSource,
indexingUpdate,
type IndexingScope,
type IndexingSource,
} from "./indexing-tab-state"
type Option = { value: string; label: string }
type TuningKey = "searchMinScore" | "searchMaxResults" | "embeddingBatchSize" | "scannerMaxBatchRetries"
@@ -44,6 +55,14 @@ const tuning: Array<{ key: TuningKey; label: string; placeholder: string }> = [
{ key: "scannerMaxBatchRetries", label: "Scanner Max Batch Retries", placeholder: "3" },
]
function sourceLabel(source: IndexingSource) {
if (source === "global") return "Global"
if (source === "local") return "Local"
if (source === "mixed") return "Global + Local"
if (source === "default") return "Default"
return ""
}
function providerFields(provider: ProviderId | undefined): Array<{ key: string; label: string; placeholder: string }> {
if (provider === "kilo") return []
if (provider === "openai") return [{ key: "apiKey", label: "API Key", placeholder: "sk-..." }]
@@ -74,7 +93,7 @@ function providerFields(provider: ProviderId | undefined): Array<{ key: string;
}
const IndexingTab: Component = () => {
const { config, globalConfig, updateConfig, updateGlobalConfig } = useConfig()
const { globalConfig, projectConfig, updateGlobalConfig, updateProjectConfig } = useConfig()
const indexing = useIndexing()
const embeds = useKiloEmbeddingModels()
const language = useLanguage()
@@ -83,13 +102,33 @@ const IndexingTab: Component = () => {
const [providerDrafts, setProviderDrafts] = createSignal<Record<string, string>>({})
const [storeDrafts, setStoreDrafts] = createSignal<Record<string, string>>({})
const [tuningDrafts, setTuningDrafts] = createSignal<Record<string, string>>({})
const [scope, setScope] = createSignal<IndexingScope>("global")
const cfg = createMemo<IndexingConfig>(() => config().indexing ?? {})
const globalCfg = createMemo<IndexingConfig>(() => globalConfig().indexing ?? {})
const globalOn = createMemo(() => globalCfg().enabled === true)
const projectCfg = createMemo<IndexingConfig>(() => projectConfig().indexing ?? {})
const raw = createMemo<IndexingConfig>(() => (scope() === "global" ? globalCfg() : projectCfg()))
const cfg = createMemo<IndexingConfig>(() => indexingConfig(scope(), globalCfg(), projectCfg()))
const enabled = createMemo(() => indexingEnabled(scope(), globalCfg(), projectCfg()))
const inherited = createMemo(() => indexingEnabledInherited(scope(), globalCfg(), projectCfg()))
const inheritance = (paths: readonly (readonly string[])[]) =>
indexingInheritance(scope(), globalCfg(), projectCfg(), paths)
const tag = (current: IndexingScope, paths: readonly (readonly string[])[]) =>
sourceLabel(indexingSource(current, globalCfg(), projectCfg(), paths)) || undefined
const description = (value: string, paths: readonly (readonly string[])[]) =>
indexingDescription(value, inheritance(paths))
const changeScope = (next: IndexingScope) => {
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()
setScope(next)
}
const updateIndexing = (partial: IndexingConfig) => {
updateConfig({ indexing: { ...cfg(), ...partial } })
const patch = { indexing: indexingUpdate(scope(), globalCfg(), projectCfg(), partial) }
if (scope() === "global") {
updateGlobalConfig(patch)
return
}
updateProjectConfig(patch)
}
const vectorStore = () => cfg().vectorStore ?? DEFAULT_VECTOR_STORE
@@ -138,21 +177,6 @@ const IndexingTab: Component = () => {
updateIndexing({ enabled })
}
const saveGlobalEnabled = (enabled: boolean) => {
if (enabled && !globalCfg().provider && !cfg().provider && kiloAvailable()) {
updateGlobalConfig({
indexing: {
enabled,
provider: "kilo",
model: knownKiloModel(cfg().model) ?? (kiloDefault() || null),
dimension: null,
},
})
return
}
updateGlobalConfig({ indexing: { enabled } })
}
const saveModel = (value: string) => {
if (selectedProvider() === "kilo") return
const trimmed = value.trim()
@@ -160,7 +184,7 @@ const IndexingTab: Component = () => {
}
const providerValue = (group: string, key: string) => {
const draftKey = `${group}.${key}`
const draftKey = `${scope()}.${group}.${key}`
const draft = providerDrafts()[draftKey]
if (draft !== undefined) return draft
const value = (cfg()[group as keyof IndexingConfig] as Record<string, string | undefined> | undefined)?.[key]
@@ -168,7 +192,7 @@ const IndexingTab: Component = () => {
}
const storeValue = (group: "qdrant" | "lancedb", key: string) => {
const draftKey = `${group}.${key}`
const draftKey = `${scope()}.${group}.${key}`
const draft = storeDrafts()[draftKey]
if (draft !== undefined) return draft
const value = (cfg()[group] as Record<string, string | undefined> | undefined)?.[key]
@@ -176,13 +200,17 @@ const IndexingTab: Component = () => {
}
const saveProviderField = (group: ProviderId, key: string, value: string) => {
const current = (cfg()[group] as Record<string, string | undefined> | undefined) ?? {}
const current = (raw()[group] as Record<string, string | undefined> | undefined) ?? {}
updateIndexing({ [group]: { ...current, [key]: value.trim() || undefined } })
const draftKey = `${scope()}.${group}.${key}`
setProviderDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey)))
}
const saveStoreField = (group: "qdrant" | "lancedb", key: string, value: string) => {
const current = (cfg()[group] as Record<string, string | undefined> | undefined) ?? {}
const current = (raw()[group] as Record<string, string | undefined> | undefined) ?? {}
updateIndexing({ [group]: { ...current, [key]: value.trim() || undefined } })
const draftKey = `${scope()}.${group}.${key}`
setStoreDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey)))
}
const saveNumber = (
@@ -193,6 +221,10 @@ const IndexingTab: Component = () => {
const trimmed = value.trim()
if (!trimmed) {
updateIndexing({ [key]: key === "dimension" ? null : undefined })
if (key !== "dimension") {
const draftKey = `${scope()}.${key}`
setTuningDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey)))
}
return
}
@@ -202,10 +234,14 @@ const IndexingTab: Component = () => {
if (options?.min !== undefined && num < options.min) return
if (options?.max !== undefined && num > options.max) return
updateIndexing({ [key]: num })
if (key !== "dimension") {
const draftKey = `${scope()}.${key}`
setTuningDrafts((prev) => Object.fromEntries(Object.entries(prev).filter(([entry]) => entry !== draftKey)))
}
}
const tuningValue = (key: TuningKey) => {
const draft = tuningDrafts()[key]
const draft = tuningDrafts()[`${scope()}.${key}`]
if (draft !== undefined) return draft
const value = cfg()[key]
return value === undefined ? "" : String(value)
@@ -220,34 +256,55 @@ const IndexingTab: Component = () => {
</span>
</SettingsRow>
<SettingsRow
title={language.t("settings.indexing.globalEnable.title")}
description={language.t("settings.indexing.globalEnable.description")}
title="Configuration scope"
description={
scope() === "global"
? language.t("settings.indexing.globalEnable.description")
: language.t("settings.indexing.projectEnable.description")
}
>
<Switch checked={globalCfg().enabled ?? false} onChange={saveGlobalEnabled} hideLabel>
{language.t("settings.indexing.globalEnable.title")}
</Switch>
<div style={{ display: "flex", gap: "8px" }}>
<Button
variant={scope() === "global" ? "primary" : "secondary"}
size="small"
onClick={() => changeScope("global")}
>
{language.t("settings.config.scope.global")}
</Button>
<Button
variant={scope() === "project" ? "primary" : "secondary"}
size="small"
onClick={() => changeScope("project")}
>
{language.t("settings.config.scope.local")}
</Button>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.indexing.projectEnable.title")}
description={language.t("settings.indexing.projectEnable.description")}
title={
scope() === "global"
? language.t("settings.indexing.globalEnable.title")
: language.t("settings.indexing.projectEnable.title")
}
description={
inherited()
? `Inherited from global config (${enabled() ? "on" : "off"}) until a project value is saved.`
: language.t("settings.indexing.enable.description")
}
tag={tag(scope(), [["enabled"]])}
last
>
<Tooltip
value={language.t("settings.indexing.projectEnable.disabledTooltip")}
placement="top"
inactive={!globalOn()}
>
<Switch checked={cfg().enabled === true} onChange={saveEnabled} disabled={globalOn()} hideLabel>
{language.t("settings.indexing.projectEnable.title")}
</Switch>
</Tooltip>
<Switch checked={enabled()} onChange={saveEnabled} hideLabel>
{language.t("settings.indexing.enable.title")}
</Switch>
</SettingsRow>
</Card>
<Card>
<SettingsRow
title={language.t("settings.indexing.provider.title")}
description={language.t("settings.indexing.provider.description")}
description={description(language.t("settings.indexing.provider.description"), [["provider"]])}
tag={tag(scope(), [["provider"]])}
>
<Select
options={providers()}
@@ -265,7 +322,8 @@ const IndexingTab: Component = () => {
<Show when={kiloModels().length > 0}>
<SettingsRow
title={language.t("settings.indexing.kiloModel.title")}
description={language.t("settings.indexing.kiloModel.description")}
description={description(language.t("settings.indexing.kiloModel.description"), [["model"]])}
tag={tag(scope(), [["model"]])}
>
<Select
options={kiloModels()}
@@ -284,14 +342,20 @@ const IndexingTab: Component = () => {
<Show when={selectedProvider() !== "kilo"}>
<SettingsRow
title={language.t("settings.indexing.model.title")}
description={language.t("settings.indexing.model.description")}
description={description(language.t("settings.indexing.model.description"), [["model"]])}
tag={tag(scope(), [["model"]])}
>
<TextField value={cfg().model ?? ""} placeholder="Enter model ID" onChange={saveModel} />
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.indexing.dimension.title")}
description={language.t("settings.indexing.dimension.description")}
description={
selectedProvider() === "kilo"
? language.t("settings.indexing.dimension.description")
: description(language.t("settings.indexing.dimension.description"), [["dimension"]])
}
tag={selectedProvider() === "kilo" ? undefined : tag(scope(), [["dimension"]])}
last={!selectedProvider() || (fields().length === 0 && !(selectedProvider() === "kilo" && !kiloAvailable()))}
>
<TextField
@@ -300,7 +364,10 @@ const IndexingTab: Component = () => {
? ""
: String(cfg().dimension)
}
placeholder={language.t("settings.indexing.dimension.placeholder")}
placeholder={
selectedProvider() === "kilo" ? "Provided by Kilo" : language.t("settings.indexing.dimension.placeholder")
}
disabled={selectedProvider() === "kilo"}
onChange={(value) => saveNumber("dimension", value, { integer: true, min: 1 })}
/>
</SettingsRow>
@@ -316,13 +383,16 @@ const IndexingTab: Component = () => {
<Show when={fields().length > 0 ? selectedProvider() : undefined} keyed>
{(group) => {
const fields = providerFields(group)
const label = allProviders.find((item) => item.value === group)?.label ?? group
const name = allProviders.find((item) => item.value === group)?.label ?? group
return (
<For each={fields}>
{(field, index) => (
<SettingsRow
title={`${label} ${field.label}`}
description={language.t("settings.indexing.providerField.description")}
title={`${name} ${field.label}`}
description={description(language.t("settings.indexing.providerField.description"), [
[group, field.key],
])}
tag={tag(scope(), [[group, field.key]])}
last={index() === fields.length - 1}
>
<TextField
@@ -331,7 +401,7 @@ const IndexingTab: Component = () => {
placeholder={field.placeholder}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement
setProviderDrafts((prev) => ({ ...prev, [`${group}.${field.key}`]: target.value }))
setProviderDrafts((prev) => ({ ...prev, [`${scope()}.${group}.${field.key}`]: target.value }))
}}
onBlur={(e: FocusEvent) => {
const target = e.currentTarget as HTMLInputElement
@@ -349,7 +419,8 @@ const IndexingTab: Component = () => {
<Card>
<SettingsRow
title={language.t("settings.indexing.vectorStore.title")}
description={language.t("settings.indexing.vectorStore.description")}
description={description(language.t("settings.indexing.vectorStore.description"), [["vectorStore"]])}
tag={tag(scope(), [["vectorStore"]])}
>
<Select
options={stores}
@@ -367,7 +438,10 @@ const IndexingTab: Component = () => {
fallback={
<SettingsRow
title={language.t("settings.indexing.lancedbDirectory.title")}
description={language.t("settings.indexing.lancedbDirectory.description")}
description={description(language.t("settings.indexing.lancedbDirectory.description"), [
["lancedb", "directory"],
])}
tag={tag(scope(), [["lancedb", "directory"]])}
last
>
<TextField
@@ -375,7 +449,7 @@ const IndexingTab: Component = () => {
placeholder={language.t("settings.indexing.lancedbDirectory.placeholder")}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement
setStoreDrafts((prev) => ({ ...prev, "lancedb.directory": target.value }))
setStoreDrafts((prev) => ({ ...prev, [`${scope()}.lancedb.directory`]: target.value }))
}}
onBlur={(e: FocusEvent) => {
const target = e.currentTarget as HTMLInputElement
@@ -388,14 +462,15 @@ const IndexingTab: Component = () => {
<>
<SettingsRow
title={language.t("settings.indexing.qdrantUrl.title")}
description={language.t("settings.indexing.qdrantUrl.description")}
description={description(language.t("settings.indexing.qdrantUrl.description"), [["qdrant", "url"]])}
tag={tag(scope(), [["qdrant", "url"]])}
>
<TextField
value={storeValue("qdrant", "url")}
placeholder="http://localhost:6333"
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement
setStoreDrafts((prev) => ({ ...prev, "qdrant.url": target.value }))
setStoreDrafts((prev) => ({ ...prev, [`${scope()}.qdrant.url`]: target.value }))
}}
onBlur={(e: FocusEvent) => {
const target = e.currentTarget as HTMLInputElement
@@ -405,7 +480,10 @@ const IndexingTab: Component = () => {
</SettingsRow>
<SettingsRow
title={language.t("settings.indexing.qdrantApiKey.title")}
description={language.t("settings.indexing.qdrantApiKey.description")}
description={description(language.t("settings.indexing.qdrantApiKey.description"), [
["qdrant", "apiKey"],
])}
tag={tag(scope(), [["qdrant", "apiKey"]])}
last
>
<TextField
@@ -414,7 +492,7 @@ const IndexingTab: Component = () => {
placeholder={language.t("settings.indexing.qdrantApiKey.placeholder")}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement
setStoreDrafts((prev) => ({ ...prev, "qdrant.apiKey": target.value }))
setStoreDrafts((prev) => ({ ...prev, [`${scope()}.qdrant.apiKey`]: target.value }))
}}
onBlur={(e: FocusEvent) => {
const target = e.currentTarget as HTMLInputElement
@@ -431,7 +509,8 @@ const IndexingTab: Component = () => {
{(item, index) => (
<SettingsRow
title={item.label}
description={language.t("settings.indexing.tuning.description")}
description={description(language.t("settings.indexing.tuning.description"), [[item.key]])}
tag={tag(scope(), [[item.key]])}
last={index() === tuning.length - 1}
>
<TextField
@@ -439,7 +518,7 @@ const IndexingTab: Component = () => {
placeholder={item.placeholder}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement
setTuningDrafts((prev) => ({ ...prev, [item.key]: target.value }))
setTuningDrafts((prev) => ({ ...prev, [`${scope()}.${item.key}`]: target.value }))
}}
onBlur={(e: FocusEvent) => {
const target = e.currentTarget as HTMLInputElement
@@ -1,8 +1,13 @@
import { Component, JSX } from "solid-js"
import { Tag } from "@kilocode/kilo-ui/tag"
import { Component, JSX, Show } from "solid-js"
const SettingsRow: Component<{ title: string; description?: string; last?: boolean; children: JSX.Element }> = (
props,
) => (
const SettingsRow: Component<{
title: string
description?: string
tag?: string
last?: boolean
children: JSX.Element
}> = (props) => (
<div
data-slot="settings-row"
style={{
@@ -15,9 +20,16 @@ const SettingsRow: Component<{ title: string; description?: string; last?: boole
<div data-slot="settings-row-label">
<div
data-slot="settings-row-label-title"
style={props.description === null || props.description === undefined ? { "margin-bottom": "0" } : {}}
style={{
display: "flex",
"align-items": "center",
gap: "6px",
"flex-wrap": "wrap",
...(props.description === null || props.description === undefined ? { "margin-bottom": "0" } : {}),
}}
>
{props.title}
<span>{props.title}</span>
<Show when={props.tag}>{(tag) => <Tag>{tag()}</Tag>}</Show>
</div>
{props.description !== null && props.description !== undefined && (
<div data-slot="settings-row-label-subtitle">{props.description}</div>
@@ -0,0 +1,93 @@
import type { IndexingConfig } from "@kilocode/kilo-indexing/config"
export type IndexingScope = "global" | "project"
export type IndexingInheritance = "none" | "inherited" | "partial"
export type IndexingSource = "none" | "global" | "local" | "mixed" | "default"
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}
function mergeEffective(base: Record<string, unknown>, patch: Record<string, unknown>) {
const result: Record<string, unknown> = { ...base }
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue
if (record(value) && record(result[key])) {
result[key] = mergeEffective(result[key], value)
continue
}
result[key] = value
}
return result
}
function mergeUpdate(base: Record<string, unknown>, patch: Record<string, unknown>) {
const result: Record<string, unknown> = { ...base }
for (const [key, value] of Object.entries(patch)) {
if (record(value) && record(result[key])) {
result[key] = mergeUpdate(result[key], value)
continue
}
result[key] = value
}
return result
}
function get(input: IndexingConfig, path: readonly string[]) {
return path.reduce<unknown>((value, key) => (record(value) ? value[key] : undefined), input)
}
export function indexingConfig(scope: IndexingScope, global: IndexingConfig, project: IndexingConfig) {
if (scope === "global") return global
return mergeEffective(global, project) as IndexingConfig
}
export function indexingUpdate(
scope: IndexingScope,
global: IndexingConfig,
project: IndexingConfig,
patch: IndexingConfig,
) {
return mergeUpdate(scope === "global" ? global : project, patch) as IndexingConfig
}
export function indexingSource(
scope: IndexingScope,
global: IndexingConfig,
project: IndexingConfig,
paths: readonly (readonly string[])[],
): IndexingSource {
if (scope !== "project") return "none"
const local = paths.filter((path) => get(project, path) !== undefined).length
const inherited = paths.filter((path) => get(project, path) === undefined && get(global, path) !== undefined).length
if (local > 0 && inherited > 0) return "mixed"
if (local > 0) return "local"
if (inherited > 0) return "global"
return "default"
}
export function indexingInheritance(
scope: IndexingScope,
global: IndexingConfig,
project: IndexingConfig,
paths: readonly (readonly string[])[],
): IndexingInheritance {
const source = indexingSource(scope, global, project, paths)
if (source === "global") return "inherited"
if (source === "mixed") return "partial"
return "none"
}
export function indexingDescription(description: string, inheritance: IndexingInheritance) {
if (inheritance === "inherited") return `${description} Inherited from global config.`
if (inheritance === "partial") return `${description} Some values are inherited from global config.`
return description
}
export function indexingEnabled(scope: IndexingScope, global: IndexingConfig, project: IndexingConfig) {
return indexingConfig(scope, global, project).enabled === true
}
export function indexingEnabledInherited(scope: IndexingScope, global: IndexingConfig, project: IndexingConfig) {
return indexingInheritance(scope, global, project, [["enabled"]]) === "inherited"
}
@@ -12,7 +12,14 @@ import { createContext, useContext, createSignal, createMemo, onCleanup } from "
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"
import {
configUnsetPaths,
deepMerge,
mergeScopedConfig,
pruneConfigSet,
stripNulls,
resolveConfig,
} from "../utils/config-utils"
import { splitConfigByScope } from "../utils/config-scope"
function has(value: Record<string, unknown>) {
@@ -27,6 +34,7 @@ export interface SaveError {
interface ConfigContextValue {
config: Accessor<Config>
globalConfig: Accessor<Config>
projectConfig: Accessor<Config>
settings: Accessor<Record<string, unknown>>
features: Accessor<FeatureFlags>
loading: Accessor<boolean>
@@ -35,6 +43,7 @@ interface ConfigContextValue {
saveError: Accessor<SaveError | null>
updateConfig: (partial: Partial<Config>) => void
updateGlobalConfig: (partial: Partial<Config>) => void
updateProjectConfig: (partial: Partial<Config>) => void
updateSetting: (key: string, value: unknown) => void
saveConfig: () => void
discardConfig: () => void
@@ -47,19 +56,25 @@ export const ConfigProvider: ParentComponent = (props) => {
const [config, setConfig] = createSignal<Config>({})
const [globalConfig, setGlobalConfig] = createSignal<Config>({})
const [projectConfig, setProjectConfig] = createSignal<Config>({})
const [settings, setSettings] = createSignal<Record<string, unknown>>({})
const [features, setFeatures] = createSignal<FeatureFlags>({ indexing: false })
const [loading, setLoading] = createSignal(true)
const [draft, setDraft] = createSignal<Partial<Config>>({})
const [globalDraft, setGlobalDraft] = createSignal<Partial<Config>>({})
const [projectDraft, setProjectDraft] = createSignal<Partial<Config>>({})
const [settingsDraft, setSettingsDraft] = createSignal<Record<string, unknown>>({})
const isDirty = createMemo(
() =>
has(draft() as Record<string, unknown>) || has(globalDraft() as Record<string, unknown>) || has(settingsDraft()),
has(draft() as Record<string, unknown>) ||
has(globalDraft() as Record<string, unknown>) ||
has(projectDraft() as Record<string, unknown>) ||
has(settingsDraft()),
)
// Last config received from the server — used to revert on discard
const [saved, setSaved] = createSignal<Config>({})
const [savedGlobal, setSavedGlobal] = createSignal<Config>({})
const [savedProject, setSavedProject] = createSignal<Config>({})
const [savedSettings, setSavedSettings] = createSignal<Record<string, unknown>>({})
// True while a saveConfig() write is in-flight — used to clear draft on success
// and to guard against stale configLoaded messages overwriting optimistic state.
@@ -91,15 +106,19 @@ export const ConfigProvider: ParentComponent = (props) => {
setFeatures(message.features)
setSaved(message.config)
if (message.globalConfig !== undefined) {
setGlobalConfig(stripNulls(deepMerge(message.globalConfig, globalDraft())))
setGlobalConfig(mergeScopedConfig(message.globalConfig, globalDraft()))
setSavedGlobal(message.globalConfig)
}
if (message.projectConfig !== undefined) {
setProjectConfig(mergeScopedConfig(message.projectConfig, projectDraft()))
setSavedProject(message.projectConfig)
}
setLoading(false)
return
}
if (message.type === "globalConfigLoaded") {
if (saving()) return
setGlobalConfig(stripNulls(deepMerge(message.config, globalDraft())))
setGlobalConfig(mergeScopedConfig(message.config, globalDraft()))
setSavedGlobal(message.config)
return
}
@@ -110,21 +129,30 @@ export const ConfigProvider: ParentComponent = (props) => {
setSaving(false)
setDraft({})
setGlobalDraft({})
setProjectDraft({})
setSaveError(null)
setConfig(message.config)
if (message.globalConfig !== undefined) {
setGlobalConfig(stripNulls(deepMerge(message.globalConfig, globalDraft())))
setGlobalConfig(mergeScopedConfig(message.globalConfig, globalDraft()))
setSavedGlobal(message.globalConfig)
}
if (message.projectConfig !== undefined) {
setProjectConfig(message.projectConfig)
setSavedProject(message.projectConfig)
}
setFeatures(message.features)
} else {
// configUpdated from a different source (e.g. PermissionDock save).
// Re-apply the draft on top so pending settings changes are preserved.
setConfig(resolveConfig(message.config, draft(), has(draft() as Record<string, unknown>)))
if (message.globalConfig !== undefined) {
setGlobalConfig(stripNulls(deepMerge(message.globalConfig, globalDraft())))
setGlobalConfig(mergeScopedConfig(message.globalConfig, globalDraft()))
setSavedGlobal(message.globalConfig)
}
if (message.projectConfig !== undefined) {
setProjectConfig(mergeScopedConfig(message.projectConfig, projectDraft()))
setSavedProject(message.projectConfig)
}
setFeatures(message.features)
}
setSaved(message.config)
@@ -186,11 +214,17 @@ export const ConfigProvider: ParentComponent = (props) => {
}
function updateGlobalConfig(partial: Partial<Config>) {
setGlobalConfig((prev) => stripNulls(deepMerge(prev, partial)))
setGlobalConfig((prev) => mergeScopedConfig(prev, partial))
setGlobalDraft((prev) => deepMerge(prev as Config, partial))
setSaveError(null)
}
function updateProjectConfig(partial: Partial<Config>) {
setProjectConfig((prev) => mergeScopedConfig(prev, partial))
setProjectDraft((prev) => deepMerge(prev as Config, partial))
setSaveError(null)
}
function updateSetting(key: string, value: unknown) {
setSettings((prev) => ({ ...prev, [key]: value }))
setSettingsDraft((prev) => ({ ...prev, [key]: value }))
@@ -200,11 +234,13 @@ export const ConfigProvider: ParentComponent = (props) => {
function saveConfig() {
const changes = draft()
const globals = globalDraft()
const projects = projectDraft()
const pending = settingsDraft()
const configDirty = has(changes as Record<string, unknown>)
const globalDirty = has(globals as Record<string, unknown>)
const projectDirty = has(projects as Record<string, unknown>)
const settingsDirty = has(pending)
if (!configDirty && !globalDirty && !settingsDirty) return
if (!configDirty && !globalDirty && !projectDirty && !settingsDirty) return
// Don't clear draft/isDirty yet — wait for configUpdated confirmation.
// If the write fails, the save bar stays visible so the user can retry.
setSaving(true)
@@ -216,7 +252,7 @@ export const ConfigProvider: ParentComponent = (props) => {
setSavedSettings((prev) => ({ ...prev, ...pending }))
setSettingsDraft({})
}
if (!configDirty && !globalDirty) {
if (!configDirty && !globalDirty && !projectDirty) {
setSaving(false)
return
}
@@ -225,14 +261,23 @@ export const ConfigProvider: ParentComponent = (props) => {
// extension confirms only after both scopes are saved.
const split = splitConfigByScope(changes)
const next = deepMerge(split.global as Config, globals)
vscode.postMessage({ type: "updateConfig", config: next, projectConfig: split.project })
const project = deepMerge(split.project as Config, projects)
vscode.postMessage({
type: "updateConfig",
config: pruneConfigSet(next) as Config,
projectConfig: pruneConfigSet(project) as Config,
globalUnset: configUnsetPaths(next),
projectUnset: configUnsetPaths(project),
})
}
function discardConfig() {
setConfig(saved())
setGlobalConfig(savedGlobal())
setProjectConfig(savedProject())
setDraft({})
setGlobalDraft({})
setProjectDraft({})
setSettings(savedSettings())
setSettingsDraft({})
setSaveError(null)
@@ -241,6 +286,7 @@ export const ConfigProvider: ParentComponent = (props) => {
const value: ConfigContextValue = {
config,
globalConfig,
projectConfig,
settings,
features,
loading,
@@ -249,6 +295,7 @@ export const ConfigProvider: ParentComponent = (props) => {
saveError,
updateConfig,
updateGlobalConfig,
updateProjectConfig,
updateSetting,
saveConfig,
discardConfig,
@@ -274,16 +274,30 @@ interface StoryProvidersProps {
sessionID?: string
/** When provided, injects a mock ConfigContext with this config instead of the real ConfigProvider. */
config?: Config
globalConfig?: Config
projectConfig?: Config
onConfigChange?: (config: Config) => void
onGlobalConfigChange?: (config: Config) => void
onProjectConfigChange?: (config: Config) => void
kiloAuth?: boolean
/** When true, renders children without the default 12px padding wrapper */
noPadding?: boolean
}
/** Wraps children with either a mock ConfigContext (when config prop is given) or the real ConfigProvider. */
const ConfigWrapper: ParentComponent<{ config?: Config; onConfigChange?: (config: Config) => void }> = (props) => {
const ConfigWrapper: ParentComponent<{
config?: Config
globalConfig?: Config
projectConfig?: Config
onConfigChange?: (config: Config) => void
onGlobalConfigChange?: (config: Config) => void
onProjectConfigChange?: (config: Config) => void
}> = (props) => {
if (props.config) {
const scoped = props.globalConfig !== undefined || props.projectConfig !== undefined
const [cfg, setCfg] = createSignal(props.config)
const [global, setGlobal] = createSignal(props.globalConfig ?? props.config)
const [project, setProject] = createSignal(props.projectConfig ?? props.config)
const [settings, setSettings] = createSignal<Record<string, unknown>>({})
const [dirty, setDirty] = createSignal(false)
const features = createMemo(() => {
@@ -298,7 +312,8 @@ const ConfigWrapper: ParentComponent<{ config?: Config; onConfigChange?: (config
const value = {
config: createMemo(() => cfg()),
globalConfig: createMemo(() => cfg()),
globalConfig: createMemo(() => (scoped ? global() : cfg())),
projectConfig: createMemo(() => (scoped ? project() : cfg())),
settings,
features,
loading: () => false,
@@ -314,11 +329,25 @@ const ConfigWrapper: ParentComponent<{ config?: Config; onConfigChange?: (config
setDirty(true)
},
updateGlobalConfig: (partial: Partial<Config>) => {
setCfg((prev) => {
const update = (prev: Config) => {
const next = merge(prev as Record<string, unknown>, partial as Record<string, unknown>) as Config
props.onGlobalConfigChange?.(next)
props.onConfigChange?.(next)
return next
})
}
if (scoped) setGlobal(update)
if (!scoped) setCfg(update)
setDirty(true)
},
updateProjectConfig: (partial: Partial<Config>) => {
const update = (prev: Config) => {
const next = merge(prev as Record<string, unknown>, partial as Record<string, unknown>) as Config
props.onProjectConfigChange?.(next)
props.onConfigChange?.(next)
return next
}
if (scoped) setProject(update)
if (!scoped) setCfg(update)
setDirty(true)
},
updateSetting: (key: string, value: unknown) => {
@@ -349,7 +378,14 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
<VSCodeProvider>
<ServerProvider>
<FeedbackProvider>
<ConfigWrapper config={props.config} onConfigChange={props.onConfigChange}>
<ConfigWrapper
config={props.config}
globalConfig={props.globalConfig}
projectConfig={props.projectConfig}
onConfigChange={props.onConfigChange}
onGlobalConfigChange={props.onGlobalConfigChange}
onProjectConfigChange={props.onProjectConfigChange}
>
<DisplayProvider>
<MockProviderProvider kiloAuth={props.kiloAuth}>
<DialogProvider>
@@ -443,6 +443,49 @@ export const IndexingProviderBlurRace: Story = {
},
}
export const IndexingScopeSwitch: Story = {
name: "IndexingTab - global and local scopes",
render: () => {
const [global, setGlobal] = createSignal<Record<string, unknown>>({})
const [project, setProject] = createSignal<Record<string, unknown>>({})
const globalConfig: Config = {
indexing: {
enabled: true,
provider: "openai",
model: "text-embedding-3-large",
dimension: 3072,
vectorStore: "qdrant",
openai: { apiKey: "global-secret" },
qdrant: { url: "http://global:6333", apiKey: "global-qdrant" },
searchMinScore: 0.4,
},
}
const projectConfig: Config = {
indexing: {
model: null,
qdrant: { apiKey: "project-qdrant" },
},
}
return (
<>
<StoryProviders
config={globalConfig}
globalConfig={globalConfig}
projectConfig={projectConfig}
onGlobalConfigChange={(next) => setGlobal((next.indexing ?? {}) as Record<string, unknown>)}
onProjectConfigChange={(next) => setProject((next.indexing ?? {}) as Record<string, unknown>)}
>
<div style={{ width: "420px", "max-height": "700px", overflow: "auto" }}>
<IndexingTab />
</div>
</StoryProviders>
<pre data-testid="indexing-global-save">{JSON.stringify(global(), null, 2)}</pre>
<pre data-testid="indexing-project-save">{JSON.stringify(project(), null, 2)}</pre>
</>
)
},
}
export const IndexingKiloModelPreset: Story = {
name: "IndexingTab - Kilo stale custom model fallback",
render: () => {
@@ -462,6 +462,7 @@ export interface ConfigLoadedMessage {
type: "configLoaded"
config: Config
globalConfig?: Config
projectConfig?: Config
features: FeatureFlags
}
@@ -469,6 +470,7 @@ export interface ConfigUpdatedMessage {
type: "configUpdated"
config: Config
globalConfig?: Config
projectConfig?: Config
features: FeatureFlags
}
@@ -425,8 +425,10 @@ export interface UpdateConfigMessage {
type: "updateConfig"
/** Global config patch written to ~/.config/kilo/kilo.json. */
config: Partial<Config>
/** Project config patch written to the workspace's .kilo/kilo.json or existing project config. */
globalUnset?: string[][]
/** Project config patch written to the workspace's .kilo/kilo.jsonc or existing project config. */
projectConfig?: Partial<Config>
projectUnset?: string[][]
}
export interface RequestNotificationSettingsMessage {
@@ -17,6 +17,51 @@ export function deepMerge(target: Config, source: Partial<Config>): Config {
return result as Config
}
function stripUndefined(value: unknown): unknown {
if (!isRecord(value)) return value
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
if (item === undefined) return []
return [[key, stripUndefined(item)]]
}),
)
}
/** Merge raw scoped config while preserving schema-valid indexing null overrides. */
export function mergeScopedConfig(target: Config, source: Partial<Config>): Config {
const merged = deepMerge(target, source)
const result = stripNulls(merged)
if (isRecord(merged.indexing)) result.indexing = stripUndefined(merged.indexing) as Config["indexing"]
return result
}
function indexingNull(path: readonly string[]) {
return path.length === 2 && path[0] === "indexing" && (path[1] === "model" || path[1] === "dimension")
}
export function configUnsetPaths(value: unknown, prefix: string[] = []): string[][] {
if (!isRecord(value)) return []
return Object.entries(value).flatMap(([key, item]) => {
const path = [...prefix, key]
if (item === undefined || (item === null && !indexingNull(path))) return [path]
return configUnsetPaths(item, path)
})
}
/** Prepare an overlay set payload while preserving schema-valid indexing null overrides. */
export function pruneConfigSet(value: unknown, prefix: string[] = []): unknown {
if (!isRecord(value)) return value
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
const path = [...prefix, key]
if (item === undefined || (item === null && !indexingNull(path))) return []
const next = pruneConfigSet(item, path)
if (path[0] === "indexing" && isRecord(next) && Object.keys(next).length === 0) return []
return [[key, next]]
}),
)
}
/** Recursively remove keys whose value is null (null = "deleted"). */
export function stripNulls(obj: Config): Config {
const result: Record<string, unknown> = {}
@@ -1,9 +1,17 @@
import { Show, splitProps, type ComponentProps, type JSX } from "solid-js"
import { Card, CardContent, CardHeader, CardTitle } from "./card"
import { Icon, type IconProps } from "./icon"
import { Tag, CountTag } from "./tag"
import { Tag } from "./tag"
export { CountTag }
export function ConfigTag(props: ComponentProps<typeof Tag>) {
return <Tag {...props} data-slot="config-tag" />
}
export function ConfigCountTag(props: ComponentProps<"span">) {
return <ConfigTag {...props} tone="neutral" />
}
export { ConfigCountTag as CountTag }
type Tone = "neutral" | "success" | "warning" | "critical" | "info" | "brand"
type Source = "default" | "global" | "project" | "system" | "inherited" | "local override" | string | undefined
@@ -39,10 +47,10 @@ export function StatusTag(props: {
return { tone: "neutral" as const, label: props.status || "Unknown" }
}
return (
<Tag tone={meta().tone} class="kw-status-tag">
<ConfigTag tone={meta().tone} class="kw-status-tag">
<StatusDot tone={meta().tone === "critical" ? "critical" : meta().tone === "success" ? "success" : "neutral"} />
{meta().label}
</Tag>
</ConfigTag>
)
}
@@ -50,9 +58,9 @@ export function SourceBadge(props: { source?: Source; inherited?: boolean; overr
const source = () => kind(props)
const label = () => source().toUpperCase()
return (
<Tag class="mono" tone={source() === "project" ? "info" : "neutral"}>
<ConfigTag class="mono" tone={source() === "project" ? "info" : "neutral"}>
{label()}
</Tag>
</ConfigTag>
)
}
@@ -23,6 +23,7 @@ import { isKiloError, showKiloErrorToast } from "@/kilocode/kilo-errors"
import { registerKiloCommands } from "@/kilocode/kilo-commands"
import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui"
import { DialogProcessList } from "@/kilocode/cli/cmd/tui/component/dialog-process-list"
import { useIndexingWarnings } from "@/kilocode/cli/cmd/tui/indexing-warning"
// Re-export so upstream can render the route without importing directly
export { KiloClawView } from "@/kilocode/claw/view"
@@ -155,6 +156,8 @@ export function init() {
const toast = useToast()
const dialog = useDialog()
useIndexingWarnings()
// Inject TUI dependencies for kilo-gateway
initializeTUIDependencies({
useCommandPalette,
@@ -0,0 +1,75 @@
import { createEffect, onCleanup } from "solid-js"
import * as Log from "@opencode-ai/core/util/log"
import { useProject } from "@tui/context/project"
import { useSDK } from "@tui/context/sdk"
import { useToast } from "@tui/ui/toast"
import { Event as IndexingStatusEvent, Warning as IndexingWarningEvent } from "@/kilocode/indexing-event"
import { indexingErrorMessage, indexingWarningKey, type IndexingWarning } from "@/kilocode/indexing-warning"
const log = Log.create({ service: "indexing-warning" })
export function useIndexingWarnings() {
const sdk = useSDK()
const toast = useToast()
const project = useProject()
const seen = new Set<string>()
const state = { scope: "" }
const show = (warning: IndexingWarning) => {
const key = indexingWarningKey(warning)
if (seen.has(key)) return
seen.add(key)
toast.show({
title: "Qdrant Compatibility Warning",
message: warning.message,
variant: "warning",
duration: 10000,
})
}
const showError = (message: string) => {
const key = `error\u0000${message}`
if (seen.has(key)) return
seen.add(key)
toast.show({
title: "Code Indexing Error",
message,
variant: "error",
duration: 10000,
})
}
onCleanup(
sdk.event.on("event", (event) => {
if (event.payload.type !== IndexingWarningEvent.type && event.payload.type !== IndexingStatusEvent.type) return
if (event.workspace !== project.workspace.current()) return
const directory = project.instance.directory() || sdk.directory
if (directory && event.directory !== directory) return
if (event.payload.type === IndexingWarningEvent.type) {
show(event.payload.properties)
return
}
const message = indexingErrorMessage(event.payload.properties.status)
if (message) showError(message)
}),
)
createEffect(() => {
const workspace = project.workspace.current()
const directory = project.instance.directory() || sdk.directory || ""
const scope = `${workspace ?? ""}\u0000${directory}`
if (state.scope !== scope) {
state.scope = scope
seen.clear()
}
void Promise.all([
sdk.client.indexing.warnings({ workspace }, { throwOnError: true }),
sdk.client.indexing.status({ workspace }, { throwOnError: true }),
])
.then(([warnings, status]) => {
if (project.workspace.current() !== workspace) return
if ((project.instance.directory() || sdk.directory || "") !== directory) return
for (const warning of warnings.data ?? []) show(warning)
const message = status.data ? indexingErrorMessage(status.data) : undefined
if (message) showError(message)
})
.catch((err) => log.debug("indexing notification replay failed", { err }))
})
}
@@ -10,14 +10,25 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DEFAULT_VECTOR_STORE } from "@kilocode/kilo-indexing/config"
import { formatKiloEmbeddingModelLabel } from "@kilocode/kilo-indexing/embedding-models"
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
import { useSync } from "@tui/context/sync"
import { useToast } from "@tui/ui/toast"
import { createResource, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import { reconcile } from "solid-js/store"
import type { IndexingConfig, Config } from "@kilocode/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import { hasKiloIndexingAuth, resolveKiloIndexingAuth, shouldDefaultIndexingToKilo } from "../indexing-auth"
import {
createIndexingDialogState,
currentKiloModel,
indexingInheritance,
indexingPatch,
indexingScopeConfig,
inheritedDescription,
kiloModelOptions,
loadKiloEmbeddingModels,
mergeIndexingConfig,
type IndexingScope,
} from "./indexing-dialog-state"
// These types are OpenCode-internal and imported at runtime
type UseSDK = any
@@ -25,6 +36,8 @@ type SDK = any
type EmbeddingProvider = NonNullable<IndexingConfig["provider"]>
const log = Log.create({ service: "indexing-model-select" })
const PROVIDER_LABELS: Record<EmbeddingProvider, string> = {
kilo: "Kilo",
openai: "OpenAI",
@@ -73,93 +86,58 @@ function maskSecret(value: string | undefined): string {
return value.slice(0, 3) + "..." + value.slice(-3)
}
function getIndexing(sync: ReturnType<typeof useSync>): IndexingConfig {
return (sync.data.config as Config & { indexing?: IndexingConfig }).indexing ?? {}
}
function globalIndexing(data: Config | undefined): IndexingConfig {
function scopedIndexing(data: Config | undefined): IndexingConfig {
return data?.indexing ?? {}
}
function hasKiloAuth(sync: ReturnType<typeof useSync>): boolean {
function hasKiloAuth(sync: ReturnType<typeof useSync>, scope: IndexingScope, indexing: IndexingConfig): boolean {
const provider = sync.data.provider_next.all.find((item) => item.id === "kilo")
return hasKiloIndexingAuth({ config: sync.data.config, provider })
const config = indexingScopeConfig(scope, sync.data.config, sync.data.globalConfig, indexing)
return hasKiloIndexingAuth({ config, provider })
}
function defaultIndexing(sync: ReturnType<typeof useSync>, global?: IndexingConfig): IndexingConfig {
const indexing = getIndexing(sync)
function defaultIndexing(
sync: ReturnType<typeof useSync>,
scope: IndexingScope,
indexing: IndexingConfig,
global?: IndexingConfig,
): IndexingConfig {
const provider = sync.data.provider_next.all.find((item) => item.id === "kilo")
const auth = resolveKiloIndexingAuth({ config: sync.data.config, provider })
const config = indexingScopeConfig(scope, sync.data.config, sync.data.globalConfig, indexing)
const auth = resolveKiloIndexingAuth({ config, provider })
if (!shouldDefaultIndexingToKilo({ ...global, ...indexing }, auth)) return indexing
return { ...indexing, provider: "kilo", model: null, dimension: null }
}
async function saveIndexing(
async function saveScopedIndexing(
sdk: SDK,
sync: ReturnType<typeof useSync>,
scope: IndexingScope,
before: IndexingConfig,
indexing: IndexingConfig,
toast: ReturnType<typeof useToast>,
): Promise<boolean> {
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 } } })]
: []),
const patch = indexingPatch(before, indexing)
const response = await sdk.client.config.overlayUpdate({
scope,
set: { indexing: patch.indexing },
unset: patch.unset,
})
if (response.error) {
toast.show({ message: "Failed to save indexing config", variant: "error" })
return false
}
const [configResponse, globalResponse] = await Promise.all([
sdk.client.config.get({}),
sdk.client.global.config.get({}),
])
if (responses.some((response) => response.error)) {
toast.show({ message: "Failed to save indexing config", variant: "error" })
return false
}
const configResponse = await sdk.client.config.get({})
if (configResponse.data) {
sync.set("config", reconcile(configResponse.data))
}
toast.show({ message: "Indexing config saved", variant: "success" })
return true
}
async function saveGlobalIndexing(
sdk: SDK,
sync: ReturnType<typeof useSync>,
indexing: IndexingConfig,
toast: ReturnType<typeof useToast>,
): Promise<boolean> {
const response = await sdk.client.global.config.update({ config: { indexing } })
if (response.error) {
toast.show({ message: "Failed to save indexing config", variant: "error" })
return false
}
const merged = await sdk.client.config.get({})
if (merged.data) sync.set("config", reconcile(merged.data))
toast.show({ message: "Indexing config saved", variant: "success" })
return true
}
async function saveProjectIndexing(
sdk: SDK,
sync: ReturnType<typeof useSync>,
indexing: IndexingConfig,
toast: ReturnType<typeof useToast>,
): Promise<boolean> {
const response = await sdk.client.config.update({ config: { indexing: { enabled: indexing.enabled } } })
if (response.error) {
toast.show({ message: "Failed to save indexing config", variant: "error" })
return false
}
const configResponse = await sdk.client.config.get({})
if (configResponse.data) sync.set("config", reconcile(configResponse.data))
if (globalResponse.data) sync.set("globalConfig", reconcile(globalResponse.data))
toast.show({ message: "Indexing config saved", variant: "success" })
return true
}
function providerSettingsDescription(
sync: ReturnType<typeof useSync>,
indexing: IndexingConfig,
provider: EmbeddingProvider,
): string {
if (provider === "kilo") return hasKiloAuth(sync) ? "uses Kilo account" : "sign in to Kilo"
function providerSettingsDescription(indexing: IndexingConfig, provider: EmbeddingProvider): string {
const fields = PROVIDER_FIELDS[provider]
const settings = indexing[provider] as Record<string, string | undefined> | undefined
if (!settings) return "not configured"
@@ -175,6 +153,10 @@ function providerSettingsDescription(
interface SubDialogProps {
useSDK: () => UseSDK
scope: IndexingScope
indexing: IndexingConfig
raw: IndexingConfig
global?: IndexingConfig
}
function ProviderSelect(props: SubDialogProps) {
@@ -182,12 +164,12 @@ function ProviderSelect(props: SubDialogProps) {
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = defaultIndexing(sync)
const indexing = props.indexing
const options: DialogSelectOption<EmbeddingProvider>[] = (
Object.entries(PROVIDER_LABELS) as [EmbeddingProvider, string][]
)
.filter(([value]) => value !== "kilo" || hasKiloAuth(sync) || indexing.provider === "kilo")
.filter(([value]) => value !== "kilo" || hasKiloAuth(sync, props.scope, indexing) || indexing.provider === "kilo")
.map(([value, title]) => ({
value,
title,
@@ -201,19 +183,18 @@ function ProviderSelect(props: SubDialogProps) {
current={indexing.provider}
onSelect={async (option) => {
const provider = option.value
const current = getIndexing(sync)
const updated: IndexingConfig = {
...current,
...props.raw,
provider,
model: null,
dimension: null,
}
const saved = await saveIndexing(sdk, sync, updated, toast)
const saved = await saveScopedIndexing(sdk, sync, props.scope, props.raw, updated, toast)
if (!saved) {
dialog.clear()
return
}
showProviderSettings(dialog, sync, sdk, toast, provider, props.useSDK)
showProviderSettings(dialog, sync, sdk, toast, provider, props.useSDK, props.scope, updated, updated)
}}
/>
)
@@ -224,51 +205,57 @@ function KiloModelSelect(props: SubDialogProps) {
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = defaultIndexing(sync)
const provider = sync.data.provider_next.all.find((item) => item.id === "kilo")
const auth = resolveKiloIndexingAuth({ config: sync.data.config, provider })
const [catalog] = createResource(() => fetchKiloEmbeddingModelCatalog({ baseURL: auth.baseUrl, token: auth.apiKey }))
const options = () =>
(catalog()?.models ?? []).map((model) => ({
value: model.id,
title: formatKiloEmbeddingModelLabel(model),
}))
const current = () => {
const indexing = props.indexing
const [error, setError] = createSignal<string>()
const [catalog] = createResource(() => loadKiloEmbeddingModels(setError))
const seen = { error: undefined as string | undefined, state: "" }
createEffect(() => {
const message = error()
if (!message || seen.error === message) return
seen.error = message
toast.show({
title: "Code Indexing Error",
message,
variant: "error",
duration: 10000,
})
})
createEffect(() => {
const cfg = catalog()
if (!cfg) return undefined
const fallback = cfg.aliases[cfg.defaultModel] ?? cfg.defaultModel
const id = indexing.model ? (cfg.aliases[indexing.model] ?? indexing.model) : fallback
if (cfg.models.some((model) => model.id === id)) return id
return fallback
}
const state = `${catalog.state}:${cfg?.models.length ?? 0}`
if (seen.state === state) return
seen.state = state
log.info("Kilo embedding model resource changed", {
state: catalog.state,
models: cfg?.models.length ?? 0,
current: currentKiloModel(cfg, indexing.model),
defaultModel: cfg?.defaultModel || undefined,
scope: props.scope,
})
})
const options = createMemo(() => kiloModelOptions(catalog()))
const current = createMemo(() => currentKiloModel(catalog(), indexing.model))
return (
<Show
when={!catalog.loading && options().length > 0}
fallback={
<DialogSelect
title="Kilo Embedding Model"
options={[
{
value: "",
title: catalog.loading ? "Loading supported models..." : "No supported models available",
disabled: true,
},
]}
renderFilter={false}
/>
}
>
<DialogSelect
title="Kilo Embedding Model"
options={options()}
current={current()}
onSelect={async (option) => {
await saveIndexing(sdk, sync, { ...getIndexing(sync), model: option.value, dimension: null }, toast)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
}}
/>
</Show>
<DialogSelect
title="Kilo Embedding Model"
options={options()}
current={current()}
renderFilter={(catalog()?.models.length ?? 0) > 0}
onSelect={async (option) => {
if (!option.value || !catalog()?.models.some((model) => model.id === option.value)) return
log.info("selected Kilo embedding model", { model: option.value, scope: props.scope })
await saveScopedIndexing(
sdk,
sync,
props.scope,
props.raw,
{ ...props.raw, model: option.value, dimension: null },
toast,
)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} scope={props.scope} />)
}}
/>
)
}
@@ -279,13 +266,15 @@ async function showProviderSettings(
toast: ReturnType<typeof useToast>,
provider: EmbeddingProvider,
useSDK: () => UseSDK,
scope: IndexingScope,
indexing: IndexingConfig,
raw: IndexingConfig,
) {
const fields = PROVIDER_FIELDS[provider]
if (fields.length === 0) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
return
}
const indexing = defaultIndexing(sync)
const currentSettings = (indexing[provider] as Record<string, string | undefined>) ?? {}
const newSettings: Record<string, string | undefined> = { ...currentSettings }
@@ -296,15 +285,15 @@ async function showProviderSettings(
placeholder: field.placeholder,
})
if (result === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
return
}
newSettings[field.key] = result.trim() || undefined
}
const updated = { ...getIndexing(sync), [provider]: newSettings }
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
const updated = { ...raw, [provider]: newSettings }
await saveScopedIndexing(sdk, sync, scope, raw, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
}
function VectorStoreSelect(props: SubDialogProps) {
@@ -312,7 +301,7 @@ function VectorStoreSelect(props: SubDialogProps) {
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = defaultIndexing(sync)
const indexing = props.indexing
const options: DialogSelectOption<string>[] = Object.entries(VECTOR_STORE_LABELS).map(([value, title]) => ({
value,
@@ -328,9 +317,9 @@ function VectorStoreSelect(props: SubDialogProps) {
onSelect={async (option) => {
const store = option.value as "lancedb" | "qdrant"
if (store === "lancedb") {
await showLancedbSettings(dialog, sync, sdk, toast, props.useSDK)
await showLancedbSettings(dialog, sync, sdk, toast, props.useSDK, props.scope, indexing, props.raw)
} else {
await showQdrantSettings(dialog, sync, sdk, toast, props.useSDK)
await showQdrantSettings(dialog, sync, sdk, toast, props.useSDK, props.scope, indexing, props.raw)
}
}}
/>
@@ -343,23 +332,25 @@ async function showLancedbSettings(
sdk: SDK,
toast: ReturnType<typeof useToast>,
useSDK: () => UseSDK,
scope: IndexingScope,
indexing: IndexingConfig,
raw: IndexingConfig,
) {
const indexing = getIndexing(sync)
const result = await DialogPrompt.show(dialog, "LanceDB — Directory", {
value: indexing.lancedb?.directory ?? "",
placeholder: "Leave empty for default",
})
if (result === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
return
}
const updated: IndexingConfig = {
...getIndexing(sync),
...raw,
vectorStore: "lancedb",
lancedb: { directory: result.trim() || undefined },
}
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
await saveScopedIndexing(sdk, sync, scope, raw, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
}
async function showQdrantSettings(
@@ -368,8 +359,10 @@ async function showQdrantSettings(
sdk: SDK,
toast: ReturnType<typeof useToast>,
useSDK: () => UseSDK,
scope: IndexingScope,
indexing: IndexingConfig,
raw: IndexingConfig,
) {
const indexing = getIndexing(sync)
const currentSettings = indexing.qdrant ?? {}
const url = await DialogPrompt.show(dialog, "Qdrant — URL", {
@@ -377,7 +370,7 @@ async function showQdrantSettings(
placeholder: "http://localhost:6333",
})
if (url === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
return
}
@@ -386,20 +379,20 @@ async function showQdrantSettings(
placeholder: "Optional API key",
})
if (apiKey === null) {
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
return
}
const updated: IndexingConfig = {
...getIndexing(sync),
...raw,
vectorStore: "qdrant",
qdrant: {
url: url.trim() || undefined,
apiKey: apiKey.trim() || undefined,
},
}
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} />)
await saveScopedIndexing(sdk, sync, scope, raw, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={useSDK} scope={scope} />)
}
interface TuningParam {
@@ -423,14 +416,16 @@ function TuningMenu(props: SubDialogProps) {
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const indexing = getIndexing(sync)
const indexing = props.indexing
const options: DialogSelectOption<string>[] = TUNING_PARAMS.map((param) => {
const value = indexing[param.key]
const description = value !== undefined ? String(value) : `default (${param.defaultValue})`
const inheritance = indexingInheritance(props.scope, props.global ?? {}, props.raw, [[param.key]])
return {
value: param.key,
title: param.label,
description: value !== undefined ? String(value) : `default (${param.defaultValue})`,
description: inheritedDescription(description, inheritance),
}
})
@@ -440,26 +435,50 @@ function TuningMenu(props: SubDialogProps) {
options={options}
onSelect={async (option) => {
const param = TUNING_PARAMS.find((p) => p.key === option.value)!
const currentIndexing = getIndexing(sync)
const currentValue = currentIndexing[param.key]
const currentValue = indexing[param.key]
const result = await DialogPrompt.show(dialog, param.label, {
value: currentValue !== undefined ? String(currentValue) : "",
placeholder: `Default: ${param.defaultValue}`,
})
if (result === null) {
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
dialog.replace(() => (
<TuningMenu
useSDK={props.useSDK}
scope={props.scope}
indexing={indexing}
raw={props.raw}
global={props.global}
/>
))
return
}
const trimmed = result.trim()
const num = trimmed ? Number(trimmed) : undefined
if (trimmed && isNaN(num!)) {
toast.show({ message: `Invalid number: "${trimmed}"`, variant: "error" })
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
dialog.replace(() => (
<TuningMenu
useSDK={props.useSDK}
scope={props.scope}
indexing={indexing}
raw={props.raw}
global={props.global}
/>
))
return
}
const updated = { ...getIndexing(sync), [param.key]: num }
await saveIndexing(sdk, sync, updated, toast)
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
const updated = { ...props.raw, [param.key]: num }
await saveScopedIndexing(sdk, sync, props.scope, props.raw, updated, toast)
const effective = props.scope === "project" ? mergeIndexingConfig(props.global ?? {}, updated) : updated
dialog.replace(() => (
<TuningMenu
useSDK={props.useSDK}
scope={props.scope}
indexing={effective}
raw={updated}
global={props.global}
/>
))
}}
/>
)
@@ -469,6 +488,26 @@ function TuningMenu(props: SubDialogProps) {
interface DialogIndexingProps {
useSDK: () => UseSDK
scope?: IndexingScope
}
function ScopeSelect(props: DialogIndexingProps & { scope: IndexingScope }) {
const dialog = useDialog()
const options: DialogSelectOption<IndexingScope>[] = [
{ value: "global", title: "Global", description: "Stored in the user config directory" },
{ value: "project", title: "Project", description: "Stored in this repo's .kilo config" },
]
return (
<DialogSelect
title="Indexing Scope"
options={options}
current={props.scope}
onSelect={(option) => {
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} scope={option.value} />)
}}
/>
)
}
export function DialogIndexing(props: DialogIndexingProps) {
@@ -476,117 +515,131 @@ export function DialogIndexing(props: DialogIndexingProps) {
const sync = useSync()
const sdk = props.useSDK()
const toast = useToast()
const [global] = createResource(async () => (await sdk.client.global.config.get({})).data as Config | undefined)
const globalCfg = () => globalIndexing(global())
const indexing = defaultIndexing(sync, globalCfg())
const scope = () => props.scope ?? "global"
const [overlay] = createResource(async () => (await sdk.client.config.overlay({ scope: "project" })).data)
const globalCfg = () => scopedIndexing((overlay()?.global as Config | undefined) ?? sync.data.globalConfig)
const projectCfg = () => scopedIndexing(overlay()?.project as Config | undefined)
const state = createIndexingDialogState({
scope,
global: globalCfg,
project: projectCfg,
resolve: (current, global) => defaultIndexing(sync, scope(), current, global),
})
const options = createMemo<DialogSelectOption<string>[]>(() => {
const indexing = state.config()
const provider = indexing.provider ? PROVIDER_LABELS[indexing.provider] : "not set"
const store = indexing.vectorStore ?? DEFAULT_VECTOR_STORE
const storeLabel = VECTOR_STORE_LABELS[store] ?? store
const mark = (value: string, paths: readonly (readonly string[])[]) =>
inheritedDescription(value, state.inherited(paths))
const count = TUNING_PARAMS.filter((param) => indexing[param.key] !== undefined).length
const tuning = count > 0 ? `${count} customized` : "defaults"
const tuningPaths = TUNING_PARAMS.map((param) => [param.key])
const result: DialogSelectOption<string>[] = [
{
value: "scope",
title: "Configuration Scope",
category: "General",
description: scope(),
},
{
value: "enabled",
title: "Indexing",
category: "General",
description: mark(state.enabled() ? "enabled" : "disabled", [["enabled"]]),
},
{
value: "provider",
title: "Embedding Provider",
category: "Embedding",
description: mark(provider, [["provider"]]),
},
{
value: "model",
title: "Embedding Model",
category: "Embedding",
description: mark(
indexing.provider === "kilo" ? (indexing.model ?? "Kilo catalog") : (indexing.model ?? "default"),
[["model"]],
),
},
{
value: "dimension",
title: "Vector Dimension",
category: "Embedding",
description:
indexing.provider === "kilo"
? "provided by Kilo"
: mark(indexing.dimension ? String(indexing.dimension) : "auto", [["dimension"]]),
disabled: indexing.provider === "kilo",
},
{
value: "vectorStore",
title: "Vector Store",
category: "Storage",
description: mark(storeLabel, [["vectorStore"]]),
},
{
value: "tuning",
title: "Tuning Parameters",
category: "Advanced",
description: mark(tuning, tuningPaths),
},
]
const providerLabel = indexing.provider ? PROVIDER_LABELS[indexing.provider] : "not set"
const store = indexing.vectorStore ?? DEFAULT_VECTOR_STORE
const storeLabel = VECTOR_STORE_LABELS[store] ?? store
const tuningCount = TUNING_PARAMS.filter((p) => indexing[p.key] !== undefined).length
const tuningDesc = tuningCount > 0 ? `${tuningCount} customized` : "defaults"
const options: DialogSelectOption<string>[] = [
{
value: "globalToggle",
title: "Indexing (Global)",
category: "General",
description: global.loading ? "loading" : globalCfg().enabled ? "enabled" : "disabled",
},
{
value: "projectToggle",
title: "Indexing (Project)",
category: "General",
description: globalCfg().enabled ? "controlled by global" : indexing.enabled ? "enabled" : "disabled",
},
{
value: "provider",
title: "Embedding Provider",
category: "Embedding",
description: providerLabel,
},
{
value: "model",
title: indexing.provider === "kilo" ? "Kilo Model Preset" : "Embedding Model",
category: "Embedding",
description: indexing.provider === "kilo" ? "hosted presets only" : (indexing.model ?? "default"),
},
{
value: "dimension",
title: "Vector Dimension",
category: "Embedding",
description: indexing.dimension ? String(indexing.dimension) : "auto",
},
{
value: "vectorStore",
title: "Vector Store",
category: "Storage",
description: storeLabel,
},
{
value: "tuning",
title: "Tuning Parameters",
category: "Advanced",
description: tuningDesc,
},
]
if (indexing.provider) {
const settingsDesc = providerSettingsDescription(sync, indexing, indexing.provider)
options.splice(2, 0, {
value: "providerSettings",
title: `${PROVIDER_LABELS[indexing.provider]} Settings`,
category: "Embedding",
description: settingsDesc,
})
}
if (indexing.provider && PROVIDER_FIELDS[indexing.provider].length > 0) {
result.splice(3, 0, {
value: "providerSettings",
title: `${PROVIDER_LABELS[indexing.provider]} Settings`,
category: "Embedding",
description: mark(
providerSettingsDescription(indexing, indexing.provider),
PROVIDER_FIELDS[indexing.provider].map((field) => [indexing.provider!, field.key]),
),
})
}
return result
})
return (
<DialogSelect
title="Indexing Configuration"
options={options}
options={options()}
skipFilter
onSelect={async (option) => {
const indexing = state.config()
const raw = state.raw()
switch (option.value) {
case "globalToggle": {
const enabled = !globalCfg().enabled
const updated =
enabled && !globalCfg().provider && !getIndexing(sync).provider && hasKiloAuth(sync)
? { ...defaultIndexing(sync, globalCfg()), enabled }
: { enabled }
await saveGlobalIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
case "scope":
dialog.replace(() => <ScopeSelect useSDK={props.useSDK} scope={scope()} />)
break
}
case "projectToggle": {
if (globalCfg().enabled) {
toast.show({
message: "Global indexing is enabled, so this project is already covered.",
variant: "info",
})
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
break
}
const current = getIndexing(sync)
const enabled = !indexing.enabled
const updated =
enabled && !current.provider && hasKiloAuth(sync) ? { ...defaultIndexing(sync), enabled } : { enabled }
await saveProjectIndexing(sdk, sync, updated, toast)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
case "enabled":
await saveScopedIndexing(sdk, sync, scope(), raw, { ...raw, enabled: !state.enabled() }, toast)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} scope={scope()} />)
break
}
case "provider":
dialog.replace(() => <ProviderSelect useSDK={props.useSDK} />)
dialog.replace(() => <ProviderSelect useSDK={props.useSDK} scope={scope()} indexing={indexing} raw={raw} />)
break
case "providerSettings":
if (indexing.provider) {
await showProviderSettings(dialog, sync, sdk, toast, indexing.provider, props.useSDK)
await showProviderSettings(
dialog,
sync,
sdk,
toast,
indexing.provider,
props.useSDK,
scope(),
indexing,
raw,
)
}
break
case "model": {
if (indexing.provider === "kilo") {
dialog.replace(() => <KiloModelSelect useSDK={props.useSDK} />)
dialog.replace(() => (
<KiloModelSelect useSDK={props.useSDK} scope={scope()} indexing={indexing} raw={raw} />
))
break
}
const result = await DialogPrompt.show(dialog, "Embedding Model", {
@@ -595,12 +648,13 @@ export function DialogIndexing(props: DialogIndexingProps) {
})
if (result !== null) {
const trimmed = result.trim()
await saveIndexing(sdk, sync, { ...getIndexing(sync), model: trimmed || null }, toast)
await saveScopedIndexing(sdk, sync, scope(), raw, { ...raw, model: trimmed || null }, toast)
}
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} scope={scope()} />)
break
}
case "dimension": {
if (indexing.provider === "kilo") break
const result = await DialogPrompt.show(dialog, "Vector Dimension", {
value: indexing.dimension ? String(indexing.dimension) : "",
placeholder: "Leave empty for auto-detection",
@@ -612,21 +666,24 @@ export function DialogIndexing(props: DialogIndexingProps) {
dim = Number(trimmed)
if (isNaN(dim) || dim <= 0 || !Number.isInteger(dim)) {
toast.show({ message: `Invalid dimension: "${trimmed}"`, variant: "error" })
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} scope={scope()} />)
break
}
}
const updated = { ...getIndexing(sync), dimension: dim ?? null }
await saveIndexing(sdk, sync, updated, toast)
await saveScopedIndexing(sdk, sync, scope(), raw, { ...raw, dimension: dim ?? null }, toast)
}
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} />)
dialog.replace(() => <DialogIndexing useSDK={props.useSDK} scope={scope()} />)
break
}
case "vectorStore":
dialog.replace(() => <VectorStoreSelect useSDK={props.useSDK} />)
dialog.replace(() => (
<VectorStoreSelect useSDK={props.useSDK} scope={scope()} indexing={indexing} raw={raw} />
))
break
case "tuning":
dialog.replace(() => <TuningMenu useSDK={props.useSDK} />)
dialog.replace(() => (
<TuningMenu useSDK={props.useSDK} scope={scope()} indexing={indexing} raw={raw} global={globalCfg()} />
))
break
}
}}
@@ -0,0 +1,148 @@
import { fetchKiloEmbeddingModelCatalog, resolveKiloGatewayBaseUrl } from "@kilocode/kilo-gateway"
import type { Config, IndexingConfig, KiloEmbeddingModelCatalog } from "@kilocode/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import { createMemo, type Accessor } from "solid-js"
export type IndexingScope = "global" | "project"
const log = Log.create({ service: "indexing-model-catalog" })
export async function loadKiloEmbeddingModels(onError?: (message: string) => void) {
const endpoint = new URL("embedding-models", resolveKiloGatewayBaseUrl()).toString()
log.info("loading Kilo embedding model catalog", { endpoint })
const catalog = await fetchKiloEmbeddingModelCatalog({
onError: (issue) => {
log.warn("failed to load Kilo embedding model catalog", {
code: issue.code,
status: issue.status,
message: issue.message,
})
onError?.(issue.message)
},
})
log.info("loaded Kilo embedding model catalog", {
models: catalog.models.length,
defaultModel: catalog.defaultModel || undefined,
})
return catalog
}
export function kiloModelOptions(catalog?: KiloEmbeddingModelCatalog) {
if (!catalog) return [{ value: "", title: "Loading supported models..." }]
if (catalog.models.length === 0) return [{ value: "", title: "No supported models available" }]
return catalog.models.map((model) => ({
value: model.id,
title: `${model.name} (${model.note ? `${model.note}, ` : ""}${model.dimension}d)`,
}))
}
export function currentKiloModel(catalog: KiloEmbeddingModelCatalog | undefined, model?: string | null) {
if (!catalog) return undefined
const fallback = catalog.aliases[catalog.defaultModel] ?? catalog.defaultModel
const current = model ? (catalog.aliases[model] ?? model) : fallback
return catalog.models.some((item) => item.id === current) ? current : fallback
}
export function indexingScopeConfig(
scope: IndexingScope,
effective: Config,
global: Config,
indexing: IndexingConfig,
): Config {
return { ...(scope === "global" ? global : effective), indexing }
}
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}
function get(input: IndexingConfig, path: readonly string[]) {
return path.reduce<unknown>((value, key) => (record(value) ? value[key] : undefined), input)
}
export type IndexingInheritance = "none" | "inherited" | "partial"
export function indexingInheritance(
scope: IndexingScope,
global: IndexingConfig,
project: IndexingConfig,
paths: readonly (readonly string[])[],
): IndexingInheritance {
if (scope !== "project") return "none"
const configured = paths.filter((path) => get(global, path) !== undefined || get(project, path) !== undefined)
const inherited = configured.filter((path) => get(project, path) === undefined && get(global, path) !== undefined)
if (inherited.length === 0) return "none"
return inherited.length === configured.length ? "inherited" : "partial"
}
export function inheritedDescription(value: string, inheritance: IndexingInheritance) {
if (inheritance === "inherited") return `${value} (inherited)`
if (inheritance === "partial") return `${value} (partially inherited)`
return value
}
function prune(input: unknown): unknown {
if (!record(input)) return input
const entries = Object.entries(input).flatMap(([key, value]) => {
if (value === undefined) return []
const next = prune(value)
if (record(next) && Object.keys(next).length === 0) return []
return [[key, next] as const]
})
return Object.fromEntries(entries)
}
function removed(before: unknown, after: unknown, prefix: string[]): string[][] {
if (!record(before)) return []
const next = record(after) ? after : {}
return Object.entries(before).flatMap(([key, value]) => {
const path = [...prefix, key]
if (!(key in next)) return [path]
if (record(value) && record(next[key])) return removed(value, next[key], path)
return []
})
}
export function indexingPatch(before: IndexingConfig, after: IndexingConfig) {
const indexing = prune(after) as IndexingConfig
const unset = removed(before, indexing, ["indexing"])
return { indexing, unset: unset.length > 0 ? unset : undefined }
}
function mergeRecord(base: Record<string, unknown>, patch: Record<string, unknown>) {
const result: Record<string, unknown> = { ...base }
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue
if (record(value) && record(result[key])) {
result[key] = mergeRecord(result[key], value)
continue
}
result[key] = value
}
return result
}
export function mergeIndexingConfig(base: IndexingConfig, patch: IndexingConfig): IndexingConfig {
return mergeRecord(base, patch) as IndexingConfig
}
export function createIndexingDialogState(input: {
scope: Accessor<IndexingScope>
global: Accessor<IndexingConfig>
project: Accessor<IndexingConfig>
resolve: (indexing: IndexingConfig, global: IndexingConfig) => IndexingConfig
}) {
const raw = createMemo(() => (input.scope() === "global" ? input.global() : input.project()))
const config = createMemo(() => {
const value = input.scope() === "global" ? raw() : mergeIndexingConfig(input.global(), raw())
return input.resolve(value, input.global())
})
const enabled = createMemo(() => {
if (input.scope() === "global") return raw().enabled === true
return (raw().enabled ?? input.global().enabled) === true
})
const inherited = (paths: readonly (readonly string[])[]) =>
indexingInheritance(input.scope(), input.global(), input.project(), paths)
return { raw, config, enabled, inherited }
}
@@ -65,7 +65,7 @@ export namespace KilocodeConfig {
*
* This mirrors the Kilo project-config load chain: prefer existing config files
* in ancestor config directories, then existing root config files, and create
* `.kilo/kilo.json` when no project config exists yet.
* `.kilo/kilo.jsonc` when no project config exists yet.
*/
export const projectConfigUpdateTarget = Effect.fn("KilocodeConfig.projectConfigUpdateTarget")(function* (input: {
fs: AppFileSystem.Interface
@@ -79,7 +79,7 @@ export namespace KilocodeConfig {
.up({ targets: [...ALL_CONFIG_FILES], start: input.directory, stop: input.worktree })
.pipe(Effect.orDie)
const files = [...dirs.flatMap((dir) => ALL_CONFIG_FILES.map((file) => path.join(dir, file))), ...roots]
return files.find((file) => existsSync(file)) ?? path.join(input.directory, ".kilo", "kilo.json")
return files.find((file) => existsSync(file)) ?? path.join(input.directory, ".kilo", "kilo.jsonc")
})
export const updateProjectConfig = Effect.fn("KilocodeConfig.updateProjectConfig")(function* (input: {
@@ -98,6 +98,7 @@ export namespace KilocodeConfig {
const patch = input.writable(input.config)
if (file.endsWith(".jsonc")) {
if (source === undefined && Object.keys(mergeConfig({}, patch)).length === 0) return
const updated = input.patch(before, patch)
yield* input.fs.writeWithDirs(file, updated).pipe(Effect.orDie)
return
@@ -84,6 +84,33 @@ export namespace KilocodeConfigOverlay {
["disabled_providers"],
["watcher", "ignore"],
["instructions"],
["indexing", "enabled"],
["indexing", "provider"],
["indexing", "model"],
["indexing", "dimension"],
["indexing", "vectorStore"],
["indexing", "kilo", "apiKey"],
["indexing", "kilo", "baseUrl"],
["indexing", "kilo", "organizationId"],
["indexing", "openai", "apiKey"],
["indexing", "ollama", "baseUrl"],
["indexing", "openai-compatible", "baseUrl"],
["indexing", "openai-compatible", "apiKey"],
["indexing", "gemini", "apiKey"],
["indexing", "mistral", "apiKey"],
["indexing", "vercel-ai-gateway", "apiKey"],
["indexing", "bedrock", "region"],
["indexing", "bedrock", "profile"],
["indexing", "openrouter", "apiKey"],
["indexing", "openrouter", "specificProvider"],
["indexing", "voyage", "apiKey"],
["indexing", "qdrant", "url"],
["indexing", "qdrant", "apiKey"],
["indexing", "lancedb", "directory"],
["indexing", "searchMinScore"],
["indexing", "searchMaxResults"],
["indexing", "embeddingBatchSize"],
["indexing", "scannerMaxBatchRetries"],
] as const
const collectionPaths = ["provider", "mcp", "permission", "agent", "formatter", "lsp"] as const
@@ -99,7 +126,7 @@ export namespace KilocodeConfigOverlay {
const found = await Filesystem.findUp([...dirs], input.directory, input.worktree)
const roots = await Filesystem.findUp([...files], input.directory, input.worktree)
const candidates = [...found.flatMap((dir) => files.map((file) => path.join(dir, file))), ...roots]
return candidates.find((file) => existsSync(file)) ?? path.join(input.directory, ".kilo", "kilo.json")
return candidates.find((file) => existsSync(file)) ?? path.join(input.directory, ".kilo", "kilo.jsonc")
}
export function globalTarget() {
@@ -188,19 +215,46 @@ export namespace KilocodeConfigOverlay {
parts: string[],
): Resolved {
const key = parts.join(".")
const value = fieldValue(scope, effective, global, local, parts)
const hasValue = hasFieldValue(scope, effective, global, local, parts)
return resolved({
key,
path: parts,
scope,
value: get(effective, parts),
value,
global: get(global, parts),
local: get(local, parts),
hasValue: has(effective, parts),
hasValue,
hasGlobal: has(global, parts),
hasLocal: has(local, parts),
})
}
function isIndexing(parts: string[]) {
return parts[0] === "indexing"
}
function fieldValue(scope: Scope, effective: Config.Info, global: Config.Info, local: Config.Info, parts: string[]) {
if (!isIndexing(parts)) return get(effective, parts)
if (scope === "project" && has(local, parts)) return get(local, parts)
if (has(global, parts)) return get(global, parts)
if (scope === "global" && has(local, parts)) return undefined
return get(effective, parts)
}
function hasFieldValue(
scope: Scope,
effective: Config.Info,
global: Config.Info,
local: Config.Info,
parts: string[],
) {
if (!isIndexing(parts)) return has(effective, parts)
if (scope === "project" && has(local, parts)) return true
if (has(global, parts)) return true
return !has(local, parts) && has(effective, parts)
}
function collection(scope: Scope, effective: Config.Info, global: Config.Info, local: Config.Info, key: string) {
const names = new Set([
...Object.keys(record(get(effective, [key]))),
@@ -2,6 +2,7 @@ import { Schema } from "effect"
import { INDEXING_STATUS_STATES } from "@kilocode/kilo-indexing/status"
import { BusEvent } from "@/bus/bus-event"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { INDEXING_WARNING_CODES } from "./indexing-warning"
export const IndexingStatusState = Schema.Literals(INDEXING_STATUS_STATES).annotate({
identifier: "IndexingStatusState",
@@ -21,3 +22,10 @@ export const Event = BusEvent.define(
status: IndexingStatusInfo,
}),
)
export const IndexingWarningInfo = Schema.Struct({
code: Schema.Literals(INDEXING_WARNING_CODES),
message: Schema.String,
}).annotate({ identifier: "IndexingWarning" })
export const Warning = BusEvent.define("indexing.warning", IndexingWarningInfo)
@@ -0,0 +1,29 @@
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"
export const INDEXING_WARNING_CODES = ["qdrant.version-incompatible", "qdrant.version-unavailable"] as const
export type IndexingWarning = {
code: (typeof INDEXING_WARNING_CODES)[number]
message: string
}
const incompatible =
/^Client version .+ is incompatible with server version .+\. Major versions should match and minor version difference must not exceed 1\. Set checkCompatibility=false to skip version check\.$/
const detail = " Major versions should match and minor version difference must not exceed 1."
const unavailable =
/^Failed to obtain server version\. Unable to check client-server compatibility\. Set checkCompatibility=false to skip version check\.$/
export function parseQdrantWarning(value: unknown): IndexingWarning | undefined {
if (typeof value !== "string") return undefined
if (incompatible.test(value)) return { code: "qdrant.version-incompatible", message: value.replace(detail, "") }
if (unavailable.test(value)) return { code: "qdrant.version-unavailable", message: value }
return undefined
}
export function indexingWarningKey(warning: IndexingWarning): string {
return `${warning.code}\u0000${warning.message}`
}
export function indexingErrorMessage(status: IndexingStatus): string | undefined {
return status.state === "Error" ? status.message : undefined
}
@@ -5,7 +5,8 @@ import type {
} from "@kilocode/kilo-indexing/engine"
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"
import { withTimeout } from "@/util/timeout"
import type { Message, Request, Result } from "./indexing-worker-protocol"
import type { Log, Message, Request, Result } from "./indexing-worker-protocol"
import type { IndexingWarning } from "./indexing-warning"
declare global {
const KILO_INDEXING_WORKER_PATH: string
@@ -15,6 +16,8 @@ export namespace IndexingWorker {
export type Hooks = {
status(status: IndexingStatus): void
telemetry(event: IndexingTelemetryEvent): void
warning(warning: IndexingWarning): void
log(event: Log): void
failure(err: unknown): void
}
@@ -26,16 +29,24 @@ export namespace IndexingWorker {
export type Factory = (directory: string, root: string, hooks: Hooks) => Driver
const worker = (directory: string, root: string, hooks: Hooks): Driver => {
type Host = Driver & {
use(hooks: Hooks): void
}
const pool = new Map<string, Host>()
const worker = (directory: string, root: string, hooks: Hooks): Host => {
const file =
typeof KILO_INDEXING_WORKER_PATH !== "undefined"
? KILO_INDEXING_WORKER_PATH
: new URL("./indexing-worker.ts", import.meta.url)
const task = new Worker(file)
const key = `${directory}\0${root}`
const task = new Worker(file, { ref: false })
const pending = new Map<number, { resolve(message: Result): void; reject(err: unknown): void }>()
let id = 0
let stopped = false
let stopping = false
let active = true
let callbacks = hooks
const reject = (err: unknown) => {
for (const item of pending.values()) item.reject(err)
@@ -43,19 +54,22 @@ export namespace IndexingWorker {
}
const fail = (err: unknown) => {
if (stopped || stopping) return
if (stopped) return
stopped = true
active = false
reject(err)
task.terminate()
hooks.failure(err)
if (pool.get(key) === host) pool.delete(key)
callbacks.failure(err)
}
task.onmessage = (event: MessageEvent<Message>) => {
const message = event.data
if (message.type === "event") {
if (stopping || stopped) return
if (message.event === "status") hooks.status(message.data)
if (message.event === "telemetry") hooks.telemetry(message.data)
if (stopped || !active) return
if (message.event === "status") callbacks.status(message.data)
if (message.event === "telemetry") callbacks.telemetry(message.data)
if (message.event === "warning") callbacks.warning(message.data)
if (message.event === "log") callbacks.log(message.data)
return
}
@@ -73,8 +87,13 @@ export namespace IndexingWorker {
fail(event.error ?? new Error(event.message))
}
const call = <T>(request: Request, read: (message: Result) => T, allowStopping = false) => {
if (stopped || (stopping && !allowStopping)) return Promise.reject(new Error("Indexing worker is disposed."))
task.addEventListener("close", () => {
if (pool.get(key) === host) pool.delete(key)
fail(new Error("Indexing worker exited."))
})
const call = <T>(request: Request, read: (message: Result) => T) => {
if (stopped) return Promise.reject(new Error("Indexing worker is unavailable."))
return new Promise<T>((resolve, reject) => {
pending.set(request.id, {
resolve(message) {
@@ -90,8 +109,13 @@ export namespace IndexingWorker {
})
}
return {
const host: Host = {
use(next) {
callbacks = next
active = true
},
init(config) {
active = true
const request: Request = {
type: "request",
id: id++,
@@ -116,35 +140,41 @@ export namespace IndexingWorker {
})
},
async dispose() {
if (stopped || stopping) return
stopping = true
if (stopped || !active) return
active = false
const request: Request = { type: "request", id: id++, method: "dispose", input: undefined }
await withTimeout(
call(
request,
(message) => {
if (message.ok && message.method === "dispose") return message.value
throw new Error("Unexpected indexing worker dispose response.")
},
true,
),
call(request, (message) => {
if (message.ok && message.method === "dispose") return message.value
throw new Error("Unexpected indexing worker dispose response.")
}),
1000,
"Indexing worker shutdown timed out",
).catch(() => undefined)
stopped = true
reject(new Error("Indexing worker is disposed."))
task.terminate()
"Indexing worker reset timed out",
).catch((err) => {
stopped = true
reject(err)
})
},
}
return host
}
let factory: Factory = worker
let factory: Factory | undefined
export function create(directory: string, root: string, hooks: Hooks) {
return factory(directory, root, hooks)
if (factory) return factory(directory, root, hooks)
const key = `${directory}\0${root}`
const existing = pool.get(key)
if (existing) {
existing.use(hooks)
return existing
}
const next = worker(directory, root, hooks)
pool.set(key, next)
return next
}
export function override(next?: Factory) {
factory = next ?? worker
factory = next
}
}
@@ -4,6 +4,7 @@ import type {
VectorStoreSearchResult,
} from "@kilocode/kilo-indexing/engine"
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"
import type { IndexingWarning } from "./indexing-warning"
export type InitInput = {
directory: string
@@ -23,8 +24,15 @@ export type Result =
| { type: "result"; id: number; method: "dispose"; ok: true; value: undefined }
| { type: "result"; id: number; method: Request["method"]; ok: false; error: string }
export type Log = {
level: "debug" | "info" | "warn" | "error"
message: string
}
export type Event =
| { type: "event"; event: "status"; data: IndexingStatus }
| { type: "event"; event: "telemetry"; data: IndexingTelemetryEvent }
| { type: "event"; event: "warning"; data: IndexingWarning }
| { type: "event"; event: "log"; data: Log }
export type Message = Result | Event
@@ -1,6 +1,7 @@
import { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
import { normalizeIndexingStatus } from "@kilocode/kilo-indexing/status"
import type { Request, Result, Event } from "./indexing-worker-protocol"
import type { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
import { format } from "node:util"
import type { Request, Result, Event, Log } from "./indexing-worker-protocol"
import { parseQdrantWarning } from "./indexing-warning"
let manager: CodeIndexManager | undefined
let progress: { dispose(): void } | undefined
@@ -10,6 +11,20 @@ function send(message: Result | Event) {
postMessage(message)
}
function write(level: Log["level"], args: unknown[]) {
const message = format(...args)
send({ type: "event", event: "log", data: { level, message } })
if (level !== "warn") return
const warning = parseQdrantWarning(message)
if (warning) send({ type: "event", event: "warning", data: warning })
}
console.debug = (...args) => write("debug", args)
console.info = (...args) => write("info", args)
console.log = (...args) => write("info", args)
console.warn = (...args) => write("warn", args)
console.error = (...args) => write("error", args)
function dispose() {
progress?.dispose()
telemetry?.dispose()
@@ -22,20 +37,23 @@ function dispose() {
async function init(request: Extract<Request, { method: "init" }>) {
dispose()
if (request.input.lancedbPath) process.env.KILO_LANCEDB_PATH = request.input.lancedbPath
const next = new CodeIndexManager(request.input.directory, request.input.root)
const [engine, status] = await Promise.all([
import("@kilocode/kilo-indexing/engine"),
import("@kilocode/kilo-indexing/status"),
])
const next = new engine.CodeIndexManager(request.input.directory, request.input.root)
manager = next
progress = next.onProgressUpdate.on(() => {
send({ type: "event", event: "status", data: normalizeIndexingStatus(next) })
send({ type: "event", event: "status", data: status.normalizeIndexingStatus(next) })
})
telemetry = next.onTelemetry.on((data) => {
send({ type: "event", event: "telemetry", data })
})
await next.initialize(request.input.config)
send({ type: "result", id: request.id, method: "init", ok: true, value: normalizeIndexingStatus(next) })
send({ type: "result", id: request.id, method: "init", ok: true, value: status.normalizeIndexingStatus(next) })
}
onmessage = async (event: MessageEvent<Request>) => {
const request = event.data
async function handle(request: Request) {
try {
if (request.method === "dispose") {
dispose()
@@ -55,3 +73,8 @@ onmessage = async (event: MessageEvent<Request>) => {
send({ type: "result", id: request.id, method: request.method, ok: false, error })
}
}
let queue = Promise.resolve()
onmessage = (event: MessageEvent<Request>) => {
queue = queue.then(() => handle(event.data))
}
+116 -20
View File
@@ -15,7 +15,10 @@ import { makeRuntime } from "@/effect/run-service"
import { registerDisposer } from "@/effect/instance-registry"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { Event as IndexingEvent } from "./indexing-event"
import type { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Event as IndexingEvent, Warning as IndexingWarningEvent } from "./indexing-event"
import { indexingWarningKey, type IndexingWarning } from "./indexing-warning"
import { IndexingWorker } from "./indexing-worker-client"
import { LanceDBRuntime } from "./lancedb" // kilocode_change
import { indexingWithKiloDefault, resolveKiloIndexingAuth, type KiloIndexingAuth } from "./indexing-auth" // kilocode_change
@@ -105,7 +108,7 @@ async function model(input: ReturnType<typeof toIndexingConfigInput>, auth: Kilo
return {
...input,
modelId: found.id,
modelDimension: chosen ? (input.modelDimension ?? found.dimension) : found.dimension,
modelDimension: found.dimension,
searchMinScore: input.searchMinScore ?? found.scoreThreshold,
}
}
@@ -188,7 +191,7 @@ export namespace KiloIndexing {
export function input(config?: IndexingConfig, global?: IndexingConfig) {
return toIndexingConfigInput({
...config,
enabled: config?.enabled === true || global?.enabled === true,
enabled: config?.enabled ?? global?.enabled ?? false,
})
}
@@ -196,6 +199,8 @@ export namespace KiloIndexing {
engine?: IndexingWorker.Driver
initialized?: boolean
current(): Status
warnings(): IndexingWarning[]
scope(workspace: WorkspaceID | undefined): void
publish(): Promise<void>
dispose(): Promise<void>
}
@@ -210,6 +215,7 @@ export namespace KiloIndexing {
}
export const Event = IndexingEvent
export const Warning = IndexingWarningEvent
const cache = new Map<string, Cache>()
@@ -220,6 +226,8 @@ export namespace KiloIndexing {
return {
current,
warnings: () => [],
scope() {},
publish,
async dispose() {},
}
@@ -253,35 +261,99 @@ export namespace KiloIndexing {
const global = globalConfig.indexing
const merged = indexingWithKiloDefault({ ...global, ...cfg.indexing }, auth)
const cfgInput = await model(enrichKilo(input(merged, global), auth), auth)
const workspaces = new Set<WorkspaceID | undefined>([WorkspaceContext.workspaceID])
const box = { status: pending() }
const warnings = new Map<string, IndexingWarning>()
const delivery = {
last: undefined as Status | undefined,
task: Promise.resolve(),
timer: undefined as ReturnType<typeof setTimeout> | undefined,
time: 0,
}
const current = () => box.status
let disposed = false
const publish = async () => {
await Bus.publish(Event, { status: current() })
}
const report = Instance.bind(async () => {
try {
return await publish()
} catch (err) {
log.error("failed to publish indexing status", { err })
}
const same = (left: Status | undefined, right: Status) =>
left?.state === right.state &&
left.message === right.message &&
left.processedFiles === right.processedFiles &&
left.totalFiles === right.totalFiles &&
left.percent === right.percent
const report = Instance.bind((next = current()) => {
delivery.task = delivery.task
.then(async () => {
if (disposed || same(delivery.last, next)) return
await Bus.publish(Event, { status: next })
delivery.last = next
})
.catch((err) => {
log.error("failed to publish indexing status", { err })
})
return delivery.task
})
const clear = () => {
if (!delivery.timer) return
clearTimeout(delivery.timer)
delivery.timer = undefined
}
const status = Instance.bind((next: Status) => {
if (disposed) return
const previous = current()
box.status = next
void report()
if (same(previous, next)) return
const immediate = previous.state !== next.state || next.state !== "In Progress"
if (immediate) {
clear()
delivery.time = Date.now()
void report(next)
return
}
if (delivery.timer) return
const delay = Math.max(0, 250 - (Date.now() - delivery.time))
if (delay === 0) {
delivery.time = Date.now()
void report(next)
return
}
delivery.timer = setTimeout(
Instance.bind(() => {
delivery.timer = undefined
delivery.time = Date.now()
void report()
}),
delay,
)
})
const telemetry = Instance.bind((event: IndexingTelemetryEvent) => {
if (disposed) return
trackTelemetry(event)
})
const warning = Instance.bind((item: IndexingWarning) => {
if (disposed) return
const key = indexingWarningKey(item)
if (warnings.has(key)) return
warnings.set(key, item)
void Promise.all(
[...workspaces].map((workspaceID) =>
WorkspaceContext.provide({ workspaceID, fn: () => Bus.publish(Warning, item) }),
),
).catch((err) => {
log.error("failed to publish indexing warning", { err, workspacePath: dir })
})
})
const output = Instance.bind((event: Parameters<IndexingWorker.Hooks["log"]>[0]) => {
if (disposed) return
log[event.level](event.message, { source: "worker", workspacePath: dir })
})
const base: Entry = {
current,
publish,
warnings: () => [...warnings.values()],
scope: (workspaceID) => workspaces.add(workspaceID),
publish: () => report(),
async dispose() {
if (disposed) return
disposed = true
clear()
base.initialized = false
await base.engine?.dispose().catch((err) => {
log.warn("failed to dispose project indexing worker", { err, workspacePath: dir })
@@ -291,9 +363,8 @@ export namespace KiloIndexing {
const failure = Instance.bind((err: unknown) => {
if (disposed) return
base.initialized = false
box.status = failed(err)
log.error("project indexing worker failed", { err, workspacePath: dir })
void report()
status(failed(err))
})
track(hit, base)
await report()
@@ -309,7 +380,7 @@ export namespace KiloIndexing {
const err = await LanceDBRuntime.ensure(cfgInput.vectorStoreProvider)
.then(async () => {
if (hit.disposed) return
const engine = IndexingWorker.create(dir, root, { status, telemetry, failure })
const engine = IndexingWorker.create(dir, root, { status, telemetry, warning, log: output, failure })
base.engine = engine
box.status = await engine.init(cfgInput)
base.initialized = true
@@ -325,12 +396,13 @@ export namespace KiloIndexing {
log.warn("failed to dispose failed project indexing worker", { err: disposeErr, workspacePath: dir })
})
base.engine = undefined
box.status = failed(err)
const next = failed(err)
status(next)
log.error("project indexing initialization failed", {
err,
workspacePath: dir,
})
await report()
await report(next)
return base
}
@@ -391,7 +463,29 @@ export namespace KiloIndexing {
}
export async function current(): Promise<Status> {
return (await hit().ready).current()
const entry = await hit().ready
entry.scope(WorkspaceContext.workspaceID)
return entry.current()
}
export async function models() {
try {
const cfg = await AppRuntime.runPromise(Config.Service.use((svc) => svc.getGlobal()))
const auth = await kiloAuth(cfg)
const catalog = await fetchKiloEmbeddingModelCatalog({ baseURL: auth.baseUrl, token: auth.apiKey })
if (catalog.models.length > 0 || (!auth.baseUrl && !auth.apiKey)) return catalog
const fallback = await fetchKiloEmbeddingModelCatalog()
return fallback.models.length > 0 ? fallback : catalog
} catch (err) {
log.warn("falling back to public Kilo embedding model catalog", { err })
return fetchKiloEmbeddingModelCatalog()
}
}
export async function warnings(): Promise<IndexingWarning[]> {
const entry = await hit().ready
entry.scope(WorkspaceContext.workspaceID)
return entry.warnings()
}
export function ready(): boolean {
@@ -402,12 +496,14 @@ export namespace KiloIndexing {
export async function available(): Promise<boolean> {
const entry = await hit().ready
entry.scope(WorkspaceContext.workspaceID)
if (!entry.initialized) return false
return entry.current().state !== "Disabled"
}
export async function search(query: string, directoryPrefix?: string): Promise<VectorStoreSearchResult[]> {
const entry = await hit().ready
entry.scope(WorkspaceContext.workspaceID)
if (!entry.initialized || entry.current().state === "Disabled" || !entry.engine) return []
return entry.engine.search(query, directoryPrefix)
}
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { IndexingStatusInfo } from "@/kilocode/indexing-event"
import { IndexingStatusInfo, IndexingWarningInfo } from "@/kilocode/indexing-event"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import {
@@ -8,12 +9,28 @@ import {
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
export { IndexingStatusInfo, IndexingStatusState } from "@/kilocode/indexing-event"
export { IndexingStatusInfo, IndexingStatusState, IndexingWarningInfo } from "@/kilocode/indexing-event"
export const KiloEmbeddingModel = Schema.Struct({
id: Schema.String,
name: Schema.String,
dimension: Schema.Int.check(Schema.isGreaterThan(0)),
scoreThreshold: Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 })),
note: Schema.optional(Schema.String),
})
export const KiloEmbeddingModelCatalog = Schema.Struct({
defaultModel: Schema.String,
models: Schema.Array(KiloEmbeddingModel),
aliases: Schema.Record(Schema.String, Schema.String),
}).annotate({ identifier: "KiloEmbeddingModelCatalog" })
const root = "/indexing"
export const IndexingPaths = {
status: `${root}/status`,
models: `${root}/models`,
warnings: `${root}/warnings`,
} as const
export const IndexingApi = HttpApi.make("indexing")
@@ -30,6 +47,28 @@ export const IndexingApi = HttpApi.make("indexing")
description: "Retrieve the current code indexing status for the active project.",
}),
),
HttpApiEndpoint.get("warnings", IndexingPaths.warnings, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(IndexingWarningInfo), "Indexing warnings"),
}).annotateMerge(
OpenApi.annotations({
identifier: "indexing.warnings",
summary: "Get indexing warnings",
description: "Retrieve code indexing warnings for the active project.",
}),
),
)
.add(
HttpApiEndpoint.get("models", IndexingPaths.models, {
query: WorkspaceRoutingQuery,
success: described(KiloEmbeddingModelCatalog, "Kilo embedding model catalog"),
}).annotateMerge(
OpenApi.annotations({
identifier: "indexing.models",
summary: "List Kilo embedding models",
description: "Retrieve the embedding models available through the active Kilo account.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -10,6 +10,7 @@ import { KilocodeKeybinds } from "@/kilocode/tui/keybinds"
import { KilocodeTuiConfig } from "@/kilocode/tui/config"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { markInstanceForDisposal } from "@/server/routes/instance/httpapi/lifecycle"
import { Effect, Option } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import {
@@ -87,6 +88,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
return result.info
}
yield* config.update(patch)
yield* markInstanceForDisposal(yield* InstanceState.context)
return yield* config.get()
})
@@ -5,12 +5,17 @@ import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
export const indexingHandlers = HttpApiBuilder.group(InstanceHttpApi, "indexing", (handlers) =>
Effect.gen(function* () {
const mod = yield* Effect.promise(() => import("@/kilocode/indexing"))
const status = Effect.fn("IndexingHttpApi.status")(function* () {
const mod = yield* Effect.promise(() => import("@/kilocode/indexing"))
const current = yield* EffectBridge.fromPromise(() => mod.KiloIndexing.current())
return current
return yield* EffectBridge.fromPromise(() => mod.KiloIndexing.current())
})
const models = Effect.fn("IndexingHttpApi.models")(function* () {
return yield* EffectBridge.fromPromise(() => mod.KiloIndexing.models())
})
const warnings = Effect.fn("IndexingHttpApi.warnings")(function* () {
return yield* EffectBridge.fromPromise(() => mod.KiloIndexing.warnings())
})
return handlers.handle("status", status)
return handlers.handle("status", status).handle("models", models).handle("warnings", warnings)
}),
)
@@ -49,6 +49,8 @@ const clear = () =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
const saveGlobal = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
const saveProject = (config: Config.Info) =>
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).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))
@@ -191,9 +193,25 @@ describe("kilocode indexing config", () => {
}
})
test("global indexing enabled applies when project indexing is disabled", async () => {
test("project indexing enabled overrides global enablement", async () => {
const input = KiloIndexing.input({ enabled: false }, { enabled: true })
expect(input.enabled).toBe(true)
expect(input.enabled).toBe(false)
expect(KiloIndexing.input(undefined, { enabled: true }).enabled).toBe(true)
expect(KiloIndexing.input({ enabled: true }, { enabled: false }).enabled).toBe(true)
})
test("creates missing project config as .kilo/kilo.jsonc", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await saveProject({ indexing: { enabled: true } })
},
})
expect(await Bun.file(path.join(tmp.path, ".kilo", "kilo.jsonc")).exists()).toBe(true)
expect(await Bun.file(path.join(tmp.path, ".kilo", "kilo.json")).exists()).toBe(false)
})
test("accepts delete sentinels for indexing model overrides", () => {
@@ -0,0 +1,196 @@
import { describe, expect, test } from "bun:test"
import { createEffect, createRoot, createSignal } from "solid-js"
import type { Config, IndexingConfig } from "@kilocode/sdk/v2"
import {
createIndexingDialogState,
currentKiloModel,
indexingInheritance,
indexingPatch,
indexingScopeConfig,
inheritedDescription,
kiloModelOptions,
loadKiloEmbeddingModels,
mergeIndexingConfig,
type IndexingScope,
} from "../../src/kilocode/components/indexing-dialog-state"
describe("indexing dialog state", () => {
test.serial("loads Kilo models directly from the public catalog", async () => {
const original = global.fetch
const calls: string[] = []
global.fetch = (async (input) => {
calls.push(String(input))
return new Response(
JSON.stringify({
defaultModel: "kilo/default",
models: [{ id: "kilo/default", name: "Default", dimension: 1024, scoreThreshold: 0.35 }],
aliases: {},
}),
{ status: 200, headers: { "content-type": "application/json" } },
)
}) as typeof global.fetch
try {
const catalog = await loadKiloEmbeddingModels()
expect(catalog.models).toHaveLength(1)
expect(catalog.defaultModel).toBe("kilo/default")
expect(calls).toHaveLength(1)
expect(new URL(calls[0] ?? "https://invalid.test").pathname).toEndWith("/embedding-models")
} finally {
global.fetch = original
}
})
test("builds stable loading, empty, and loaded model options", () => {
expect(kiloModelOptions()).toEqual([{ value: "", title: "Loading supported models..." }])
expect(kiloModelOptions({ defaultModel: "", models: [], aliases: {} })).toEqual([
{ value: "", title: "No supported models available" },
])
const catalog = {
defaultModel: "provider/default",
models: [
{ id: "provider/default", name: "Default", dimension: 1024, scoreThreshold: 0.35 },
{ id: "provider/code", name: "Code", dimension: 1536, scoreThreshold: 0.4, note: "code" },
],
aliases: { code: "provider/code" },
}
expect(kiloModelOptions(catalog)).toEqual([
{ value: "provider/default", title: "Default (1024d)" },
{ value: "provider/code", title: "Code (code, 1536d)" },
])
expect(currentKiloModel(catalog, "code")).toBe("provider/code")
expect(currentKiloModel(catalog, "missing")).toBe("provider/default")
})
test("classifies scalar and partial nested inheritance", () => {
const global: IndexingConfig = {
provider: "openai-compatible",
model: "global-model",
dimension: 1024,
"openai-compatible": { baseUrl: "https://global.test", apiKey: "global-secret" },
}
const project: IndexingConfig = {
model: null,
"openai-compatible": { baseUrl: "https://project.test" },
}
expect(indexingInheritance("project", global, project, [["provider"]])).toBe("inherited")
expect(indexingInheritance("project", global, project, [["model"]])).toBe("none")
expect(indexingInheritance("project", global, project, [["dimension"]])).toBe("inherited")
expect(
indexingInheritance("project", global, project, [
["openai-compatible", "baseUrl"],
["openai-compatible", "apiKey"],
]),
).toBe("partial")
expect(indexingInheritance("global", global, project, [["provider"]])).toBe("none")
expect(inheritedDescription("OpenAI-Compatible", "inherited")).toBe("OpenAI-Compatible (inherited)")
expect(inheritedDescription("configured", "partial")).toBe("configured (partially inherited)")
})
test("does not classify built-in defaults as inherited", () => {
expect(indexingInheritance("project", {}, {}, [["vectorStore"]])).toBe("none")
expect(indexingInheritance("project", {}, {}, [["searchMinScore"]])).toBe("none")
})
test("reveals the inherited tuning value after clearing a project override", () => {
const global: IndexingConfig = { searchMinScore: 0.4, qdrant: { url: "http://global", apiKey: "secret" } }
const project: IndexingConfig = { searchMinScore: undefined, qdrant: { url: "http://project", apiKey: undefined } }
expect(mergeIndexingConfig(global, project)).toMatchObject({
searchMinScore: 0.4,
qdrant: { url: "http://project", apiKey: "secret" },
})
expect(indexingInheritance("project", global, project, [["searchMinScore"]])).toBe("inherited")
})
test("isolates global auth config from project indexing values", () => {
const project: IndexingConfig = { kilo: { apiKey: "project-key", baseUrl: "https://project.test" } }
const inherited: IndexingConfig = { enabled: true }
const effective: Config = {
provider: { kilo: { options: { apiKey: "provider-key" } } },
indexing: project,
}
const global: Config = { provider: effective.provider, indexing: inherited }
expect(indexingScopeConfig("global", effective, global, inherited)).toEqual(global)
expect(indexingScopeConfig("project", effective, global, project)).toEqual(effective)
})
test("unsets cleared nested values without persisting undefined", () => {
expect(
indexingPatch(
{ qdrant: { url: "http://localhost:6333", apiKey: "secret" }, searchMinScore: 0.4 },
{ qdrant: { url: "http://localhost:6333", apiKey: undefined }, searchMinScore: undefined },
),
).toEqual({
indexing: { qdrant: { url: "http://localhost:6333" } },
unset: [
["indexing", "qdrant", "apiKey"],
["indexing", "searchMinScore"],
],
})
})
test("reacts when the project overlay loads", () => {
const [scope, setScope] = createSignal<IndexingScope>("project")
const [global, setGlobal] = createSignal<IndexingConfig>({ enabled: true, provider: "openai" })
const [project, setProject] = createSignal<IndexingConfig>({})
const seen: IndexingConfig[] = []
const dispose = createRoot((dispose) => {
const state = createIndexingDialogState({ scope, global, project, resolve: (config) => config })
createEffect(() => seen.push(state.config()))
return dispose
})
setProject({ enabled: false, provider: "ollama" })
setScope("global")
setGlobal({ enabled: false, provider: "gemini" })
expect(seen).toEqual([
{ enabled: true, provider: "openai" },
{ enabled: false, provider: "ollama" },
{ enabled: true, provider: "openai" },
{ enabled: false, provider: "gemini" },
])
dispose()
})
test("resolves selected-scope values and inheritance", () => {
const [scope, setScope] = createSignal<IndexingScope>("project")
const [global] = createSignal<IndexingConfig>({
enabled: true,
provider: "openai",
openai: { apiKey: "global" },
qdrant: { url: "http://global", apiKey: "global-secret" },
})
const [project, setProject] = createSignal<IndexingConfig>({ qdrant: { url: "http://project" } })
const result = createRoot((dispose) => {
const state = createIndexingDialogState({ scope, global, project, resolve: (config) => config })
return { state, dispose }
})
expect(result.state.enabled()).toBe(true)
expect(result.state.inherited([["enabled"]])).toBe("inherited")
expect(result.state.config()).toEqual({
enabled: true,
provider: "openai",
openai: { apiKey: "global" },
qdrant: { url: "http://project", apiKey: "global-secret" },
})
setProject({ enabled: false })
expect(result.state.enabled()).toBe(false)
expect(result.state.inherited([["enabled"]])).toBe("none")
setScope("global")
expect(result.state.enabled()).toBe(true)
expect(result.state.inherited([["enabled"]])).toBe("none")
result.dispose()
})
})
@@ -1,9 +1,14 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { CodeIndexManager } from "@kilocode/kilo-indexing/engine"
import { normalizeIndexingStatus } from "@kilocode/kilo-indexing/status"
import type { Config } from "../../src/config/config"
import { GlobalBus } from "../../src/bus/global"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceContext } from "../../src/control-plane/workspace-context"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { indexingWarningKey } from "../../src/kilocode/indexing-warning"
import { IndexingWorker } from "../../src/kilocode/indexing-worker-client"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
@@ -125,6 +130,47 @@ afterEach(async () => {
await disposeAllInstances()
})
describe("indexing model catalog", () => {
test("ignores a project-scoped Kilo origin", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const global = path.join(dir, "global")
const project = path.join(dir, "project")
await fs.mkdir(path.join(project, ".kilo"), { recursive: true })
await fs.mkdir(global, { recursive: true })
await Bun.write(path.join(global, "kilo.jsonc"), "{}")
await Bun.write(
path.join(project, ".kilo", "kilo.jsonc"),
JSON.stringify({ indexing: { kilo: { baseUrl: "http://127.0.0.1:4567" } } }),
)
return { global, project }
},
})
process.env["KILO_CONFIG_DIR"] = tmp.extra.global
const calls: string[] = []
globalThis.fetch = (async (input) => {
calls.push(String(input))
return new Response(
JSON.stringify({
defaultModel: "provider/model",
models: [{ id: "provider/model", name: "Provider Model", dimension: 1024, scoreThreshold: 0.4 }],
aliases: {},
}),
)
}) as typeof fetch
const response = await Server.Default().app.request("/indexing/models", {
headers: { "x-kilo-directory": tmp.extra.project },
})
const catalogs = calls.filter((url) => url.includes("embedding-models"))
expect(response.status).toBe(200)
expect(catalogs).toHaveLength(1)
expect(catalogs[0]).not.toContain("127.0.0.1:4567")
})
})
describe("indexing startup degradation", () => {
test("keeps server routes alive when indexing initialization fails", async () => {
const init = spyOn(CodeIndexManager.prototype, "initialize").mockRejectedValue(error)
@@ -161,6 +207,171 @@ describe("indexing startup degradation", () => {
}
})
test("retains and deduplicates indexing warnings for TUI replay", async () => {
const warning = {
code: "qdrant.version-incompatible" as const,
message:
"Client version 1.17.0 is incompatible with server version 1.14.1. Set checkCompatibility=false to skip version check.",
}
const events: (typeof warning)[] = []
const workspaces: (string | undefined)[] = []
let emit: IndexingWorker.Hooks["warning"] | undefined
IndexingWorker.override((_directory, _root, hooks) => {
emit = hooks.warning
return {
async init() {
hooks.log({ level: "warn", message: warning.message })
hooks.warning(warning)
hooks.warning(warning)
return {
state: "Standby",
message: "Indexing paused.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}
},
async search() {
return []
},
async dispose() {},
}
})
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
const on = (data: {
directory?: string
workspace?: string
payload?: { type?: string; properties?: typeof warning }
}) => {
if (data.directory !== tmp.path) return
if (data.payload?.type !== KiloIndexing.Warning.type) return
if (data.payload.properties) events.push(data.payload.properties)
workspaces.push(data.workspace)
}
GlobalBus.on("event", on)
try {
const workspace = WorkspaceID.make("wrk_indexing_warning")
await WorkspaceContext.provide({
workspaceID: workspace,
fn: () =>
WithInstance.provide({
directory: tmp.path,
fn: () => KiloIndexing.current(),
}),
})
const app = Server.Default().app
const list = await (async () => {
for (const _ of Array.from({ length: 100 })) {
const response = await app.request("/indexing/warnings", {
headers: {
"x-kilo-directory": tmp.path,
},
})
expect(response.status).toBe(200)
const body = (await response.json()) as (typeof warning)[]
if (body.length > 0) return body
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error("indexing warning was not retained")
})()
for (const _ of Array.from({ length: 100 })) {
if (events.length > 0 && events.length === workspaces.length) break
await new Promise((resolve) => setTimeout(resolve, 10))
}
expect(list).toEqual([warning])
expect(events.length).toBeGreaterThan(0)
expect(events.every((item) => indexingWarningKey(item) === indexingWarningKey(warning))).toBe(true)
expect(workspaces.every((item) => item === undefined || item === workspace)).toBe(true)
const offset = events.length
const second = WorkspaceID.make("wrk_indexing_warning_second")
await WorkspaceContext.provide({
workspaceID: second,
fn: () =>
WithInstance.provide({
directory: tmp.path,
fn: () => KiloIndexing.warnings(),
}),
})
const next = { ...warning, message: `${warning.message} Again.` }
emit?.(next)
for (const _ of Array.from({ length: 100 })) {
if (workspaces.slice(offset).includes(workspace) && workspaces.slice(offset).includes(second)) break
await new Promise((resolve) => setTimeout(resolve, 10))
}
const scoped = workspaces.slice(offset)
expect(events.slice(offset).every((item) => indexingWarningKey(item) === indexingWarningKey(next))).toBe(true)
expect(scoped.includes(workspace)).toBe(true)
expect(scoped.includes(second)).toBe(true)
expect(scoped.every((item) => item === undefined || item === workspace || item === second)).toBe(true)
} finally {
GlobalBus.off("event", on)
}
})
test("coalesces burst indexing progress publications", async () => {
const complete: KiloIndexing.Status = {
state: "Complete",
message: "Index up-to-date.",
processedFiles: 50,
totalFiles: 50,
percent: 100,
}
IndexingWorker.override((_directory, _root, hooks) => ({
async init() {
for (const processedFiles of Array.from({ length: 50 }, (_, index) => index + 1)) {
hooks.status({
state: "In Progress",
message: `Indexed ${processedFiles} / 50 files.`,
processedFiles,
totalFiles: 50,
percent: processedFiles * 2,
})
}
return complete
},
async search() {
return []
},
async dispose() {},
}))
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
const events: KiloIndexing.Status[] = []
const on = (data: {
directory?: string
payload?: { type?: string; properties?: { status?: KiloIndexing.Status } }
}) => {
if (data.directory !== tmp.path) return
if (data.payload?.type !== KiloIndexing.Event.type) return
if (data.payload.properties?.status) events.push(data.payload.properties.status)
}
GlobalBus.on("event", on)
try {
await WithInstance.provide({
directory: tmp.path,
fn: async () => expect(await wait(() => KiloIndexing.current(), "Complete")).toEqual(complete),
})
await new Promise((resolve) => setTimeout(resolve, 150))
const progress = events.filter((status) => status.state === "In Progress")
expect(progress.length).toBeLessThanOrEqual(2)
expect(progress.filter((status) => status.processedFiles > 0)).toHaveLength(1)
expect(events.filter((status) => status.state === "Complete")).toEqual([complete])
} finally {
GlobalBus.off("event", on)
}
})
test("reports routes as in progress while initialization is in flight", async () => {
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
@@ -419,7 +630,7 @@ describe("indexing startup degradation", () => {
}
})
test("keeps configured dimensions for supported Kilo models", async () => {
test("uses hosted dimensions for supported Kilo models", async () => {
global.fetch = (() =>
Promise.resolve(
new Response(
@@ -461,7 +672,7 @@ describe("indexing startup degradation", () => {
expect(init.mock.calls[0]?.[0]).toMatchObject({
embedderProvider: "kilo",
modelId: "openai/text-embedding-3-small",
modelDimension: 256,
modelDimension: 1536,
})
},
})
@@ -472,7 +683,7 @@ describe("indexing startup degradation", () => {
}
})
test("does not execute stored Kilo models when the hosted catalog is unavailable", async () => {
test("leaves Kilo model metadata unset when the hosted catalog is unavailable", async () => {
global.fetch = (() => Promise.resolve(new Response(undefined, { status: 500 }))) as unknown as typeof global.fetch
const init = spyOn(CodeIndexManager.prototype, "initialize").mockResolvedValue({ requiresRestart: false })
const key = process.env.KILO_API_KEY
@@ -486,9 +697,12 @@ describe("indexing startup degradation", () => {
directory: tmp.path,
fn: async () => {
await called(init)
expect(init.mock.calls[0]?.[0]).toMatchObject({ embedderProvider: "kilo" })
expect(init.mock.calls[0]?.[0].modelId).toBeUndefined()
expect(init.mock.calls[0]?.[0].modelDimension).toBeUndefined()
expect(init.mock.calls[0]?.[0]).toMatchObject({
embedderProvider: "kilo",
modelId: undefined,
modelDimension: undefined,
searchMinScore: undefined,
})
},
})
} finally {
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { indexingErrorMessage, indexingWarningKey, parseQdrantWarning } from "../../src/kilocode/indexing-warning"
describe("parseQdrantWarning", () => {
test("classifies incompatible Qdrant versions", () => {
const message =
"Client version 1.17.0 is incompatible with server version 1.14.1. Major versions should match and minor version difference must not exceed 1. Set checkCompatibility=false to skip version check."
expect(parseQdrantWarning(message)).toEqual({
code: "qdrant.version-incompatible",
message:
"Client version 1.17.0 is incompatible with server version 1.14.1. Set checkCompatibility=false to skip version check.",
})
})
test("classifies unavailable Qdrant versions", () => {
const message =
"Failed to obtain server version. Unable to check client-server compatibility. Set checkCompatibility=false to skip version check."
expect(parseQdrantWarning(message)).toEqual({
code: "qdrant.version-unavailable",
message,
})
})
test("ignores unrelated warnings and non-string values", () => {
expect(parseQdrantWarning("Api key is used with unsecure connection.")).toBeUndefined()
expect(parseQdrantWarning(new Error("warning"))).toBeUndefined()
})
})
test("classifies error statuses for TUI notifications", () => {
const status = { processedFiles: 0, totalFiles: 0, percent: 0 }
expect(indexingErrorMessage({ ...status, state: "Error", message: "Unable to connect" })).toBe("Unable to connect")
expect(indexingErrorMessage({ ...status, state: "Complete", message: "Index up-to-date." })).toBeUndefined()
})
test("indexingWarningKey includes the warning code and message", () => {
expect(indexingWarningKey({ code: "qdrant.version-unavailable", message: "warning" })).toBe(
"qdrant.version-unavailable\u0000warning",
)
})
@@ -8,6 +8,8 @@ test("runs indexing engine requests in its worker", async () => {
const engine = IndexingWorker.create(tmp.path, tmp.path, {
status() {},
telemetry() {},
warning() {},
log() {},
failure(err) {
failures.push(err)
},
@@ -22,3 +24,59 @@ test("runs indexing engine requests in its worker", async () => {
expect(failures).toEqual([])
})
test("reuses enabled workers across provider initialization errors", async () => {
await using tmp = await tmpdir()
const drivers = new Set<IndexingWorker.Driver>()
for (const _ of Array.from({ length: 3 })) {
const failures: unknown[] = []
const warnings: string[] = []
const engine = IndexingWorker.create(tmp.path, tmp.path, {
status() {},
telemetry() {},
warning(item) {
warnings.push(item.code)
},
log() {},
failure(err) {
failures.push(err)
},
})
drivers.add(engine)
const err = await engine
.init({
enabled: true,
embedderProvider: "ollama",
ollamaBaseUrl: "http://127.0.0.1:1",
modelId: "nomic-embed-text",
modelDimension: 768,
vectorStoreProvider: "qdrant",
qdrantUrl: "http://127.0.0.1:1",
})
.then(
() => undefined,
(err) => err,
)
await engine.dispose()
expect(err).toBeInstanceOf(Error)
expect(warnings).toContain("qdrant.version-unavailable")
expect(failures).toEqual([])
}
const engine = IndexingWorker.create(tmp.path, tmp.path, {
status() {},
telemetry() {},
warning() {},
log() {},
failure() {},
})
const status = await engine.init({ enabled: false, embedderProvider: "openai" })
await engine.dispose()
expect(status.state).toBe("Disabled")
expect(drivers.has(engine)).toBe(true)
expect(drivers.size).toBe(1)
})
@@ -54,14 +54,14 @@ async function writeConfig(dir: string, config: unknown) {
await Filesystem.write(path.join(dir, "kilo.json"), JSON.stringify(config, null, 2))
}
test("project config update creates .kilo/kilo.json and reloads it", async () => {
test("project config update creates .kilo/kilo.jsonc and reloads it", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await save({ model: "updated/model" } as any)
const written = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, ".kilo", "kilo.json"))
const written = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, ".kilo", "kilo.jsonc"))
expect(written.model).toBe("updated/model")
const loaded = await load()
@@ -77,7 +77,7 @@ test("project config update skips empty delete-only writes when no config exists
fn: async () => {
await save({ provider: { missing: null } } as any)
await expect(fs.access(path.join(tmp.path, ".kilo", "kilo.json"))).rejects.toThrow()
await expect(fs.access(path.join(tmp.path, ".kilo", "kilo.jsonc"))).rejects.toThrow()
},
})
})
@@ -108,6 +108,95 @@ describe("config overlay routes", () => {
})
})
test.serial("marks global indexing values inherited in project scope", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await config(global.path, {
indexing: {
enabled: true,
provider: "ollama",
ollama: { baseUrl: "http://localhost:11434" },
},
})
await invalidate()
const body = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
expect(body.fields["indexing.enabled"]).toMatchObject({ source: "global", inherited: true, value: true })
expect(body.fields["indexing.provider"]).toMatchObject({ source: "global", inherited: true, value: "ollama" })
expect(body.fields["indexing.ollama.baseUrl"]).toMatchObject({
source: "global",
inherited: true,
value: "http://localhost:11434",
})
})
test.serial("excludes project indexing values from global scope", async () => {
await using project = await tmpdir()
const global: Config.Info = {
indexing: {
enabled: true,
provider: "openai",
openai: { apiKey: "global-secret" },
},
}
const local: Config.Info = {
indexing: {
enabled: false,
provider: "ollama",
ollama: { baseUrl: "http://project:11434" },
},
}
await config(project.path, local)
const body = await KilocodeConfigOverlay.resolve({
directory: project.path,
scope: "global",
effective: local,
global,
sources: [],
})
expect(body.fields["indexing.enabled"]).toMatchObject({ source: "global", value: true })
expect(body.fields["indexing.provider"]).toMatchObject({ source: "global", value: "openai" })
expect(body.fields["indexing.openai.apiKey"]).toMatchObject({ source: "global", value: "global-secret" })
expect(body.fields["indexing.ollama.baseUrl"]).toMatchObject({ source: "default" })
expect(body.fields["indexing.ollama.baseUrl"].value).toBeUndefined()
})
test.serial("writes project indexing overrides to .kilo/kilo.jsonc", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await config(global.path, { indexing: { enabled: true, provider: "openai" } })
await invalidate()
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scope: "project",
set: { indexing: { enabled: false, provider: "ollama", ollama: { baseUrl: "http://127.0.0.1:11434" } } },
}),
}),
)
const file = path.join(project.path, ".kilo", "kilo.jsonc")
const saved = (await Bun.file(file).json()) as { indexing: Record<string, unknown> }
const body = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
expect(await Bun.file(path.join(project.path, ".kilo", "kilo.json")).exists()).toBe(false)
expect(saved.indexing).toEqual({
enabled: false,
provider: "ollama",
ollama: { baseUrl: "http://127.0.0.1:11434" },
})
expect(body.fields["indexing.enabled"]).toMatchObject({ source: "project", value: false })
expect(body.fields["indexing.provider"]).toMatchObject({ source: "project", value: "ollama" })
})
test.serial("removes local scalar override and falls back to global", async () => {
await using global = await tmpdir()
await using project = await tmpdir({ config: { model: "kilo/project-model", username: "alice" } })
@@ -150,7 +239,7 @@ describe("config overlay routes", () => {
}),
)
const saved = (await Bun.file(path.join(project.path, ".kilo", "kilo.json")).json()) as {
const saved = (await Bun.file(path.join(project.path, ".kilo", "kilo.jsonc")).json()) as {
mcp: Record<string, unknown>
}
expect(Object.keys(saved.mcp)).toEqual(["local"])
@@ -173,7 +262,7 @@ describe("config overlay routes", () => {
}),
)
const saved = (await Bun.file(path.join(project.path, ".kilo", "kilo.json")).json()) as {
const saved = (await Bun.file(path.join(project.path, ".kilo", "kilo.jsonc")).json()) as {
mcp: Record<string, unknown>
}
expect(saved.mcp).toEqual({ shared: { enabled: false } })
@@ -254,6 +343,7 @@ describe("config overlay routes", () => {
;(Global.Path as { config: string }).config = global.path
await config(global.path, { permission: { edit: "ask" } })
await invalidate()
await disposeAllInstances()
const target = app(value)
const before = await json<Agent[]>(await request(target, project.path, "/agent"))
@@ -129,6 +129,8 @@ export const kiloScenarios: Scenario[] = [
}))
.json(200, (body) => check(body === null, "missing worktree diff detail should return null")),
http.protected.get("/indexing/status", "indexing.status").json(200, object),
http.protected.get("/indexing/models", "indexing.models").json(200, object),
http.protected.get("/indexing/warnings", "indexing.warnings").json(200, array),
http.protected.get("/kilo/profile", "kilo.profile").probe({ path: "/path" }).status(401),
http.protected.get("/kilo/modes", "kilo.modes").json(200, (body) => {
object(body)
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test"
import { Result, Schema as EffectSchema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { AgentBuilderPaths } from "../../../src/kilocode/server/httpapi/groups/agent-builder"
import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process"
import { ConfigConsolePaths } from "../../../src/kilocode/server/httpapi/groups/config-console"
import { IndexingPaths } from "../../../src/kilocode/server/httpapi/groups/indexing"
import { IndexingPaths, KiloEmbeddingModel } from "../../../src/kilocode/server/httpapi/groups/indexing"
import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway"
import { NetworkPaths } from "../../../src/kilocode/server/httpapi/groups/network"
import { TelemetryPaths } from "../../../src/kilocode/server/httpapi/groups/telemetry"
@@ -40,6 +41,25 @@ describe("Kilo PublicApi OpenAPI contract", () => {
expect(spec.info.description).toBe("kilo api")
})
test("constrains embedding model metadata", () => {
const accepts = (dimension: number, scoreThreshold: number) =>
Result.isSuccess(
EffectSchema.decodeUnknownResult(KiloEmbeddingModel)({
id: "provider/model",
name: "Model",
dimension,
scoreThreshold,
}),
)
expect(accepts(1, 0)).toBe(true)
expect(accepts(1024, 1)).toBe(true)
expect(accepts(0, 0.5)).toBe(false)
expect(accepts(1.5, 0.5)).toBe(false)
expect(accepts(1024, -0.1)).toBe(false)
expect(accepts(1024, 1.1)).toBe(false)
})
test("constrains agent builder route ids", () => {
const spec = OpenApi.fromApi(PublicApi)
const save = AgentBuilderPaths.save.replace(":id", "{id}")
@@ -83,6 +103,7 @@ describe("Kilo PublicApi OpenAPI contract", () => {
{ method: "get", path: ConfigConsolePaths.overlay },
{ method: "patch", path: ConfigConsolePaths.overlay },
{ method: "get", path: IndexingPaths.status },
{ method: "get", path: IndexingPaths.models },
] satisfies Array<{ method: Method; path: string }>
for (const route of routes) {
+62
View File
@@ -85,7 +85,9 @@ import type {
GlobalHealthResponses,
GlobalUpgradeErrors,
GlobalUpgradeResponses,
IndexingModelsResponses,
IndexingStatusResponses,
IndexingWarningsResponses,
InstanceDisposeResponses,
KiloAudioTranscriptionsErrors,
KiloAudioTranscriptionsResponses,
@@ -6187,6 +6189,66 @@ export class Indexing extends HeyApiClient {
...params,
})
}
/**
* Get indexing warnings
*
* Retrieve code indexing warnings for the active project.
*/
public warnings<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<IndexingWarningsResponses, unknown, ThrowOnError>({
url: "/indexing/warnings",
...options,
...params,
})
}
/**
* List Kilo embedding models
*
* Retrieve the embedding models available through the active Kilo account.
*/
public models<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<IndexingModelsResponses, unknown, ThrowOnError>({
url: "/indexing/models",
...options,
...params,
})
}
}
export class Audio extends HeyApiClient {
+176 -111
View File
@@ -5,10 +5,17 @@ export type ClientOptions = {
}
export type Event =
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventIndexingWarning
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
@@ -16,10 +23,6 @@ export type Event =
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -44,7 +47,6 @@ export type Event =
| EventSessionCompacted
| EventCommandExecuted
| EventProjectUpdated
| EventKilocodeAgentManagerStart
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -58,7 +60,6 @@ export type Event =
| EventPtyDeleted
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventIndexingStatus
export type OAuth = {
type: "oauth"
@@ -85,6 +86,76 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type IndexingWarning = {
code: "qdrant.version-incompatible" | "qdrant.version-unavailable"
message: string
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
@@ -147,61 +218,6 @@ export type QuestionRejected = {
requestID: string
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type SessionNetworkWait = {
id: string
sessionID: string
@@ -416,16 +432,6 @@ export type Pty = {
sessionID?: string | null
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type OutputFormatText = {
type: "text"
}
@@ -859,10 +865,17 @@ export type GlobalEvent = {
project?: string
workspace?: string
payload:
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventIndexingWarning
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventQuestionAsked
@@ -870,10 +883,6 @@ export type GlobalEvent = {
| EventQuestionRejected
| EventLspClientDiagnostics
| EventLspUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -898,7 +907,6 @@ export type GlobalEvent = {
| EventSessionCompacted
| EventCommandExecuted
| EventProjectUpdated
| EventKilocodeAgentManagerStart
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -912,7 +920,6 @@ export type GlobalEvent = {
| EventPtyDeleted
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventIndexingStatus
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
@@ -2177,6 +2184,20 @@ export type TuiKeybindListResponse = {
keybinds: Array<TuiKeybindInfo>
}
export type KiloEmbeddingModelCatalog = {
defaultModel: string
models: Array<{
id: string
name: string
dimension: number
scoreThreshold: number
note?: string
}>
aliases: {
[key: string]: string
}
}
export type EffectHttpApiErrorUnauthorized = {
_tag: "Unauthorized"
}
@@ -2723,14 +2744,6 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
@@ -2755,6 +2768,44 @@ export type EventGlobalConfigUpdated = {
}
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type EventIndexingWarning = {
id: string
type: "indexing.warning"
properties: IndexingWarning
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventFileEdited = {
id: string
type: "file.edited"
@@ -3032,22 +3083,6 @@ export type EventProjectUpdated = {
properties: Project
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventVcsBranchUpdated = {
id: string
type: "vcs.branch.updated"
@@ -3156,14 +3191,6 @@ export type EventInstallationUpdateAvailable = {
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type PromptSource = {
start: number
end: number
@@ -8153,6 +8180,44 @@ export type IndexingStatusResponses = {
export type IndexingStatusResponse = IndexingStatusResponses[keyof IndexingStatusResponses]
export type IndexingWarningsData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/indexing/warnings"
}
export type IndexingWarningsResponses = {
/**
* Indexing warnings
*/
200: Array<IndexingWarning>
}
export type IndexingWarningsResponse = IndexingWarningsResponses[keyof IndexingWarningsResponses]
export type IndexingModelsData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/indexing/models"
}
export type IndexingModelsResponses = {
/**
* Kilo embedding model catalog
*/
200: KiloEmbeddingModelCatalog
}
export type IndexingModelsResponse = IndexingModelsResponses[keyof IndexingModelsResponses]
export type KiloProfileData = {
body?: never
path?: never
+482 -309
View File
@@ -11163,6 +11163,98 @@
]
}
},
"/indexing/warnings": {
"get": {
"tags": ["indexing"],
"operationId": "indexing.warnings",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Indexing warnings",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IndexingWarning"
},
"description": "Indexing warnings"
}
}
}
}
},
"description": "Retrieve code indexing warnings for the active project.",
"summary": "Get indexing warnings",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.indexing.warnings({\n ...\n})"
}
]
}
},
"/indexing/models": {
"get": {
"tags": ["indexing"],
"operationId": "indexing.models",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Kilo embedding model catalog",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KiloEmbeddingModelCatalog"
}
}
}
}
},
"description": "Retrieve the embedding models available through the active Kilo account.",
"summary": "List Kilo embedding models",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.indexing.models({\n ...\n})"
}
]
}
},
"/kilo/profile": {
"get": {
"tags": ["kilo"],
@@ -14344,9 +14436,6 @@
"schemas": {
"Event": {
"anyOf": [
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
@@ -14356,6 +14445,30 @@
{
"$ref": "#/components/schemas/EventGlobalConfigUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/EventTuiToastShow1"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
},
{
"$ref": "#/components/schemas/EventIndexingWarning"
},
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventFileEdited"
},
@@ -14377,18 +14490,6 @@
{
"$ref": "#/components/schemas/EventLspUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/EventTuiToastShow1"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventMcpToolsChanged"
},
@@ -14461,9 +14562,6 @@
{
"$ref": "#/components/schemas/EventProjectUpdated"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventVcsBranchUpdated"
},
@@ -14502,9 +14600,6 @@
},
{
"$ref": "#/components/schemas/EventInstallationUpdate-available"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
}
]
},
@@ -14585,6 +14680,184 @@
}
]
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.prompt.append"]
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.command.execute"]
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.toast.show"]
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["message", "variant"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.session.select"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses",
"description": "Session ID to navigate to"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"IndexingStatusState": {
"type": "string",
"enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"]
},
"IndexingStatus": {
"type": "object",
"properties": {
"state": {
"$ref": "#/components/schemas/IndexingStatusState"
},
"message": {
"type": "string"
},
"processedFiles": {
"type": "integer",
"minimum": 0
},
"totalFiles": {
"type": "integer",
"minimum": 0
},
"percent": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
},
"required": ["state", "message", "processedFiles", "totalFiles", "percent"],
"additionalProperties": false
},
"IndexingWarning": {
"type": "object",
"properties": {
"code": {
"type": "string",
"enum": ["qdrant.version-incompatible", "qdrant.version-unavailable"]
},
"message": {
"type": "string"
}
},
"required": ["code", "message"],
"additionalProperties": false
},
"QuestionOption": {
"type": "object",
"properties": {
@@ -14727,140 +15000,6 @@
"required": ["sessionID", "requestID"],
"additionalProperties": false
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.prompt.append"]
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.command.execute"]
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.toast.show"]
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["message", "variant"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["tui.session.select"]
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses",
"description": "Session ID to navigate to"
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"SessionNetworkWait": {
"type": "object",
"properties": {
@@ -15490,36 +15629,6 @@
"required": ["id", "title", "command", "args", "cwd", "status", "pid"],
"additionalProperties": false
},
"IndexingStatusState": {
"type": "string",
"enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"]
},
"IndexingStatus": {
"type": "object",
"properties": {
"state": {
"$ref": "#/components/schemas/IndexingStatusState"
},
"message": {
"type": "string"
},
"processedFiles": {
"type": "integer",
"minimum": 0
},
"totalFiles": {
"type": "integer",
"minimum": 0
},
"percent": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
},
"required": ["state", "message", "processedFiles", "totalFiles", "percent"],
"additionalProperties": false
},
"OutputFormatText": {
"type": "object",
"properties": {
@@ -16871,9 +16980,6 @@
},
"payload": {
"anyOf": [
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventServerConnected"
},
@@ -16883,6 +16989,30 @@
{
"$ref": "#/components/schemas/EventGlobalConfigUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
},
{
"$ref": "#/components/schemas/EventIndexingWarning"
},
{
"$ref": "#/components/schemas/EventServerInstanceDisposed"
},
{
"$ref": "#/components/schemas/EventFileEdited"
},
@@ -16904,18 +17034,6 @@
{
"$ref": "#/components/schemas/EventLspUpdated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
},
{
"$ref": "#/components/schemas/Event.tui.command.execute"
},
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.tui.session.select"
},
{
"$ref": "#/components/schemas/EventMcpToolsChanged"
},
@@ -16988,9 +17106,6 @@
{
"$ref": "#/components/schemas/EventProjectUpdated"
},
{
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
},
{
"$ref": "#/components/schemas/EventVcsBranchUpdated"
},
@@ -17030,9 +17145,6 @@
{
"$ref": "#/components/schemas/EventInstallationUpdate-available"
},
{
"$ref": "#/components/schemas/EventIndexingStatus"
},
{
"$ref": "#/components/schemas/SyncEventMessageUpdated"
},
@@ -20744,6 +20856,50 @@
"required": ["keybinds"],
"additionalProperties": false
},
"KiloEmbeddingModelCatalog": {
"type": "object",
"properties": {
"defaultModel": {
"type": "string"
},
"models": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"dimension": {
"type": "integer",
"exclusiveMinimum": 0
},
"scoreThreshold": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"note": {
"type": "string"
}
},
"required": ["id", "name", "dimension", "scoreThreshold"],
"additionalProperties": false
}
},
"aliases": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["defaultModel", "models", "aliases"],
"additionalProperties": false
},
"effect_HttpApiError_Unauthorized": {
"type": "object",
"properties": {
@@ -22636,30 +22792,6 @@
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"EventServerInstanceDisposed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["server.instance.disposed"]
},
"properties": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventServerConnected": {
"type": "object",
"properties": {
@@ -22714,6 +22846,126 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventKilocodeAgent_managerStart": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["kilocode.agent_manager.start"]
},
"properties": {
"type": "object",
"properties": {
"requestID": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"mode": {
"type": "string",
"enum": ["worktree", "local"]
},
"versions": {
"type": "boolean"
},
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"name": {
"type": "string"
},
"branchName": {
"type": "string"
}
},
"additionalProperties": false
},
"minItems": 1,
"maxItems": 20
}
},
"required": ["requestID", "sessionID", "mode", "tasks"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventIndexingStatus": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["indexing.status"]
},
"properties": {
"type": "object",
"properties": {
"status": {
"$ref": "#/components/schemas/IndexingStatus"
}
},
"required": ["status"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventIndexingWarning": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["indexing.warning"]
},
"properties": {
"$ref": "#/components/schemas/IndexingWarning"
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventServerInstanceDisposed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["server.instance.disposed"]
},
"properties": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventFileEdited": {
"type": "object",
"properties": {
@@ -23553,61 +23805,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventKilocodeAgent_managerStart": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["kilocode.agent_manager.start"]
},
"properties": {
"type": "object",
"properties": {
"requestID": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"mode": {
"type": "string",
"enum": ["worktree", "local"]
},
"versions": {
"type": "boolean"
},
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"name": {
"type": "string"
},
"branchName": {
"type": "string"
}
},
"additionalProperties": false
},
"minItems": 1,
"maxItems": 20
}
},
"required": ["requestID", "sessionID", "mode", "tasks"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventVcsBranchUpdated": {
"type": "object",
"properties": {
@@ -23936,30 +24133,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventIndexingStatus": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["indexing.status"]
},
"properties": {
"type": "object",
"properties": {
"status": {
"$ref": "#/components/schemas/IndexingStatus"
}
},
"required": ["status"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"PromptSource": {
"type": "object",
"properties": {