import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; export const DEFAULT_CONFIG = { BOX_PAD_X: 1, MENU_GAP: 0, EXTRA_MENU_INDENT: 1, BOXED_VIEW: true, COMPANION_ENABLED: false, COMPANION_COLOR: "accent" as const, COMPANION_TOP_PADDING: 3, }; interface ChatInputUserConfig { boxedView?: boolean; boxPadX?: number; menuGap?: number; extraMenuIndent?: number; companion?: { enabled?: boolean; color?: string; type?: string; ears?: string; types?: Array<{ typeName: string; top: string; }>; }; } /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */ export function configPath(): string { return join(getAgentDir(), "configs", "opl-input.json"); } /** Subset of fields the configurator tab is allowed to edit. */ export interface EditableInputConfig { boxedView?: boolean; companion?: { enabled?: boolean; type?: string; }; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Read the live config as a loose record ({} on absence or malformed), preserving unknown keys. */ export function readInputConfig(): Record { try { const parsed = JSON.parse(readFileSync(configPath(), "utf8")); return isPlainObject(parsed) ? parsed : {}; } catch { return {}; } } /** Merge only the editable fields into the live file. Returns false when nothing changed. */ export function saveInputConfig(patch: EditableInputConfig): boolean { const current = readInputConfig(); const next: Record = { ...current }; if (typeof patch.boxedView === "boolean") next.boxedView = patch.boxedView; if (patch.companion) { const enabled = patch.companion.enabled; const type = patch.companion.type; const hasEnabled = typeof enabled === "boolean"; const hasType = typeof type === "string" && type.length > 0; if (hasEnabled || hasType) { const companion = isPlainObject(next.companion) ? { ...next.companion } : {}; if (hasEnabled) companion.enabled = enabled; if (hasType) companion.type = type; next.companion = companion; } } if (JSON.stringify(next) === JSON.stringify(current)) return false; const file = configPath(); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(next, null, 2) + "\n", "utf8"); return true; } function loadUserConfig(): ChatInputUserConfig { try { const raw = readFileSync(configPath(), "utf8"); return JSON.parse(raw) as ChatInputUserConfig; } catch { return {}; } } const userConfig = loadUserConfig(); export const CONFIG = { BOX_PAD_X: userConfig.boxPadX ?? DEFAULT_CONFIG.BOX_PAD_X, MENU_GAP: userConfig.menuGap ?? DEFAULT_CONFIG.MENU_GAP, EXTRA_MENU_INDENT: userConfig.extraMenuIndent ?? DEFAULT_CONFIG.EXTRA_MENU_INDENT, BOXED_VIEW: userConfig.boxedView ?? DEFAULT_CONFIG.BOXED_VIEW, COMPANION_ENABLED: userConfig.companion?.enabled ?? DEFAULT_CONFIG.COMPANION_ENABLED, COMPANION_COLOR: (userConfig.companion?.color ?? DEFAULT_CONFIG.COMPANION_COLOR) as string, COMPANION_TOP_PADDING: DEFAULT_CONFIG.COMPANION_TOP_PADDING, COMPANION_EARS: resolveEars(userConfig.companion), }; function resolveEars(companion?: ChatInputUserConfig["companion"]): string { if (companion?.ears) return companion.ears; if (companion?.type && companion?.types) { const found = companion.types.find((t) => t.typeName === companion.type); if (found) return found.top; } if (companion?.type === "dog") return " /)_(\\ "; return " /\\_/\\ "; } // Non-user-configurable constants export const COMPANION_PADDING = 3; export const MIN_WIDTH_FOR_COMPANION = 40; // ── animation timing (all in ms) ──────────────────────────────────── export const DIP_INTERVAL_MS = 4000; export const RISE_INTERVAL_MS = 8000; export const EARS_MIN_DURATION_MS = 2000; export const EARS_MAX_DURATION_MS = 4000; export const FULL_MIN_DURATION_MS = 3000; export const FULL_MAX_DURATION_MS = 23000; export const NONE_MIN_DURATION_MS = 800; export const NONE_MAX_DURATION_MS = 2000; export const FACE_MIN_DURATION_MS = 6000; export const FACE_MAX_DURATION_MS = 36000; // expression cycling export const EXPR_MIN_DURATION_MS = 2000; export const EXPR_MAX_DURATION_MS = 5500; export const STARE_MIN_DURATION_MS = 8000; export const STARE_MAX_DURATION_MS = 13000; export const STARE_CHANCE = 0.15; export const BLINK_MIN_DURATION_MS = 80; export const BLINK_MAX_DURATION_MS = 330; // expression transition: blink vs instant vs double-blink export const EXPR_BLINK_CHANCE = 0.50; export const EXPR_DOUBLE_BLINK_CHANCE = 0; // disabled export const DOUBLE_BLINK_GAP_MIN_MS = 80; export const DOUBLE_BLINK_GAP_MAX_MS = 160; // wobble (ears phase) export const WOBBLE_RANGE = 12; export const WOBBLE_MIN_INTERVAL_MS = 200; export const WOBBLE_MAX_INTERVAL_MS = 600; export const DIR_STEPS_MIN = 2; export const DIR_STEPS_MAX = 5; export const EDGE_BIAS_STRENGTH = 0.45; export const EDGE_PAUSE_MIN_MS = 300; export const EDGE_PAUSE_MAX_MS = 800; // face micro-drift export const FACE_DRIFT_RANGE = 3; export const FACE_DRIFT_MIN_INTERVAL_MS = 4000; export const FACE_DRIFT_MAX_INTERVAL_MS = 10000; // phase transitions export const EARS_TO_NONE_CHANCE = 0.15; export const EARS_TO_FULL_CHANCE = 0.425; // remainder = face export const FULL_TO_EARS_CHANCE = 0.15; export const FULL_TO_NONE_CHANCE = 0.10; // remainder = face // transition frames export const SLOW_TRANSITION_CHANCE = 0.2; export const SLOW_TRANSITION_MULT_MIN = 2; export const SLOW_TRANSITION_MULT_MAX = 3; export const BLINK_ART: [string, string, string] = [" /\\_/\\ ", "( -.- )", " | | "]; const EARS = CONFIG.COMPANION_EARS; export const COMPANION_ARTS: [string, string, string][] = [ [EARS, "( ⌒.⌒ )", " | | "], // happy [EARS, "( o.o )", " | | "], // original [EARS, "( ^.^ )", " | | "], // happy [EARS, "( O.O )", " | | "], // awake [EARS, "( o.- )", " | | "], // winking [EARS, "( >.< )", " | | "], // closed [EARS, "( o.O )", " | | "], // curious [EARS, "( *.* )", " | | "], // sparkle [EARS, "( ᴗ.ᴗ )", " | | "], // unimpressed [EARS, "( ω.ω )", " | | "], // joyful ];