From 61094d31868a424371fde1d0ed497783649d8c80 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 6 Aug 2026 18:20:32 +0900 Subject: [PATCH] feat(settings): support named model profiles with /model integration (#204) Add a profiles map (model, baseUrl, apiKey, thinkingEnabled, reasoningEffort, temperature) and an optional defaultProfile to settings.json. The /model dropdown lists saved profiles for one-click switching via writeProfileSelection, and the default profile is applied as a fallback when resolving settings. (cherry picked from commit 5ee6ca63f187d0545a966f7e0037a978adf39161) --- packages/cli/src/tests/exec-runner.test.ts | 1 + .../ui/components/ModelsDropdown/index.tsx | 61 ++++++-- packages/cli/src/ui/views/App.tsx | 56 +++++++- packages/cli/src/ui/views/PromptInput.tsx | 7 + packages/core/src/index.ts | 2 + packages/core/src/settings.ts | 134 +++++++++++++++++- .../src/tests/settings-and-notify.test.ts | 75 ++++++++++ 7 files changed, 320 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/tests/exec-runner.test.ts b/packages/cli/src/tests/exec-runner.test.ts index 1faa40ea..4c31d756 100644 --- a/packages/cli/src/tests/exec-runner.test.ts +++ b/packages/cli/src/tests/exec-runner.test.ts @@ -34,6 +34,7 @@ function createSettings( permissions, enabledSkills: {}, statusline: { enabled: false, refreshMs: 1000, separator: " | ", providers: [] }, + profiles: {}, }; } diff --git a/packages/cli/src/ui/components/ModelsDropdown/index.tsx b/packages/cli/src/ui/components/ModelsDropdown/index.tsx index 9fe968b4..0c3b5b57 100644 --- a/packages/cli/src/ui/components/ModelsDropdown/index.tsx +++ b/packages/cli/src/ui/components/ModelsDropdown/index.tsx @@ -19,6 +19,10 @@ export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ { label: "No thinking", thinkingEnabled: false }, ]; +export type ModelProfileOption = { + name: string; +}; + function getThinkingOptionIndex(config: Pick): number { const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { if (!config.thinkingEnabled) { @@ -33,8 +37,10 @@ type Props = { open: boolean; modelConfig: ModelConfigSelection; width: number; + profiles?: ModelProfileOption[]; onClose: () => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onProfileSelect?: (profileName: string) => string | Promise; onStatusMessage?: (message: string | null) => void; }; @@ -42,8 +48,10 @@ const ModelsDropdown: React.FC = ({ open, modelConfig, width, + profiles, onClose, onModelConfigChange, + onProfileSelect, onStatusMessage, }) => { const [step, setStep] = useState(null); @@ -53,29 +61,47 @@ const ModelsDropdown: React.FC = ({ // Initialize state when opened useEffect(() => { if (open) { + const profileCount = profiles?.length ?? 0; const currentIndex = MODEL_COMMAND_MODELS.findIndex((m) => m === modelConfig.model); setPendingModel(null); setStep("model"); - setActiveIndex(currentIndex >= 0 ? currentIndex : 0); + setActiveIndex(currentIndex >= 0 ? profileCount + currentIndex : 0); } else { setStep(null); } - }, [open, modelConfig.model]); + }, [open, modelConfig.model, profiles]); // Validate activeIndex bounds useEffect(() => { if (!step) { return; } - const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + const optionCount = + step === "model" ? (profiles?.length ?? 0) + MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; if (activeIndex >= optionCount) { setActiveIndex(Math.max(0, optionCount - 1)); } - }, [activeIndex, step]); + }, [activeIndex, step, profiles]); function selectItem(): void { if (step === "model") { - const model = MODEL_COMMAND_MODELS[activeIndex] ?? modelConfig.model; + const profileCount = profiles?.length ?? 0; + if (activeIndex < profileCount && profiles?.[activeIndex] && onProfileSelect) { + const profile = profiles[activeIndex]; + onClose(); + Promise.resolve(onProfileSelect(profile.name)) + .then((message) => { + if (message) { + onStatusMessage?.(message); + } + }) + .catch((error) => { + const msg = error instanceof Error ? error.message : String(error); + onStatusMessage?.(`Failed to apply profile: ${msg}`); + }); + return; + } + const model = MODEL_COMMAND_MODELS[activeIndex - profileCount] ?? modelConfig.model; setPendingModel(model); setStep("thinking"); setActiveIndex(getThinkingOptionIndex(modelConfig)); @@ -107,7 +133,10 @@ const ModelsDropdown: React.FC = ({ return; } - const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + const optionCount = + step === "model" + ? (profiles?.length ?? 0) + MODEL_COMMAND_MODELS.length + : MODEL_COMMAND_THINKING_OPTIONS.length; if (key.upArrow) { setActiveIndex((idx) => (idx - 1 + optionCount) % optionCount); @@ -135,12 +164,20 @@ const ModelsDropdown: React.FC = ({ const items = step === "model" - ? MODEL_COMMAND_MODELS.map((model) => ({ - key: model, - label: model, - description: model === modelConfig.model ? "current model" : "", - selected: model === (pendingModel ?? modelConfig.model), - })) + ? [ + ...(profiles ?? []).map((profile) => ({ + key: `profile:${profile.name}`, + label: `⚔ ${profile.name}`, + description: "apply saved profile", + selected: false, + })), + ...MODEL_COMMAND_MODELS.map((model) => ({ + key: model, + label: model, + description: model === modelConfig.model ? "current model" : "", + selected: model === (pendingModel ?? modelConfig.model), + })), + ] : MODEL_COMMAND_THINKING_OPTIONS.map((option, i) => ({ key: option.label, label: option.label, diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 034c62ac..7501f869 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -9,6 +9,7 @@ import { MessageView, RawModeExitPrompt } from "../components"; import { SessionList } from "./SessionList"; import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; import { buildLoadingText } from "../core/loading-text"; +import type { ModelProfileOption } from "../components/ModelsDropdown"; import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; @@ -32,7 +33,12 @@ import { isCurrentSessionEmpty, renderRawModeMessages, } from "../utils"; -import { resolveCurrentSettings, writeModelConfigSelection } from "@vegamo/deepcode-core"; +import { + getModelProfiles, + resolveCurrentSettings, + writeModelConfigSelection, + writeProfileSelection, +} from "@vegamo/deepcode-core"; import { useStatusLine } from "../hooks"; import type { SessionInfo } from "../statusline"; import { isCollapsedThinking } from "../core/thinking-state"; @@ -131,6 +137,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const [showWelcome, setShowWelcome] = useState(true); const [welcomeNonce, setWelcomeNonce] = useState(0); const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); + const modelProfiles = useMemo( + () => Object.keys(getModelProfiles(resolvedSettings)).map((name) => ({ name })), + [resolvedSettings] + ); const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); const [showProcessStdout, setShowProcessStdout] = useState(false); @@ -539,6 +549,48 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes [handlePrompt] ); + const handleProfileSelect = useCallback( + (profileName: string): string => { + const { changed, profile } = writeProfileSelection(profileName, projectRoot); + const next = resolveCurrentSettings(projectRoot); + setResolvedSettings(next); + if (!changed || !profile) { + return `Profile "${profileName}" not found in settings.json`; + } + + const activeSessionId = sessionManager.getActiveSessionId(); + const content = `/model\nā”” Applied profile ${profileName} → ${profile.model ?? next.model} (${ + next.thinkingEnabled ? next.reasoningEffort : "no thinking" + })`; + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, content, true, { isModelChange: true }); + const activeSession = sessionManager.getSession(activeSessionId); + setStatusLine(activeSession ? buildStatusLine(activeSession, next) : ""); + } else { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta: { isModelChange: true }, + }, + ]); + } + + return `Applied profile "${profileName}" (${profile.model ?? next.model})`; + }, + [projectRoot, sessionManager] + ); + const handlePlanImplementationChoice = useCallback( (choice: "implement" | "stay" | "default") => { const proposedPlan = pendingPlanImplementation; @@ -1061,6 +1113,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes promptDraft={promptDraft} onSubmit={handleSubmit} onModelConfigChange={handleModelConfigChange} + modelProfiles={modelProfiles} + onProfileSelect={handleProfileSelect} onRawModeChange={handleRawModeChange} onInterrupt={handleInterrupt} onToggleProcessStdout={handleToggleProcessStdout} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 9e6240a4..f1a43c38 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -62,6 +62,7 @@ import { import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; import type { ModelConfigSelection, PermissionScope } from "@vegamo/deepcode-core"; import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "../components"; +import type { ModelProfileOption } from "../components/ModelsDropdown"; import type { SessionEntry, SkillInfo } from "@vegamo/deepcode-core"; import type { UserToolPermission } from "@vegamo/deepcode-core"; import type { StatusSegment } from "../statusline"; @@ -100,6 +101,8 @@ type Props = { planMode: boolean; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + modelProfiles?: ModelProfileOption[]; + onProfileSelect?: (profileName: string) => string | Promise; onRawModeChange?: (mode: string) => void; onPlanModeChange: (enabled: boolean) => void; onInterrupt: () => void; @@ -135,6 +138,8 @@ export const PromptInput = React.memo(function PromptInput({ planMode, onSubmit, onModelConfigChange, + modelProfiles, + onProfileSelect, onInterrupt, onToggleProcessStdout, onExitShortcut, @@ -851,8 +856,10 @@ export const PromptInput = React.memo(function PromptInput({ open={showModelDropdown} modelConfig={modelConfig} width={screenWidth} + profiles={modelProfiles} onClose={() => setShowModelDropdown(false)} onModelConfigChange={onModelConfigChange} + onProfileSelect={onProfileSelect} onStatusMessage={setStatusMessage} /> ; + defaultProfile?: string; }; export type ResolvedDeepcodingSettings = { @@ -116,6 +127,7 @@ export type ResolvedDeepcodingSettings = { permissions: Required; enabledSkills: EnabledSkillsSettings; statusline: ResolvedStatusLineSettings; + profiles: Record; }; export type ModelConfigSelection = { @@ -199,6 +211,56 @@ function trimString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function normalizeProfile(value: unknown): ModelProfile | null { + if (!isPlainObject(value)) { + return null; + } + const profile: ModelProfile = {}; + const model = trimString(value["model"]); + if (model) { + profile.model = model; + } + const baseUrl = trimString(value["baseUrl"]); + if (baseUrl) { + profile.baseUrl = baseUrl; + } + const apiKey = trimString(value["apiKey"]); + if (apiKey) { + profile.apiKey = apiKey; + } + const thinkingEnabled = parseBoolean(value["thinkingEnabled"]); + if (thinkingEnabled !== undefined) { + profile.thinkingEnabled = thinkingEnabled; + } + const reasoningEffort = resolveReasoningEffort(value["reasoningEffort"]); + if (reasoningEffort) { + profile.reasoningEffort = reasoningEffort; + } + const temperature = parseTemperature(value["temperature"]); + if (temperature !== undefined) { + profile.temperature = temperature; + } + return Object.keys(profile).length > 0 ? profile : null; +} + +function resolveActiveProfile( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): ModelProfile | null { + for (const source of [projectSettings, userSettings]) { + const name = source?.defaultProfile; + const profiles = source?.profiles; + if (!name || !profiles || !isPlainObject(profiles)) { + continue; + } + const profile = normalizeProfile(profiles[name]); + if (profile) { + return profile; + } + } + return null; +} + const VALID_PERMISSION_SCOPES = new Set([ "read-in-cwd", "read-out-cwd", @@ -518,6 +580,7 @@ export function resolveSettingsSources( ...projectEnv, ...systemEnv, }; + const profile = resolveActiveProfile(userSettings, projectSettings); const model = trimString(systemEnv.MODEL) || @@ -525,6 +588,7 @@ export function resolveSettingsSources( trimString(projectEnv.MODEL) || trimString(userSettings?.model) || trimString(userEnv.MODEL) || + trimString(profile?.model) || defaults.model; const contextWindow = @@ -544,6 +608,7 @@ export function resolveSettingsSources( parseBoolean(projectEnv.THINKING_ENABLED) ?? parseBoolean(userSettings?.thinkingEnabled) ?? parseBoolean(userEnv.THINKING_ENABLED) ?? + profile?.thinkingEnabled ?? defaultsToThinkingMode(model); const reasoningEffort = @@ -552,6 +617,7 @@ export function resolveSettingsSources( resolveReasoningEffort(projectEnv.REASONING_EFFORT) ?? resolveReasoningEffort(userSettings?.reasoningEffort) ?? resolveReasoningEffort(userEnv.REASONING_EFFORT) ?? + profile?.reasoningEffort ?? "max"; const temperature = @@ -559,7 +625,8 @@ export function resolveSettingsSources( parseTemperature(projectSettings?.temperature) ?? parseTemperature(projectEnv.TEMPERATURE) ?? parseTemperature(userSettings?.temperature) ?? - parseTemperature(userEnv.TEMPERATURE); + parseTemperature(userEnv.TEMPERATURE) ?? + profile?.temperature; const debugLogEnabled = parseBoolean(systemEnv.DEBUG_LOG_ENABLED) ?? @@ -587,8 +654,8 @@ export function resolveSettingsSources( return { env, - apiKey: trimString(env.API_KEY) || undefined, - baseURL: trimString(env.BASE_URL) || defaults.baseURL, + apiKey: trimString(env.API_KEY) || trimString(profile?.apiKey) || undefined, + baseURL: trimString(env.BASE_URL) || trimString(profile?.baseUrl) || defaults.baseURL, model, contextWindow, autoCompactWindow, @@ -603,6 +670,7 @@ export function resolveSettingsSources( permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), statusline: mergeStatusLine(userSettings, projectSettings), + profiles: { ...getModelProfiles(userSettings), ...getModelProfiles(projectSettings) }, }; } @@ -717,6 +785,66 @@ export function writeModelConfigSelection( return result; } +export function getModelProfiles(settings: DeepcodingSettings | null | undefined): Record { + const result: Record = {}; + const profiles = settings?.profiles; + if (!profiles || !isPlainObject(profiles)) { + return result; + } + for (const [name, value] of Object.entries(profiles)) { + const profile = normalizeProfile(value); + if (profile && name.trim()) { + result[name.trim()] = profile; + } + } + return result; +} + +/** + * Apply a named profile from settings.profiles to the current configuration, + * writing the merged settings back to disk. (#204) + */ +export function writeProfileSelection( + profileName: string, + projectRoot: string = process.cwd() +): { changed: boolean; profile: ModelProfile | null } { + const projectSettingsPath = getProjectSettingsPath(projectRoot); + const shouldWriteProjectSettings = fs.existsSync(projectSettingsPath); + const rawSettings = shouldWriteProjectSettings ? readProjectSettings(projectRoot) : readSettings(); + const profile = getModelProfiles(rawSettings)[profileName] ?? null; + if (!profile) { + return { changed: false, profile: null }; + } + + const next: DeepcodingSettings = { ...(rawSettings ?? {}) }; + if (profile.model) { + next.model = profile.model; + } + if (profile.thinkingEnabled !== undefined) { + next.thinkingEnabled = profile.thinkingEnabled; + } + if (profile.reasoningEffort) { + next.reasoningEffort = profile.reasoningEffort; + } + if (profile.temperature !== undefined) { + next.temperature = profile.temperature; + } + if (profile.baseUrl || profile.apiKey) { + next.env = { + ...(next.env ?? {}), + ...(profile.baseUrl ? { BASE_URL: profile.baseUrl } : {}), + ...(profile.apiKey ? { API_KEY: profile.apiKey } : {}), + }; + } + + if (shouldWriteProjectSettings) { + writeProjectSettings(next, projectRoot); + } else { + writeSettings(next); + } + return { changed: true, profile }; +} + export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { const userPath = path.resolve(getUserSettingsPath()); const projectPath = path.resolve(getProjectSettingsPath(projectRoot)); diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index 93e8dc29..2dca155d 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -8,9 +8,84 @@ import { type NotifySpawn, } from "../common/notify"; import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { getModelProfiles, readSettingsFile, writeProfileSelection } from "../settings"; const TEST_PROCESS_ENV = {}; +test("resolveSettings applies defaultProfile values as fallbacks (#204)", () => { + const resolved = resolveSettings( + { + profiles: { + "pro-max": { model: "deepseek-v4-pro", thinkingEnabled: true, reasoningEffort: "max", temperature: 0 }, + "flash-quick": { model: "deepseek-v4-flash", thinkingEnabled: false, temperature: 0.7 }, + }, + defaultProfile: "flash-quick", + env: { API_KEY: "sk-test" }, + }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.model, "deepseek-v4-flash"); + assert.equal(resolved.thinkingEnabled, false); + assert.equal(resolved.temperature, 0.7); +}); + +test("resolveSettings keeps explicit user settings over defaultProfile (#204)", () => { + const resolved = resolveSettings( + { + model: "deepseek-v4-pro", + profiles: { "flash-quick": { model: "deepseek-v4-flash", thinkingEnabled: false } }, + defaultProfile: "flash-quick", + env: { API_KEY: "sk-test" }, + }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.model, "deepseek-v4-pro"); +}); + +test("getModelProfiles filters invalid profile entries (#204)", () => { + const profiles = getModelProfiles({ + profiles: { valid: { model: "deepseek-v4-flash" }, invalid: {}, missing: { temperature: 0.5 } }, + }); + assert.deepEqual(Object.keys(profiles).sort(), ["missing", "valid"]); +}); + +test("writeProfileSelection applies a profile to the project settings file (#204)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-profile-test-")); + try { + fs.mkdirSync(path.join(dir, ".deepcode"), { recursive: true }); + const projectSettingsPath = path.join(dir, ".deepcode", "settings.json"); + fs.writeFileSync( + projectSettingsPath, + JSON.stringify({ + env: { API_KEY: "sk-original" }, + profiles: { + "pro-max": { model: "deepseek-v4-pro", thinkingEnabled: true, reasoningEffort: "max" }, + }, + }), + "utf8" + ); + + const result = writeProfileSelection("pro-max", dir); + assert.equal(result.changed, true); + assert.equal(result.profile?.model, "deepseek-v4-pro"); + + const saved = readSettingsFile(projectSettingsPath); + assert.equal(saved?.model, "deepseek-v4-pro"); + assert.equal(saved?.thinkingEnabled, true); + assert.equal(saved?.env?.API_KEY, "sk-original"); + + const missing = writeProfileSelection("nope", dir); + assert.equal(missing.changed, false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool", () => { const resolved = resolveSettings( {