diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx index a953352c5d2..aec7acfe4e6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ProvidersTab.tsx @@ -6,6 +6,7 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { useConfig } from "../../context/config" import { useProvider } from "../../context/provider" import { useLanguage } from "../../context/language" +import { useSession } from "../../context/session" import { ModelSelectorBase } from "../shared/ModelSelector" import type { ModelSelection } from "../../types/messages" import SettingsRow from "./SettingsRow" @@ -31,6 +32,7 @@ const ProvidersTab: Component = () => { const { config, updateConfig } = useConfig() const provider = useProvider() const language = useLanguage() + const session = useSession() const providerOptions = createMemo(() => Object.keys(provider.providers()) @@ -61,13 +63,27 @@ const ProvidersTab: Component = () => { function handleModelSelect(configKey: "model" | "small_model") { return (providerID: string, modelID: string) => { if (!providerID || !modelID) { - updateConfig({ [configKey]: undefined }) + updateConfig({ [configKey]: null }) } else { updateConfig({ [configKey]: `${providerID}/${modelID}` }) } } } + const primaryAgents = createMemo(() => + session.agents().filter((a) => a.mode === "primary"), + ) + + function handleModeModelSelect(agentName: string) { + return (providerID: string, modelID: string) => { + if (!providerID || !modelID) { + updateConfig({ agent: { [agentName]: { model: null } } }) + } else { + updateConfig({ agent: { [agentName]: { model: `${providerID}/${modelID}` } } }) + } + } + } + return (
{/* Model selection */} @@ -99,6 +115,38 @@ const ProvidersTab: Component = () => { + {/* Model per Mode */} +

{language.t("settings.providers.modeModels")}

+ +
0 ? "1px solid var(--border-weak-base)" : "none", + }} + > + {language.t("settings.providers.modeModels.description")} +
+ + {(agent, index) => ( + + + + )} + +
+ {/* Disabled providers */}

{language.t("settings.providers.disabled")}

diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 9155da011ca..12ca11dc282 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -14,6 +14,37 @@ interface ConfigContextValue { updateConfig: (partial: Partial) => void } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +/** Deep merge two objects, with source values overriding target values. */ +function deepMerge(target: Config, source: Partial): Config { + const result: Record = { ...target } + for (const [key, value] of Object.entries(source)) { + if (isRecord(value) && isRecord(result[key])) { + result[key] = deepMerge(result[key] as Config, value as Partial) + } else { + result[key] = value + } + } + return result as Config +} + +/** Recursively remove keys whose value is null (null = "deleted"). */ +function stripNulls(obj: Config): Config { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) continue + if (isRecord(value)) { + result[key] = stripNulls(value as Config) + } else { + result[key] = value + } + } + return result as Config +} + const ConfigContext = createContext() export const ConfigProvider: ParentComponent = (props) => { @@ -59,8 +90,8 @@ export const ConfigProvider: ParentComponent = (props) => { onCleanup(() => clearInterval(retryTimer)) function updateConfig(partial: Partial) { - // Optimistically update local state - setConfig((prev) => ({ ...prev, ...partial })) + // Optimistically update local state with deep merge + null stripping + setConfig((prev) => stripNulls(deepMerge(prev, partial))) // Send to extension for persistence vscode.postMessage({ type: "updateConfig", config: partial }) } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 27f9cc6646b..6749efd7385 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -20,6 +20,7 @@ import { createStore, produce } from "solid-js/store" import { useVSCode } from "./vscode" import { useServer } from "./server" import { useProvider } from "./provider" +import { useConfig } from "./config" import { useLanguage } from "./language" import { showToast } from "@kilocode/kilo-ui/toast" import type { @@ -144,6 +145,7 @@ export const SessionProvider: ParentComponent = (props) => { const vscode = useVSCode() const server = useServer() const provider = useProvider() + const { config } = useConfig() const language = useLanguage() // Current session ID @@ -202,7 +204,7 @@ export const SessionProvider: ParentComponent = (props) => { variantSelections: {}, }) - // Keep pending selection in sync with provider default until the user + // Keep pending selection in sync with provider/mode default until the user // explicitly changes it (or a session exists). createEffect(() => { const def = provider.defaultSelection() @@ -214,23 +216,45 @@ export const SessionProvider: ParentComponent = (props) => { return } - setPendingModelSelection(def) + // Per-mode config takes priority over global default + const pendingAgent = pendingAgentSelection() ?? defaultAgent() + const modeModel = getModeModel(pendingAgent) + setPendingModelSelection(modeModel ?? def) }) // If we have no pending yet, initialize it from provider default. createEffect(() => { if (!pendingModelSelection()) { - setPendingModelSelection(provider.defaultSelection()) + const pendingAgent = pendingAgentSelection() ?? defaultAgent() + const modeModel = getModeModel(pendingAgent) + setPendingModelSelection(modeModel ?? provider.defaultSelection()) } }) + /** Parse a "provider/model" config string into a ModelSelection (or null). */ + function getModeModel(agentName: string): ModelSelection | null { + const raw = config().agent?.[agentName]?.model + if (!raw) return null + const slash = raw.indexOf("/") + if (slash <= 0) return null + return { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) } + } + // Per-session model selection + // Precedence: session override > per-mode config > global default > kilo/auto const selected = createMemo(() => { const sessionID = currentSessionID() if (sessionID) { - return store.modelSelections[sessionID] ?? provider.defaultSelection() + const sessionModel = store.modelSelections[sessionID] + if (sessionModel) return sessionModel + const agentName = store.agentSelections[sessionID] ?? defaultAgent() + return getModeModel(agentName) ?? provider.defaultSelection() } - return pendingModelSelection() + // Pre-session: check pending agent's per-mode default + const pending = pendingModelSelection() + if (pending) return pending + const pendingAgent = pendingAgentSelection() ?? defaultAgent() + return getModeModel(pendingAgent) ?? provider.defaultSelection() }) // Per-session agent selection @@ -788,6 +812,12 @@ export const SessionProvider: ParentComponent = (props) => { setStore("agentSelections", id, name) } else { setPendingAgentSelection(name) + // When switching mode pre-session, update pending model to per-mode default + // (unless user explicitly set a model for this session) + if (!pendingWasUserSet()) { + const modeModel = getModeModel(name) + setPendingModelSelection(modeModel ?? provider.defaultSelection()) + } } } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index cf8a9123e86..34d08218156 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -988,6 +988,8 @@ export const dict = { "settings.providers.defaultModel.description": "Primary model for conversations", "settings.providers.smallModel.title": "Small Model", "settings.providers.smallModel.description": "Lightweight model for title generation and other quick tasks", + "settings.providers.modeModels": "Model per Mode", + "settings.providers.modeModels.description": "Override the default model for specific modes. If not set, the global default model is used.", "settings.providers.disabled": "Disabled Providers", "settings.providers.disabled.description": "Providers to hide from the provider list", "settings.providers.enabled": "Enabled Providers (Allowlist)", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 7f253ff1706..bbb92241dee 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -257,7 +257,7 @@ export type PermissionLevel = "allow" | "ask" | "deny" export type PermissionConfig = Partial> export interface AgentConfig { - model?: string + model?: string | null prompt?: string temperature?: number top_p?: number @@ -309,8 +309,8 @@ export interface ExperimentalConfig { export interface Config { permission?: PermissionConfig - model?: string - small_model?: string + model?: string | null + small_model?: string | null default_agent?: string agent?: Record provider?: Record diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d73f9c00156..fe86ccfb8c2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -774,7 +774,7 @@ export namespace Config { export const Agent = z .object({ - model: ModelId.optional(), + model: ModelId.nullable().optional(), variant: z .string() .optional() @@ -1129,8 +1129,8 @@ export namespace Config { .array(z.string()) .optional() .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), - model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), - small_model: ModelId.describe( + model: ModelId.nullable().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + small_model: ModelId.nullable().describe( "Small model to use for tasks like title generation in the format of provider/model", ).optional(), // kilocode_change start - renamed from "build" to "code" @@ -1407,7 +1407,7 @@ export namespace Config { export async function update(config: Info) { const filepath = path.join(Instance.directory, "config.json") const existing = await loadFile(filepath) - await Filesystem.writeJson(filepath, mergeDeep(existing, config)) + await Filesystem.writeJson(filepath, stripNulls(mergeDeep(existing, config) as Record)) await Instance.dispose() } @@ -1427,9 +1427,24 @@ export namespace Config { return !!value && typeof value === "object" && !Array.isArray(value) } + /** Recursively remove keys whose value is null (used after mergeDeep to honor delete sentinels). */ + function stripNulls(obj: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (value === null) continue + if (isRecord(value)) { + result[key] = stripNulls(value) + } else { + result[key] = value + } + } + return result + } + function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { - const edits = modify(input, path, patch, { + // null means "delete this key" — pass undefined to jsonc-parser's modify() + const edits = modify(input, path, patch === null ? undefined : patch, { formattingOptions: { insertSpaces: true, tabSize: 2, @@ -1488,7 +1503,7 @@ export namespace Config { const next = await (async () => { if (!filepath.endsWith(".jsonc")) { const existing = parseConfig(before, filepath) - const merged = mergeDeep(existing, config) + const merged = stripNulls(mergeDeep(existing, config) as Record) as Info await Filesystem.writeJson(filepath, merged) return merged }