feat(vscode): add 'Not set' option to autocomplete model picker

Aligns the autocomplete model selector with the small/subagent/per-mode
model selectors by exposing a 'Not set (use server default)' option.
Users who pick it follow future default-model changes automatically;
users who explicitly pin a model keep their selection.

Also runs a one-time migration that clears
`kilo-code.new.autocomplete.{provider,model}` when they exactly equal
the current `DEFAULT_AUTOCOMPLETE_MODEL` (Codestral via Kilo Gateway).
Many users have these stored only because it was the only thing visible
in the dropdown; without this they would stay pinned to the current
default and miss future improvements.
This commit is contained in:
kiloconnect[bot]
2026-05-28 10:10:56 +00:00
committed by Mark IJbema
parent 17e0b2d6b5
commit f18a452082
12 changed files with 306 additions and 47 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add a "Not set (use server default)" option to the autocomplete model picker so users can follow the recommended default automatically. Users who previously had the default model pinned only because it was the only thing visible in the dropdown are migrated to "Not set" once.
+2 -2
View File
@@ -850,13 +850,13 @@
"mercury-next-edit"
],
"enumDescriptions": [
"Codestral via Kilo Gateway (default)",
"Codestral via Kilo Gateway",
"Mercury Edit 2 via Kilo Gateway",
"Codestral via your connected Mistral provider API key",
"Mercury Edit 2 (FIM) via your connected Inception provider API key",
"Mercury Next Edit (multi-line edit predictions with jump-to-edit UX) via your connected Inception provider API key"
],
"description": "Model to use for inline autocomplete suggestions"
"description": "Model to use for inline autocomplete suggestions. If unset, the recommended default is used."
},
"kilo-code.new.autocomplete.provider": {
"type": "string",
+5 -1
View File
@@ -3030,7 +3030,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const { section, leaf } = buildSettingPath(key)
if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return
const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`)
await config.update(leaf, value, vscode.ConfigurationTarget.Global)
// Normalize a webview-side clear to `undefined` so VS Code removes the
// key from settings.json rather than persisting a literal `null`. This
// lets the runtime fall back to the resolved default.
const next = value === null ? undefined : value
await config.update(leaf, next, vscode.ConfigurationTarget.Global)
}
/**
@@ -33,45 +33,31 @@ describe("autocomplete settings", () => {
expect(buildAutocompleteSettingsMessage().settings.model).toBe("mercury-edit-2")
})
it("does not infer direct provider from a bare model name when provider is unset", async () => {
// Safety: a legacy `model` setting alone must never silently route to a
// direct BYOK provider. Direct providers require an explicit `provider`.
it("passes a bare model setting through unchanged so the webview can render it as-is", async () => {
// The webview now distinguishes "no explicit setting" (null) from "user
// picked something." We don't try to interpret a bare `model` here —
// resolving it to a default happens at the runtime layer, not in the
// settings message.
state.set("model", "mercury-edit-2")
const { buildAutocompleteSettingsMessage } = await import("../settings")
expect(buildAutocompleteSettingsMessage().settings.provider).toBe("kilo")
expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508")
expect(buildAutocompleteSettingsMessage().settings.provider).toBeNull()
expect(buildAutocompleteSettingsMessage().settings.model).toBe("mercury-edit-2")
})
it("defaults to codestral when no model is set", async () => {
it("returns null for both keys when nothing is set (let the webview render 'Not set')", async () => {
const { buildAutocompleteSettingsMessage } = await import("../settings")
expect(buildAutocompleteSettingsMessage().settings.provider).toBe("kilo")
expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508")
expect(buildAutocompleteSettingsMessage().settings.provider).toBeNull()
expect(buildAutocompleteSettingsMessage().settings.model).toBeNull()
})
it("defaults to codestral when stored model is no longer supported", async () => {
it("preserves an unsupported stored model verbatim — runtime fallback handles resolution", async () => {
state.set("model", "some/removed-model")
const { buildAutocompleteSettingsMessage } = await import("../settings")
expect(buildAutocompleteSettingsMessage().settings.provider).toBe("kilo")
expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508")
})
it("maps legacy inception/mercury-edit to Kilo Gateway Mercury", async () => {
state.set("model", "inception/mercury-edit")
const { buildAutocompleteSettingsMessage } = await import("../settings")
expect(buildAutocompleteSettingsMessage().settings.provider).toBe("kilo")
expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit-2")
})
it("maps legacy inception/mercury-edit-2 to Kilo Gateway Mercury", async () => {
state.set("model", "inception/mercury-edit-2")
const { buildAutocompleteSettingsMessage } = await import("../settings")
expect(buildAutocompleteSettingsMessage().settings.provider).toBe("kilo")
expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit-2")
expect(buildAutocompleteSettingsMessage().settings.provider).toBeNull()
expect(buildAutocompleteSettingsMessage().settings.model).toBe("some/removed-model")
})
it("validates supported model updates", async () => {
@@ -81,12 +67,20 @@ describe("autocomplete settings", () => {
expect(validAutocompleteSetting("provider", "inception")).toBe(true)
})
it("accepts null/undefined for provider and model so users can clear the setting", async () => {
const { validAutocompleteSetting } = await import("../settings")
expect(validAutocompleteSetting("provider", null)).toBe(true)
expect(validAutocompleteSetting("provider", undefined)).toBe(true)
expect(validAutocompleteSetting("model", null)).toBe(true)
expect(validAutocompleteSetting("model", undefined)).toBe(true)
})
it("rejects unsupported autocomplete updates", async () => {
const { validAutocompleteSetting } = await import("../settings")
expect(validAutocompleteSetting("model", "other/model")).toBe(false)
expect(validAutocompleteSetting("provider", undefined)).toBe(false)
expect(validAutocompleteSetting("provider", null)).toBe(false)
expect(validAutocompleteSetting("provider", "openrouter")).toBe(false)
})
it("rejects non-boolean toggle updates", async () => {
@@ -1,6 +1,7 @@
import * as vscode from "vscode"
import { AutocompleteServiceManager } from "./AutocompleteServiceManager"
import { ensureBackendForAutocomplete } from "./ensure-backend"
import { migrateDefaultAutocompleteSettings } from "./migrate-default"
import { nesLog } from "./next-edit/log"
import { INLINE_COMPLETION_ACCEPTED_COMMAND as NEXT_EDIT_ACCEPTED_COMMAND } from "./next-edit/NextEditInlineCompletionProvider"
import { chainNextPrediction } from "./next-edit/NextEditSuggestionManager"
@@ -10,6 +11,10 @@ export const registerAutocompleteProvider = (
context: vscode.ExtensionContext,
connectionService: KiloConnectionService,
) => {
// Run before constructing the manager so its initial readSettings() sees
// the cleared state and behaves as "Not set."
void migrateDefaultAutocompleteSettings(context)
const autocompleteManager = new AutocompleteServiceManager(context, connectionService)
context.subscriptions.push(autocompleteManager)
@@ -0,0 +1,33 @@
import * as vscode from "vscode"
import { DEFAULT_AUTOCOMPLETE_MODEL } from "../../shared/autocomplete-models"
const FLAG = "kilo.autocomplete.defaultClearMigrationV1"
/**
* One-time migration: clear `kilo-code.new.autocomplete.{provider,model}` when
* they exactly match the current `DEFAULT_AUTOCOMPLETE_MODEL`. Many users have
* the default explicitly stored only because it was the only thing visible in
* the dropdown — leaving it pinned would block them from picking up future
* default changes (e.g. a switch to Mercury Next Edit) silently. After the
* migration runs they show up as "Not set" and follow the resolved default.
*
* Users who picked a different model are untouched. The migration runs once
* per machine and is gated on a globalState flag.
*/
export async function migrateDefaultAutocompleteSettings(context: vscode.ExtensionContext): Promise<void> {
if (context.globalState.get<boolean>(FLAG)) return
const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete")
const provider = config.get<string>("provider")
const model = config.get<string>("model")
const matchesDefault =
provider === DEFAULT_AUTOCOMPLETE_MODEL.providerID && model === DEFAULT_AUTOCOMPLETE_MODEL.modelID
if (matchesDefault) {
await config.update("provider", undefined, vscode.ConfigurationTarget.Global)
await config.update("model", undefined, vscode.ConfigurationTarget.Global)
}
await context.globalState.update(FLAG, true)
}
@@ -1,9 +1,5 @@
import * as vscode from "vscode"
import {
getAutocompleteModel,
validAutocompleteModel,
validAutocompleteProvider,
} from "../../shared/autocomplete-models"
import { validAutocompleteModel, validAutocompleteProvider } from "../../shared/autocomplete-models"
type Message = {
type: string
@@ -22,15 +18,17 @@ export async function routeAutocompleteMessage(message: Message, post: Post): Pr
export function buildAutocompleteSettingsMessage() {
const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete")
const info = getAutocompleteModel(config.get<string>("provider"), config.get<string>("model"))
// Pass through provider/model as-is (null when unset) so the webview can
// distinguish "user hasn't picked" from "user picked the current default."
// The runtime resolves null → DEFAULT_AUTOCOMPLETE_MODEL via getAutocompleteModel().
return {
type: "autocompleteSettingsLoaded" as const,
settings: {
enableAutoTrigger: config.get<boolean>("enableAutoTrigger", true),
enableSmartInlineTaskKeybinding: config.get<boolean>("enableSmartInlineTaskKeybinding", false),
enableChatAutocomplete: config.get<boolean>("enableChatAutocomplete", false),
provider: info.providerID,
model: info.modelID,
provider: config.get<string>("provider") ?? null,
model: config.get<string>("model") ?? null,
},
}
}
@@ -46,10 +44,13 @@ export function watchAutocompleteConfig(post: Post): vscode.Disposable {
export function validAutocompleteSetting(key: string, value: unknown) {
if (key === "model") {
// Allow clearing back to the server-side default.
if (value === null || value === undefined) return true
return validAutocompleteModel(value)
}
if (key === "provider") {
if (value === null || value === undefined) return true
return validAutocompleteProvider(value)
}
@@ -0,0 +1,111 @@
import { describe, it, expect, afterEach, beforeEach } from "bun:test"
import * as vscode from "vscode"
import { migrateDefaultAutocompleteSettings } from "../../src/services/autocomplete/migrate-default"
import { DEFAULT_AUTOCOMPLETE_MODEL } from "../../src/shared/autocomplete-models"
type Stub = {
getConfiguration: (section?: string) => {
get: (key: string, fallback?: unknown) => unknown
update: (key: string, value: unknown, target: unknown) => Promise<void>
}
}
const original = vscode.workspace.getConfiguration
function makeContext(initial: Record<string, unknown> = {}) {
const flag = new Map<string, unknown>(Object.entries(initial))
return {
flag,
context: {
globalState: {
get: <T>(key: string) => flag.get(key) as T | undefined,
update: async (key: string, value: unknown) => {
flag.set(key, value)
},
},
} as any,
}
}
function stubConfig(state: Map<string, unknown>) {
;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => {
if (section !== "kilo-code.new.autocomplete") {
return { get: () => undefined, update: async () => {} }
}
return {
get: (key: string, fallback?: unknown) => state.get(key) ?? fallback,
update: async (key: string, value: unknown) => {
if (value === undefined) state.delete(key)
else state.set(key, value)
},
}
}
}
afterEach(() => {
;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"]
})
describe("migrateDefaultAutocompleteSettings", () => {
let state: Map<string, unknown>
beforeEach(() => {
state = new Map()
stubConfig(state)
})
it("clears provider/model when both equal the current default", async () => {
state.set("provider", DEFAULT_AUTOCOMPLETE_MODEL.providerID)
state.set("model", DEFAULT_AUTOCOMPLETE_MODEL.modelID)
const { context, flag } = makeContext()
await migrateDefaultAutocompleteSettings(context)
expect(state.has("provider")).toBe(false)
expect(state.has("model")).toBe(false)
expect(flag.get("kilo.autocomplete.defaultClearMigrationV1")).toBe(true)
})
it("leaves an explicitly chosen non-default model untouched", async () => {
state.set("provider", "inception")
state.set("model", "mercury-edit-2")
const { context, flag } = makeContext()
await migrateDefaultAutocompleteSettings(context)
expect(state.get("provider")).toBe("inception")
expect(state.get("model")).toBe("mercury-edit-2")
expect(flag.get("kilo.autocomplete.defaultClearMigrationV1")).toBe(true)
})
it("leaves a partial match untouched", async () => {
state.set("provider", DEFAULT_AUTOCOMPLETE_MODEL.providerID)
state.set("model", "inception/mercury-edit-2")
const { context } = makeContext()
await migrateDefaultAutocompleteSettings(context)
expect(state.get("provider")).toBe(DEFAULT_AUTOCOMPLETE_MODEL.providerID)
expect(state.get("model")).toBe("inception/mercury-edit-2")
})
it("only runs once per machine", async () => {
state.set("provider", DEFAULT_AUTOCOMPLETE_MODEL.providerID)
state.set("model", DEFAULT_AUTOCOMPLETE_MODEL.modelID)
const { context } = makeContext({ "kilo.autocomplete.defaultClearMigrationV1": true })
await migrateDefaultAutocompleteSettings(context)
// Setting was preserved — second run is a no-op.
expect(state.get("provider")).toBe(DEFAULT_AUTOCOMPLETE_MODEL.providerID)
expect(state.get("model")).toBe(DEFAULT_AUTOCOMPLETE_MODEL.modelID)
})
it("sets the flag even when nothing needed clearing", async () => {
const { context, flag } = makeContext()
await migrateDefaultAutocompleteSettings(context)
expect(flag.get("kilo.autocomplete.defaultClearMigrationV1")).toBe(true)
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect, afterEach, beforeEach } from "bun:test"
import * as vscode from "vscode"
import { buildAutocompleteSettingsMessage, validAutocompleteSetting } from "../../src/services/autocomplete/settings"
type Stub = {
getConfiguration: (section?: string) => {
get: <T>(key: string, fallback?: T) => T | undefined
update?: (key: string, value: unknown) => Promise<void>
}
}
const original = vscode.workspace.getConfiguration
function stubConfig(state: Map<string, unknown>) {
;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => {
if (section !== "kilo-code.new.autocomplete") {
return { get: <T>(_key: string, fallback?: T) => fallback }
}
return {
get: <T>(key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback),
}
}
}
afterEach(() => {
;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"]
})
describe("buildAutocompleteSettingsMessage", () => {
let state: Map<string, unknown>
beforeEach(() => {
state = new Map()
stubConfig(state)
})
it("returns null for both keys when nothing is set so the webview renders 'Not set'", () => {
const msg = buildAutocompleteSettingsMessage()
expect(msg.settings.provider).toBeNull()
expect(msg.settings.model).toBeNull()
})
it("passes an explicit BYOK selection through verbatim", () => {
state.set("provider", "inception")
state.set("model", "mercury-edit-2")
const msg = buildAutocompleteSettingsMessage()
expect(msg.settings.provider).toBe("inception")
expect(msg.settings.model).toBe("mercury-edit-2")
})
it("does not coerce a bare model setting to a default — let the webview see what was stored", () => {
state.set("model", "mercury-edit-2")
const msg = buildAutocompleteSettingsMessage()
expect(msg.settings.provider).toBeNull()
expect(msg.settings.model).toBe("mercury-edit-2")
})
})
describe("validAutocompleteSetting", () => {
it("accepts null/undefined for provider and model so the user can clear back to the default", () => {
expect(validAutocompleteSetting("provider", null)).toBe(true)
expect(validAutocompleteSetting("provider", undefined)).toBe(true)
expect(validAutocompleteSetting("model", null)).toBe(true)
expect(validAutocompleteSetting("model", undefined)).toBe(true)
})
it("accepts known providers and models", () => {
expect(validAutocompleteSetting("provider", "inception")).toBe(true)
expect(validAutocompleteSetting("model", "mercury-edit-2")).toBe(true)
})
it("rejects unknown providers and models", () => {
expect(validAutocompleteSetting("provider", "openrouter")).toBe(false)
expect(validAutocompleteSetting("model", "gpt-5")).toBe(false)
})
it("rejects non-boolean toggle updates", () => {
expect(validAutocompleteSetting("enableAutoTrigger", "true")).toBe(false)
})
})
@@ -5,7 +5,6 @@ import { useLanguage } from "../../context/language"
import { useProvider } from "../../context/provider"
import { useSession } from "../../context/session"
import { parseModelString } from "../../../../src/shared/provider-model"
import { DEFAULT_AUTOCOMPLETE_MODEL } from "../../../../src/shared/autocomplete-models"
import { ModelSelectorBase } from "../shared/ModelSelector"
import { ThinkingSelectorBase } from "../shared/ThinkingSelector"
import SettingsRow from "./SettingsRow"
@@ -17,9 +16,14 @@ const ModelsTab: Component = () => {
const provider = useProvider()
const session = useSession()
const autocompleteProvider = () =>
String(settings()["autocomplete.provider"] ?? DEFAULT_AUTOCOMPLETE_MODEL.providerID)
const autocompleteModel = () => String(settings()["autocomplete.model"] ?? DEFAULT_AUTOCOMPLETE_MODEL.modelID)
const autocompleteProvider = () => {
const v = settings()["autocomplete.provider"]
return typeof v === "string" ? v : undefined
}
const autocompleteModel = () => {
const v = settings()["autocomplete.model"]
return typeof v === "string" ? v : undefined
}
function handleModelSelect(configKey: "model" | "small_model") {
return (providerID: string, modelID: string) => {
@@ -73,7 +77,13 @@ const ModelsTab: Component = () => {
}
function handleAutocompleteModelSelect(providerID: string, modelID: string) {
if (!providerID || !modelID) return
if (!providerID || !modelID) {
// Clearing both keys reverts to the resolved server-side default. Users
// who pick "Not set" follow future default changes automatically.
updateSetting("autocomplete.provider", null)
updateSetting("autocomplete.model", null)
return
}
updateSetting("autocomplete.provider", providerID)
updateSetting("autocomplete.model", modelID)
}
@@ -137,6 +147,8 @@ const ModelsTab: Component = () => {
placement="bottom-start"
models={AUTOCOMPLETE_SELECTOR_MODELS}
favorites={false}
allowClear
clearLabel={language.t("settings.providers.notSet")}
/>
</SettingsRow>
</Card>
@@ -1,7 +1,14 @@
import { AUTOCOMPLETE_MODELS, getAutocompleteModel } from "../../../../src/shared/autocomplete-models"
import type { EnrichedModel } from "../../context/provider"
/**
* Resolve the (provider, model) pair to the dropdown's value. Returns
* `null` when neither is set so the selector renders the "Not set" (clear)
* state via `allowClear`. The runtime resolves unset values to
* `DEFAULT_AUTOCOMPLETE_MODEL` separately.
*/
export function getAutocompleteSelection(provider?: string, modelID?: string) {
if (!provider && !modelID) return null
const model = getAutocompleteModel(provider, modelID)
return { providerID: model.providerID, modelID: model.modelID }
}
@@ -336,8 +336,10 @@ export interface AutocompleteSettingsLoadedMessage {
enableAutoTrigger: boolean
enableSmartInlineTaskKeybinding: boolean
enableChatAutocomplete: boolean
provider: string
model: string
/** `null` means "no explicit setting — use the resolved default." */
provider: string | null
/** `null` means "no explicit setting — use the resolved default." */
model: string | null
}
}