import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { DEFAULT_METER_UPPER_BOUND_TPS, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN } from "./activity-meter.ts"; import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts"; import { fromLegacyOutputStyle, isOutputStyleId, OUTPUT_STYLE_IDS, OUTPUT_STYLES, type OutputStyleId } from "./output-style.ts"; import type { MenuNumberEntry, MenuSection } from "./menu.ts"; import { isWordPackEnabled, isWordPackId, WORD_PACK_MENU_PREFIX, type WordPack } from "./word-packs.ts"; import { isPlainObject } from "./util.ts"; export const SETTING_COLOR_VALUES = ["accent", "border", "borderAccent", "success", "error", "warning"] as const; export const LOADER_COLOR_VALUES = [...SETTING_COLOR_VALUES, "text", "muted"] as const; export const THINKING_LEVEL_COLOR_VALUES = ["thinking-level", ...LOADER_COLOR_VALUES] as const; export type ThinkingLevelColor = (typeof THINKING_LEVEL_COLOR_VALUES)[number]; export const THINKING_LEVEL_SETTING_COLOR_VALUES = ["thinking-level", ...SETTING_COLOR_VALUES] as const; export type ThinkingLevelSettingColor = (typeof THINKING_LEVEL_SETTING_COLOR_VALUES)[number]; export const SPINNER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALUES; export type SpinnerColor = ThinkingLevelSettingColor; export const PROMPT_BORDER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALUES; export type PromptBorderColor = ThinkingLevelSettingColor; export const DONE_MARKER_BORDER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALUES; export type DoneMarkerBorderColor = ThinkingLevelSettingColor; const THINKING_LEVEL_COLOR_MIGRATION_VERSION = 2; const DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION = 3; const RESPONSE_MODEL_FOOTER_MIGRATION_VERSION = 4; export const SETTINGS_SCHEMA_VERSION = 4; export const BORDER_STYLE_VALUES = ["double", "single", "rounded", "heavy"] as const; export type BorderStyle = (typeof BORDER_STYLE_VALUES)[number]; export const DONE_MARKER_BORDER_STYLE_VALUES = [...BORDER_STYLE_VALUES, "none"] as const; export type DoneMarkerBorderStyle = (typeof DONE_MARKER_BORDER_STYLE_VALUES)[number]; export const DONE_MARKER_STYLE_VALUES = ["elite", "bookend"] as const; export type DoneMarkerStyle = (typeof DONE_MARKER_STYLE_VALUES)[number]; export const METER_UPPER_BOUND_TPS_PRESETS = ["10", "20", "30", "40", "50", "60", "80", "100", "150", "200", "300", "500", "1000"] as const; export function isBorderStyle(value: unknown): value is BorderStyle { return typeof value === "string" && BORDER_STYLE_VALUES.some(style => style === value); } export function isDoneMarkerBorderStyle(value: unknown): value is DoneMarkerBorderStyle { return typeof value === "string" && DONE_MARKER_BORDER_STYLE_VALUES.some(style => style === value); } export function isDoneMarkerStyle(value: unknown): value is DoneMarkerStyle { return typeof value === "string" && DONE_MARKER_STYLE_VALUES.some(style => style === value); } export function isThinkingLevelColor(value: unknown): value is ThinkingLevelColor { return typeof value === "string" && THINKING_LEVEL_COLOR_VALUES.some(color => color === value); } export function isThinkingLevelSettingColor(value: unknown): value is ThinkingLevelSettingColor { return typeof value === "string" && THINKING_LEVEL_SETTING_COLOR_VALUES.some(color => color === value); } export const isSpinnerColor = isThinkingLevelSettingColor; export const isPromptBorderColor = isThinkingLevelSettingColor; export const isDoneMarkerBorderColor = isThinkingLevelSettingColor; export function parseMeterUpperBoundTps(value: unknown): number | undefined { if (typeof value === "number") { return Number.isInteger(value) && value >= METER_UPPER_BOUND_TPS_MIN && value <= METER_UPPER_BOUND_TPS_MAX ? value : undefined; } if (typeof value !== "string" || !/^\s*\d+\s*$/.test(value)) return undefined; const parsed = Number(value); return parsed >= METER_UPPER_BOUND_TPS_MIN && parsed <= METER_UPPER_BOUND_TPS_MAX ? parsed : undefined; } export function fromCycleMeterUpperBound(value: unknown): number { return parseMeterUpperBoundTps(value) ?? DEFAULT_METER_UPPER_BOUND_TPS; } export interface DecoratorSettings { decorations: { animatedSpinner: boolean; shimmer: boolean; shimmerInverted: boolean; shimmerDirection: "ltr" | "rtl"; shimmerDirectionEnabled: boolean; shimmerSpeed: "slow" | "normal" | "fast"; shimmerSpeedEnabled: boolean; tokenActivityMonitor: boolean; meterDirection: "ltr" | "rtl"; meterDirectionEnabled: boolean; decorateUserPrompt: boolean; borderColor: PromptBorderColor; borderColorEnabled: boolean; borderStyle: BorderStyle; borderStyleEnabled: boolean; doneMarkerBorderStyle: DoneMarkerBorderStyle; doneMarkerBorderColor: DoneMarkerBorderColor; doneMarkerStyle: DoneMarkerStyle; doneMarkerModelColor: ThinkingLevelColor; doneMarkerModelDimmed: boolean; spinnerColor: SpinnerColor; spinnerColorEnabled: boolean; meterColor: ThinkingLevelColor; meterColorEnabled: boolean; meterDimmed: boolean; meterUpperBoundTps: number; tokenRateColor: ThinkingLevelColor; tokenRateDimmed: boolean; responseModelColor: ThinkingLevelColor; responseModelDimmed: boolean; promptIcon: boolean; promptTimestamp: boolean; promptProvider: boolean; promptModel: boolean; useNerdFont: boolean; }; features: { substituteDefaultMessage: boolean; elapsedTime: boolean; outputTokens: boolean; tokenRate: boolean; responseModel: boolean; responseModelFooter: boolean; doneMarker: boolean; doneMarkerIcon: boolean; randomizeDoneMarker: boolean; doneMarkerTokens: boolean; doneMarkerInputs: boolean; doneMarkerServiceTier: boolean; doneMarkerModel: boolean; includeDefaultWorkingText: boolean; }; loaderOrder: LoaderElement[]; wordPacks: Record; outputStyle: OutputStyleId; } export const DEFAULT_SETTINGS: DecoratorSettings = { decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "thinking-level", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "thinking-level", doneMarkerStyle: "elite", doneMarkerModelColor: "muted", doneMarkerModelDimmed: false, spinnerColor: "thinking-level", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, meterUpperBoundTps: DEFAULT_METER_UPPER_BOUND_TPS, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true }, features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerServiceTier: true, doneMarkerModel: true, includeDefaultWorkingText: true }, loaderOrder: [...DEFAULT_LOADER_ORDER], wordPacks: {}, outputStyle: "default", }; /** Menu key carrying the loader element order as a comma-joined list of element ids. */ export const LOADER_ORDER_ID = "loaderOrder"; /** Menu key for the output style cycle row. */ export const OUTPUT_STYLE_ID = "outputStyle"; const LOADER_ELEMENT_LABELS: Record = { spinner: "Animated spinner", text: "“Working” text", meter: "Token activity monitor", elapsed: "Elapsed time", tokens: "Output tokens", tokenRate: "Token rate", responseModel: "Response model", }; /** * Normalize an element order from disk or from the menu, tolerating hand edits and * version skew: unknown entries and duplicates are dropped, missing elements are * appended in their default positions. */ export function parseLoaderOrder(value: unknown): LoaderElement[] { const entries = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : []; const order: LoaderElement[] = []; for (const entry of entries) { const element = typeof entry === "string" ? entry.trim() as LoaderElement : undefined; if (element && DEFAULT_LOADER_ORDER.includes(element) && !order.includes(element)) order.push(element); } for (const element of DEFAULT_LOADER_ORDER) if (!order.includes(element)) order.push(element); return order; } export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); } /** pi-topping-insights' saved style, honored until pi-topping's own settings record one. */ function legacyOutputStyle(): OutputStyleId { try { const parsed: unknown = JSON.parse(readFileSync(join(getAgentDir(), "pi-topping-insights", "settings.json"), "utf8")); return (isPlainObject(parsed) ? fromLegacyOutputStyle(parsed.style) : undefined) ?? DEFAULT_SETTINGS.outputStyle; } catch { return DEFAULT_SETTINGS.outputStyle; } } function defaultSettings(): DecoratorSettings { return { ...structuredClone(DEFAULT_SETTINGS), outputStyle: legacyOutputStyle() }; } function mergeGroup>(defaults: T, parsed: unknown): T { const merged = { ...defaults }; if (!isPlainObject(parsed)) return merged; for (const [key, value] of Object.entries(parsed)) { if (!Object.hasOwn(merged, key)) continue; if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record)[key] = value; } return merged; } /** Convert a persisted or menu direction to a safe preview direction. */ export function fromCycleDirection(value: unknown): "ltr" | "rtl" { return value === "rtl" ? "rtl" : "ltr"; } /** Convert a persisted or menu speed to a safe preview speed. */ export function fromCycleSpeed(value: unknown): "slow" | "normal" | "fast" { return value === "slow" || value === "fast" ? value : "normal"; } type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Word Packs" | "Options"; type DecorationSettings = DecoratorSettings["decorations"]; type FeatureSettings = DecoratorSettings["features"]; type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings]; type MenuEntryBase = { id: string; label: string; section: MenuSectionName }; type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleValueLabels?: Readonly>; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string; numberEntry?: MenuNumberEntry }; type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleValueLabels?: never; cycleEnabledBy?: never; cycleDisabledValue?: never; numberEntry?: never }; type MenuEntry = DecorationMenuEntry | FeatureMenuEntry; const THINKING_LEVEL_CYCLE_LABELS = { "thinking-level": "thinkingLevel" } as const; const DIRECTION_CYCLE_LABELS = { ltr: "Left to Right", rtl: "Right to Left" } as const; const SPEED_CYCLE_LABELS = { slow: "Slow", normal: "Normal", fast: "Fast" } as const; export const MENU_ENTRIES: readonly MenuEntry[] = [ { id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" }, { id: "borderStyle", label: "Border style", section: "User Prompt", group: "decorations", key: "borderStyle", cycleValues: BORDER_STYLE_VALUES, cycleEnabledBy: "borderStyleEnabled", cycleDisabledValue: "double" }, { id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: PROMPT_BORDER_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS, cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "thinking-level" }, { id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" }, { id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" }, { id: "promptProvider", label: "Provider", section: "User Prompt", group: "decorations", key: "promptProvider" }, { id: "promptModel", label: "Model", section: "User Prompt", group: "decorations", key: "promptModel" }, { id: "animatedSpinner", label: "Animated spinner", section: "“Working” Loader", group: "decorations", key: "animatedSpinner" }, { id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: SPINNER_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS, cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "thinking-level" }, { id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "“Working” Loader", group: "features", key: "substituteDefaultMessage" }, { id: "shimmer", label: "Text shimmer", section: "“Working” Loader", group: "decorations", key: "shimmer" }, { id: "shimmerInverted", label: "Invert shimmer", section: "“Working” Loader", group: "decorations", key: "shimmerInverted" }, { id: "shimmerDirection", label: "Text shimmer direction", section: "“Working” Loader", group: "decorations", key: "shimmerDirection", cycleValues: ["ltr", "rtl"], cycleValueLabels: DIRECTION_CYCLE_LABELS, cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "ltr" }, { id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["slow", "normal", "fast"], cycleValueLabels: SPEED_CYCLE_LABELS, cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "normal" }, { id: "tokenActivityMonitor", label: "Token activity monitor", section: "“Working” Loader", group: "decorations", key: "tokenActivityMonitor" }, { id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS, cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" }, { id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["ltr", "rtl"], cycleValueLabels: DIRECTION_CYCLE_LABELS, cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "rtl" }, { id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" }, { id: "meterUpperBoundTps", label: "Token activity monitor upper bound", section: "“Working” Loader", group: "decorations", key: "meterUpperBoundTps", cycleValues: METER_UPPER_BOUND_TPS_PRESETS, numberEntry: { min: METER_UPPER_BOUND_TPS_MIN, max: METER_UPPER_BOUND_TPS_MAX, unit: "tps" } }, { id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" }, { id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" }, // id differs from key: the Elements Order row already owns "tokenRate" in the menu's shared value namespace. { id: "showTokenRate", label: "Token rate", section: "“Working” Loader", group: "features", key: "tokenRate" }, { id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS }, { id: "tokenRateDimmed", label: "Token rate dimmed", section: "“Working” Loader", group: "decorations", key: "tokenRateDimmed" }, // id differs from key: the Elements Order row already owns "responseModel" in the menu's shared value namespace. { id: "showResponseModel", label: "Response model", section: "“Working” Loader", group: "features", key: "responseModel" }, { id: "responseModelColor", label: "Response model color", section: "“Working” Loader", group: "decorations", key: "responseModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS }, { id: "responseModelDimmed", label: "Response model dimmed", section: "“Working” Loader", group: "decorations", key: "responseModelDimmed" }, { id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" }, { id: "doneMarkerStyle", label: "Marker style", section: "Completion Marker", group: "decorations", key: "doneMarkerStyle", cycleValues: DONE_MARKER_STYLE_VALUES }, { id: "doneMarkerBorderStyle", label: "Border style", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderStyle", cycleValues: DONE_MARKER_BORDER_STYLE_VALUES }, { id: "doneMarkerBorderColor", label: "Border color", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderColor", cycleValues: DONE_MARKER_BORDER_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS }, { id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" }, { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" }, { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" }, { id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" }, { id: "doneMarkerServiceTier", label: "Service tier", section: "Completion Marker", group: "features", key: "doneMarkerServiceTier" }, { id: "doneMarkerModel", label: "Response model", section: "Completion Marker", group: "features", key: "doneMarkerModel" }, { id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS }, { id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" }, { id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" }, { id: "showResponseModelFooter", label: "Response model footer", section: "Options", group: "features", key: "responseModelFooter" }, { id: "includeDefaultWorkingText", label: "Include default/random “Working” text", section: "Word Packs", group: "features", key: "includeDefaultWorkingText" }, ]; function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["items"][number] { const value = entry.group === "decorations" ? settings.decorations[entry.key] : settings.features[entry.key]; const cycleEnabled = entry.group === "decorations" && entry.cycleEnabledBy ? settings.decorations[entry.cycleEnabledBy] : undefined; return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleValueLabels: entry.cycleValueLabels, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, numberEntry: entry.numberEntry, value: typeof value === "number" ? String(value) : value }; } function isDecorationBooleanKey(key: keyof DecorationSettings): key is DecorationBooleanKey { return typeof DEFAULT_SETTINGS.decorations[key] === "boolean"; } function setDecorationCycleValue(decorations: DecorationSettings, key: keyof DecorationSettings, value: string): void { switch (key) { case "borderColor": case "spinnerColor": case "doneMarkerBorderColor": if (isThinkingLevelSettingColor(value)) decorations[key] = value; return; case "meterColor": case "tokenRateColor": case "responseModelColor": case "doneMarkerModelColor": if (isThinkingLevelColor(value)) decorations[key] = value; return; case "borderStyle": if (isBorderStyle(value)) decorations[key] = value; return; case "doneMarkerBorderStyle": if (isDoneMarkerBorderStyle(value)) decorations[key] = value; return; case "doneMarkerStyle": if (isDoneMarkerStyle(value)) decorations[key] = value; return; case "shimmerDirection": case "meterDirection": if (value === "ltr" || value === "rtl") decorations[key] = value; return; case "shimmerSpeed": if (value === "slow" || value === "normal" || value === "fast") decorations[key] = value; return; case "meterUpperBoundTps": { const bound = parseMeterUpperBoundTps(value); if (bound !== undefined) decorations[key] = bound; return; } default: // Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here. throw new Error(`Unhandled cycle setting: ${String(key)}`); } } function buildSection(title: MenuSectionName, settings: DecoratorSettings): MenuSection { return { title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) }; } export function buildMenuSections(settings: DecoratorSettings, bundledPacks: readonly WordPack[], userPacks: readonly WordPack[] = []): MenuSection[] { const packs = [...bundledPacks, ...userPacks]; const packItems = packs.map((pack) => ({ id: `${WORD_PACK_MENU_PREFIX}${pack.id}`, label: pack.name, value: isWordPackEnabled(pack.id, settings.wordPacks) })); const packIds = packItems.map(item => item.id); return [ buildSection("User Prompt", settings), buildSection("“Working” Loader", settings), { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) }, buildSection("Completion Marker", settings), { title: "Word Packs", items: [ ...packItems, ...buildSection("Word Packs", settings).items.map((item, index) => ({ ...item, spacerBefore: index === 0, disabledUnlessAnyOf: packIds })), ], }, { title: "Output Style", items: [{ id: OUTPUT_STYLE_ID, label: "Style", value: settings.outputStyle, cycleValues: OUTPUT_STYLE_IDS, cycleValueLabels: Object.fromEntries(OUTPUT_STYLE_IDS.map(id => [id, OUTPUT_STYLES[id].label])) }], }, buildSection("Options", settings), ]; } export function applyMenuResult(settings: DecoratorSettings, values: Record): DecoratorSettings { const next = structuredClone(settings); for (const entry of MENU_ENTRIES) { const value = values[entry.id]; if (value === undefined) continue; if (entry.group === "decorations" && entry.cycleValues && typeof value === "string" && (entry.cycleValues.includes(value) || entry.numberEntry !== undefined)) { setDecorationCycleValue(next.decorations, entry.key, value); if (entry.cycleEnabledBy) next.decorations[entry.cycleEnabledBy] = values[entry.cycleEnabledBy] !== false; } else if (entry.group === "decorations" && typeof value === "boolean" && isDecorationBooleanKey(entry.key)) { next.decorations[entry.key] = value; } else if (entry.group === "features" && typeof value === "boolean") { next.features[entry.key] = value; } } for (const [id, value] of Object.entries(values)) { if (!id.startsWith(WORD_PACK_MENU_PREFIX) || typeof value !== "boolean") continue; const packId = id.slice(WORD_PACK_MENU_PREFIX.length); if (isWordPackId(packId)) next.wordPacks[packId] = value; } if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]); const outputStyle = values[OUTPUT_STYLE_ID]; if (isOutputStyleId(outputStyle)) next.outputStyle = outputStyle; return next; } /** * Read settings.json, merging valid leaves over the defaults and falling back * to defaults on any read/parse error. When a cycle setting's enabled flag is * off, its stored value resets to the menu's disabled default so stale choices * don't resurface on re-enable. * Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and * `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker * border color is reset. Below 4, the response-model footer inherits the response-model * setting. A missing, non-integer, or out-of-range meter upper bound resolves * to the default of 80 tps. A legacy `features.simCityWorkingText: true` enables the * `simcity` pack when no explicit preference is stored. A missing or invalid `outputStyle` * falls back to `pi-topping-insights/settings.json`'s `style`, then to `default`, without a schema-version gate. */ export function loadSettings(): DecoratorSettings { try { const parsed = JSON.parse(readFileSync(settingsPath(), "utf8")); if (!isPlainObject(parsed)) return defaultSettings(); const wordPacks: Record = { ...DEFAULT_SETTINGS.wordPacks }; if (isPlainObject(parsed.wordPacks)) { for (const [id, enabled] of Object.entries(parsed.wordPacks)) { if (isWordPackId(id) && typeof enabled === "boolean") wordPacks[id] = enabled; } } if (isPlainObject(parsed.features) && parsed.features.simCityWorkingText === true && !("simcity" in wordPacks)) { wordPacks.simcity = true; } const storedOutputStyle = parsed.outputStyle; const outputStyle = isOutputStyleId(storedOutputStyle) ? storedOutputStyle : legacyOutputStyle(); const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks, outputStyle }; if (isPlainObject(parsed.decorations)) { for (const [key, value] of Object.entries(parsed.decorations)) { if (!Object.hasOwn(DEFAULT_SETTINGS.decorations, key) || typeof value !== "string") continue; const k = key as keyof DecorationSettings; if (isDecorationBooleanKey(k)) continue; setDecorationCycleValue(settings.decorations, k, value === "default" && (k === "spinnerColor" || k === "doneMarkerBorderColor") ? "thinking-level" : value); } const bound = parseMeterUpperBoundTps(parsed.decorations.meterUpperBoundTps); if (bound !== undefined) settings.decorations.meterUpperBoundTps = bound; } const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1; if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) { settings.decorations.spinnerColor = "thinking-level"; if (settings.decorations.borderColor === "borderAccent") settings.decorations.borderColor = "thinking-level"; } if (schemaVersion < DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION) { settings.decorations.doneMarkerBorderColor = "thinking-level"; } if (schemaVersion < RESPONSE_MODEL_FOOTER_MIGRATION_VERSION) { settings.features.responseModelFooter = settings.features.responseModel; } for (const entry of MENU_ENTRIES) { if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) { setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue); } } return settings; } catch { return defaultSettings(); } } export function atomicWriteFile(path: string, contents: string): void { mkdirSync(dirname(path), { recursive: true }); const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; try { writeFileSync(tmpPath, contents, { flag: "wx" }); renameSync(tmpPath, path); } catch (err) { try { unlinkSync(tmpPath); } catch { /* tmp file was never created */ } throw err; } } export function saveSettings(settings: DecoratorSettings): void { atomicWriteFile(settingsPath(), `${JSON.stringify({ schemaVersion: SETTINGS_SCHEMA_VERSION, ...settings }, null, 2)}\n`); }