diff --git a/.changeset/autocomplete-not-set-default.md b/.changeset/autocomplete-not-set-default.md new file mode 100644 index 0000000000..6888828276 --- /dev/null +++ b/.changeset/autocomplete-not-set-default.md @@ -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. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 3627dd898a..30e804c061 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 2f801984a4..563f8b6a4c 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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) } /** diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts index 04574cad6e..43bf1438ae 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts @@ -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 () => { diff --git a/packages/kilo-vscode/src/services/autocomplete/index.ts b/packages/kilo-vscode/src/services/autocomplete/index.ts index a27f838616..45a2759df8 100644 --- a/packages/kilo-vscode/src/services/autocomplete/index.ts +++ b/packages/kilo-vscode/src/services/autocomplete/index.ts @@ -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) diff --git a/packages/kilo-vscode/src/services/autocomplete/migrate-default.ts b/packages/kilo-vscode/src/services/autocomplete/migrate-default.ts new file mode 100644 index 0000000000..59e497d984 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/migrate-default.ts @@ -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 { + if (context.globalState.get(FLAG)) return + + const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") + const provider = config.get("provider") + const model = config.get("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) +} diff --git a/packages/kilo-vscode/src/services/autocomplete/settings.ts b/packages/kilo-vscode/src/services/autocomplete/settings.ts index 1312de8f9b..0a3f189d58 100644 --- a/packages/kilo-vscode/src/services/autocomplete/settings.ts +++ b/packages/kilo-vscode/src/services/autocomplete/settings.ts @@ -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("provider"), config.get("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("enableAutoTrigger", true), enableSmartInlineTaskKeybinding: config.get("enableSmartInlineTaskKeybinding", false), enableChatAutocomplete: config.get("enableChatAutocomplete", false), - provider: info.providerID, - model: info.modelID, + provider: config.get("provider") ?? null, + model: config.get("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) } diff --git a/packages/kilo-vscode/tests/unit/autocomplete-migrate-default.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-migrate-default.test.ts new file mode 100644 index 0000000000..7f8eb76193 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-migrate-default.test.ts @@ -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 + } +} + +const original = vscode.workspace.getConfiguration + +function makeContext(initial: Record = {}) { + const flag = new Map(Object.entries(initial)) + return { + flag, + context: { + globalState: { + get: (key: string) => flag.get(key) as T | undefined, + update: async (key: string, value: unknown) => { + flag.set(key, value) + }, + }, + } as any, + } +} + +function stubConfig(state: Map) { + ;(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 + + 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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-settings-message.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-settings-message.test.ts new file mode 100644 index 0000000000..3d24e8c924 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-settings-message.test.ts @@ -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: (key: string, fallback?: T) => T | undefined + update?: (key: string, value: unknown) => Promise + } +} + +const original = vscode.workspace.getConfiguration + +function stubConfig(state: Map) { + ;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => { + if (section !== "kilo-code.new.autocomplete") { + return { get: (_key: string, fallback?: T) => fallback } + } + return { + get: (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 + + 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) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx index 26e631c804..c01e043409 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx @@ -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")} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/autocomplete-model-selector.ts b/packages/kilo-vscode/webview-ui/src/components/settings/autocomplete-model-selector.ts index 0a2ab252af..5eae431a37 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/autocomplete-model-selector.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/autocomplete-model-selector.ts @@ -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 } } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index d72c1dc673..c65c22d724 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -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 } }