From 5af58aa75838dd887a0f5774a242ee877f177b2a Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 29 Jan 2026 21:19:53 +0100 Subject: [PATCH] feat: change free model onboarding to opt-in with button - Revert automatic kilocode profile creation for new users - Add 'Start with free models' button to welcome screen - Implement startWithFreeModels message handler - Update tests to reflect opt-in behavior - Users must explicitly click button to enable free models Reverts the auto-creation behavior from PR #5415 and adds explicit opt-in via button click instead. --- .changeset/opt-in-free-models.md | 5 ++ packages/types/src/vscode-extension-host.ts | 1 + src/core/config/ProviderSettingsManager.ts | 30 ++----- .../__tests__/ProviderSettingsManager.spec.ts | 24 ++---- .../webviewMessageHandlerUtils.spec.ts | 84 +++++++++++++++++++ .../webview/webviewMessageHandlerUtils.ts | 22 +++++ src/core/webview/webviewMessageHandler.ts | 1 + .../kilocode/common/KiloCodeAuth.tsx | 7 ++ webview-ui/src/i18n/locales/en/kilocode.json | 1 + 9 files changed, 133 insertions(+), 42 deletions(-) create mode 100644 .changeset/opt-in-free-models.md create mode 100644 src/core/kilocode/webview/__tests__/webviewMessageHandlerUtils.spec.ts diff --git a/.changeset/opt-in-free-models.md b/.changeset/opt-in-free-models.md new file mode 100644 index 00000000000..8ab2bf94939 --- /dev/null +++ b/.changeset/opt-in-free-models.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Changed free model onboarding from automatic to opt-in: new users must click "Start with free models" button to enable free models instead of having them configured by default diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index bc118a4ed1d..a46c5a0d9ee 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -948,6 +948,7 @@ export interface WebviewMessage { | "openDebugUiHistory" | "startDeviceAuth" // kilocode_change: Start device auth flow | "cancelDeviceAuth" // kilocode_change: Cancel device auth flow + | "startWithFreeModels" // kilocode_change: Start with free models without auth | "deviceAuthCompleteWithProfile" // kilocode_change: Device auth complete with specific profile | "requestChatCompletion" // kilocode_change: Request FIM completion for chat text area | "chatCompletionAccepted" // kilocode_change: User accepted a chat completion suggestion diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 47c58c503c2..448ddd10bcd 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -66,16 +66,9 @@ export class ProviderSettingsManager { modes.map((mode) => [mode.slug, this.defaultConfigId]), ) - // kilocode_change start: Anonymous kilocode onboarding - set default provider for new users private readonly defaultProviderProfiles: ProviderProfiles = { currentApiConfigName: "default", - apiConfigs: { - default: { - id: this.defaultConfigId, - apiProvider: "kilocode", - kilocodeModel: "minimax/minimax-m2.1:free", - }, - }, + apiConfigs: { default: { id: this.defaultConfigId } }, modeApiConfigs: this.defaultModeApiConfigs, migrations: { rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs @@ -86,7 +79,6 @@ export class ProviderSettingsManager { claudeCodeLegacySettingsMigrated: true, // Mark as migrated on fresh installs }, } - // kilocode_change end // kilocode_change start private pendingDuplicateIdRepairReport: Record | null = null @@ -148,15 +140,10 @@ export class ProviderSettingsManager { async init_runMigrations() { try { return await this.lock(async () => { - // kilocode_change start: Check if this is a new user (no stored config) - const storedContent = await this.context.secrets.get(this.secretsKey) - const isNewUser = !storedContent - // kilocode_change end - - const providerProfiles = await this.loadFromContent(storedContent) + const providerProfiles = await this.load() - if (isNewUser) { - await this.store(providerProfiles) + if (!providerProfiles) { + await this.store(this.defaultProviderProfiles) return } @@ -774,13 +761,9 @@ export class ProviderSettingsManager { } private async load(): Promise { - const content = await this.context.secrets.get(this.secretsKey) - return this.loadFromContent(content) - } - - // kilocode_change start: Extract content parsing to avoid double-fetching in init_runMigrations - private loadFromContent(content: string | undefined): ProviderProfiles { try { + const content = await this.context.secrets.get(this.secretsKey) + if (!content) { return this.defaultProviderProfiles } @@ -819,7 +802,6 @@ export class ProviderSettingsManager { throw new Error(`Failed to read provider profiles from secrets: ${error}`) } } - // kilocode_change end /** * Sanitizes a provider config by resetting invalid/removed apiProvider values. diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index c92039d2355..3271432dccb 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -43,12 +43,6 @@ describe("ProviderSettingsManager", () => { // then reinitializing, and spying on internals of said initialization. // I'm not convinced this test coverage means very much, so this fix makes a complicated puzzle happen to fall in place // Also this override resets itself, but fortunately no test required triple initialization... - - // Wait for the first manager's initialization to complete, then clear mock calls - // This is needed because new users get the default kilocode config stored - await providerSettingsManager.initialize() - vi.clearAllMocks() - providerSettingsManager.initialize = async () => { providerSettingsManager = new ProviderSettingsManager(mockContext) await providerSettingsManager.initialize() @@ -57,21 +51,15 @@ describe("ProviderSettingsManager", () => { }) describe("initialize", () => { - // kilocode_change start: test updated to expect kilocode default profile for new users - it("should initialize kilocode default profile when secrets.get returns null", async () => { + it("should not write to storage when secrets.get returns null", async () => { // Mock readConfig to return null mockSecrets.get.mockResolvedValueOnce(null) await providerSettingsManager.initialize() - // Should write to storage with default kilocode profile for new users - expect(mockSecrets.store).toHaveBeenCalled() - const calls = mockSecrets.store.mock.calls - const storedConfig = JSON.parse(calls[calls.length - 1][1]) - expect(storedConfig.apiConfigs.default.apiProvider).toBe("kilocode") - expect(storedConfig.apiConfigs.default.kilocodeModel).toBe("minimax/minimax-m2.1:free") + // Should not write to storage because readConfig returns defaultConfig + expect(mockSecrets.store).not.toHaveBeenCalled() }) - // kilocode_change end it("should not initialize config if it exists and migrations are complete", async () => { mockSecrets.get.mockResolvedValue( @@ -449,7 +437,7 @@ describe("ProviderSettingsManager", () => { mockSecrets.get.mockRejectedValue(new Error("Storage failed")) await expect(providerSettingsManager.initialize()).rejects.toThrow( - "Failed to initialize config: Error: Storage failed", + "Failed to initialize config: Error: Failed to read provider profiles from secrets: Error: Storage failed", ) }) }) @@ -510,7 +498,7 @@ describe("ProviderSettingsManager", () => { mockSecrets.get.mockRejectedValue(new Error("Read failed")) await expect(providerSettingsManager.listConfig()).rejects.toThrow( - "Failed to list configs: Error: Read failed", + "Failed to list configs: Error: Failed to read provider profiles from secrets: Error: Read failed", ) }) }) @@ -947,7 +935,7 @@ describe("ProviderSettingsManager", () => { mockSecrets.get.mockRejectedValue(new Error("Storage failed")) await expect(providerSettingsManager.hasConfig("test")).rejects.toThrow( - "Failed to check config existence: Error: Storage failed", + "Failed to check config existence: Error: Failed to read provider profiles from secrets: Error: Storage failed", ) }) }) diff --git a/src/core/kilocode/webview/__tests__/webviewMessageHandlerUtils.spec.ts b/src/core/kilocode/webview/__tests__/webviewMessageHandlerUtils.spec.ts new file mode 100644 index 00000000000..2667d82e5b3 --- /dev/null +++ b/src/core/kilocode/webview/__tests__/webviewMessageHandlerUtils.spec.ts @@ -0,0 +1,84 @@ +import { deviceAuthMessageHandler } from "../webviewMessageHandlerUtils" +import { ClineProvider } from "../../../webview/ClineProvider" +import { WebviewMessage } from "../../../../shared/WebviewMessage" + +// Mock the buildApiHandler +vi.mock("../../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({}), +})) + +describe("deviceAuthMessageHandler", () => { + let mockProvider: Partial + + beforeEach(() => { + mockProvider = { + getState: vi.fn().mockResolvedValue({ + apiConfiguration: {}, + currentApiConfigName: "default", + }), + upsertProviderProfile: vi.fn().mockResolvedValue(undefined), + getCurrentTask: vi.fn().mockReturnValue(null), + postMessageToWebview: vi.fn(), + log: vi.fn(), + } + }) + + describe("startWithFreeModels", () => { + it("should set up the profile with kilocode provider and free model", async () => { + const message: WebviewMessage = { type: "startWithFreeModels" } + + const result = await deviceAuthMessageHandler(mockProvider as ClineProvider, message) + + expect(result).toBe(true) + expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("default", { + apiProvider: "kilocode", + kilocodeModel: "minimax/minimax-m2.1:free", + }) + }) + + it("should navigate to chat tab after setting up free models", async () => { + const message: WebviewMessage = { type: "startWithFreeModels" } + + await deviceAuthMessageHandler(mockProvider as ClineProvider, message) + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "switchTab", + tab: "chat", + }) + }) + + it("should preserve existing apiConfiguration when setting up free models", async () => { + mockProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { + existingSetting: "value", + anotherSetting: 123, + }, + currentApiConfigName: "my-profile", + }) + + const message: WebviewMessage = { type: "startWithFreeModels" } + + await deviceAuthMessageHandler(mockProvider as ClineProvider, message) + + expect(mockProvider.upsertProviderProfile).toHaveBeenCalledWith("my-profile", { + existingSetting: "value", + anotherSetting: 123, + apiProvider: "kilocode", + kilocodeModel: "minimax/minimax-m2.1:free", + }) + }) + + it("should log error if setup fails", async () => { + const error = new Error("Test error") + mockProvider.upsertProviderProfile = vi.fn().mockRejectedValue(error) + + const message: WebviewMessage = { type: "startWithFreeModels" } + + const result = await deviceAuthMessageHandler(mockProvider as ClineProvider, message) + + expect(result).toBe(true) // Still returns true even on error + expect(mockProvider.log).toHaveBeenCalledWith("Error setting up free models: Test error") + }) + }) +}) diff --git a/src/core/kilocode/webview/webviewMessageHandlerUtils.ts b/src/core/kilocode/webview/webviewMessageHandlerUtils.ts index ef6454cf752..50e838ffe25 100644 --- a/src/core/kilocode/webview/webviewMessageHandlerUtils.ts +++ b/src/core/kilocode/webview/webviewMessageHandlerUtils.ts @@ -354,6 +354,28 @@ export const deviceAuthMessageHandler = async (provider: ClineProvider, message: provider.cancelDeviceAuth() return true } + case "startWithFreeModels": { + // Set up kilocode provider with free model (no token required) + try { + const { currentApiConfigName = "default", apiConfiguration } = await provider.getState() + await provider.upsertProviderProfile(currentApiConfigName, { + ...apiConfiguration, + apiProvider: "kilocode", + kilocodeModel: "minimax/minimax-m2.1:free", + }) + + // Navigate to chat tab after setup using action message + await provider.postMessageToWebview({ + type: "action", + action: "switchTab", + tab: "chat", + }) + } catch (error) { + provider.log(`Error setting up free models: ${error instanceof Error ? error.message : String(error)}`) + vscode.window.showErrorMessage("Failed to set up free models") + } + return true + } case "deviceAuthCompleteWithProfile": { // Save token to specific profile or current profile if no profile name provided if (message.values?.token) { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 13dd8ef5edc..3686054e52b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -4605,6 +4605,7 @@ export const webviewMessageHandler = async ( // kilocode_change start - Device Auth handlers case "startDeviceAuth": case "cancelDeviceAuth": + case "startWithFreeModels": case "deviceAuthCompleteWithProfile": { await deviceAuthMessageHandler(provider, message) break diff --git a/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx b/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx index c52e1012c84..2cf8f319773 100644 --- a/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx +++ b/webview-ui/src/components/kilocode/common/KiloCodeAuth.tsx @@ -138,6 +138,13 @@ const KiloCodeAuth: React.FC = ({ onManualConfigClick, onLogi
{t("kilocode:welcome.ctaButton")} + { + vscode.postMessage({ type: "startWithFreeModels" }) + }}> + {t("kilocode:welcome.startWithFreeModels")} + + {!!onManualConfigClick && ( onManualConfigClick && onManualConfigClick()}> {t("kilocode:welcome.manualModeButton")} diff --git a/webview-ui/src/i18n/locales/en/kilocode.json b/webview-ui/src/i18n/locales/en/kilocode.json index f63b2754950..f3ab50e4a13 100644 --- a/webview-ui/src/i18n/locales/en/kilocode.json +++ b/webview-ui/src/i18n/locales/en/kilocode.json @@ -5,6 +5,7 @@ "introText2": "It works with the latest AI models like Claude Opus 4.5, Gemini 3 Pro, GPT-5.2, and 450+ more.", "introText3": "Create a free account and get $20 in bonus credits when you top-up for the first time.", "ctaButton": "Get Started", + "startWithFreeModels": "Start with free models", "manualModeButton": "Use your own API key", "alreadySignedUp": "Already signed up?", "loginText": "Log in here"