// 配置加载与持久化 // 配置文件位置:~/.pi/agent/ccline/config.json(主题/段/style) // 模型映射: ~/.pi/agent/ccline/models.json(可选,覆盖默认 ModelConfig) import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import type { Config, ModelConfig, SegmentId } from "./types.ts"; import { getTheme, defaultThemeName } from "./themes.ts"; const DIR = "ccline"; /** 配置版本: * 1 = 初版(thinking/tokens/cost 默认关闭) * 2 = 新增 cache/session 段(tokens/cache/cost 曾一度默认显示) * 3 = 默认只保留 Model/Thinking/Directory/Git/Context Window,tokens/cache/cost 改回默认关闭 */ export const CONFIG_VERSION = 3; /** 各版本段的默认启用状态(逐版本迁移时用于识别“用户未改过”的段) */ const VERSION_ENABLED: Record>> = { 1: { model: true, directory: true, git: true, contextWindow: true, tokens: false, cost: false, thinking: false, }, 2: { model: true, thinking: true, directory: true, git: true, session: false, contextWindow: true, tokens: true, cache: true, cost: true, }, 3: { model: true, thinking: true, directory: true, git: true, session: false, contextWindow: true, tokens: false, cache: false, cost: false, }, }; function cclineDir(): string { // ~/.pi/agent/ccline return join(getAgentDir(), DIR); } function configPath(): string { return join(cclineDir(), "config.json"); } function modelsPath(): string { return join(cclineDir(), "models.json"); } /** 默认 ModelConfig(移植 CCometixLine 默认第三方模型 + 1M 修饰符) */ export function defaultModelConfig(): ModelConfig { return { models: [ { pattern: "glm-4.5", displayName: "GLM-4.5", contextLimit: 128_000 }, { pattern: "kimi-k2-turbo", displayName: "Kimi K2 Turbo", contextLimit: 128_000 }, { pattern: "kimi-k2", displayName: "Kimi K2", contextLimit: 128_000 }, { pattern: "qwen3-coder", displayName: "Qwen Coder", contextLimit: 256_000 }, ], contextModifiers: [{ pattern: "[1m]", displaySuffix: " 1M", contextLimit: 1_000_000 }], }; } /** 合并用户原始段配置(校验 id/enabled 等基本字段) */ function normalizeSegments(raw: unknown): Config["segments"] | undefined { if (!Array.isArray(raw) || raw.length === 0) return undefined; const result: Config["segments"] = []; for (const s of raw) { if (!s || typeof s !== "object" || typeof s.id !== "string") continue; result.push({ id: s.id as Config["segments"][number]["id"], enabled: s.enabled !== false, icon: { plain: s.icon?.plain ?? "", nerdFont: s.icon?.nerdFont ?? "", }, colors: { icon: s.colors?.icon, text: s.colors?.text, background: s.colors?.background, }, styles: { textBold: s.styles?.textBold === true }, options: s.options ?? {}, }); } return result; } /** 合并配置中的段与主题预设:已有段保留用户配置,缺失的新增段按主题规范顺序补齐 */ function mergeSegments(saved: Config["segments"], defaults: Config["segments"]): Config["segments"] { const savedById = new Map(saved.map((s) => [s.id, s])); const hasMissing = defaults.some((d) => !savedById.has(d.id)); if (!hasMissing) return saved; return defaults.map((d) => savedById.get(d.id) ?? d); } /** 逐版本套用默认值:仅当段的启用状态仍等于上一版默认值时,才更新为本版默认值 */ function migrateConfig(config: Config, fromVersion: number): Config { let segments = config.segments; for (let v = fromVersion; v < CONFIG_VERSION; v++) { const prev = VERSION_ENABLED[v]; const next = VERSION_ENABLED[v + 1]; if (!prev || !next) continue; segments = segments.map((s) => { const p = prev[s.id]; const n = next[s.id]; if (p === undefined || n === undefined || s.enabled !== p) return s; return { ...s, enabled: n }; }); } return { ...config, segments }; } /** 检测旧版 4 位 \uXXXX 转义残留的坏 NerdFont 图标 * 旧 bug:themes.ts 写 "\uf024b",JS 解析为 U+F024 + 字面 'b', * saveConfig 存盘后 icon.nerdFont 变成 2 字符(PUA + ASCII 字母/数字)。 * 合法 NerdFont 图标都是单个 PUA codepoint,不会是 PUA+ASCII 组合。 */ function isCorruptNerdIcon(nf: string): boolean { const chars = Array.from(nf); if (chars.length !== 2) return false; const cp0 = chars[0].codePointAt(0) ?? 0; const cp1 = chars[1].codePointAt(0) ?? 0; const isPUA = (cp0 >= 0xe000 && cp0 <= 0xf8ff) || (cp0 >= 0xf0000 && cp0 <= 0xffffd); const isAsciiVisible = cp1 >= 0x21 && cp1 <= 0x7e; return isPUA && isAsciiVisible; } /** Nerd Fonts v3 已移除的旧版图标码点(命中则回退到当前主题默认图标) */ const STALE_NERD_ICONS = new Set([ "\u{f5dc}", // Nerd Fonts v2 时代的 MDI brain 码点,v3 已移除 ]); /** 迁移段配置:修正坏/失效的 NerdFont 图标 + 补 contextWindow plain emoji 的 VS16,返回新 config 与是否变化 */ function migrateSegments(config: Config): { config: Config; changed: boolean } { const defaults = getTheme(config.theme); const defById = new Map(defaults.segments.map((s) => [s.id, s])); let anyChanged = false; const segments = config.segments.map((s) => { const def = defById.get(s.id); if (!def) return s; let plain = s.icon.plain; let nerdFont = s.icon.nerdFont; let segChanged = false; if ((isCorruptNerdIcon(nerdFont) || STALE_NERD_ICONS.has(nerdFont)) && def.icon.nerdFont !== nerdFont) { nerdFont = def.icon.nerdFont; // 用当前主题默认图标修正 segChanged = true; } if (s.id === "contextWindow" && plain === "⚡") { plain = "⚡️"; // 补 VS16 emoji 选择符,对齐 CCometixLine segChanged = true; } if (!segChanged) return s; anyChanged = true; return { ...s, icon: { plain, nerdFont } }; }); return { config: { ...config, segments }, changed: anyChanged }; } /** 加载完整配置:config.json 覆盖主题预设;models.json 覆盖默认 ModelConfig */ export function loadConfig(): { config: Config; modelConfig: ModelConfig; enabled: boolean } { let config: Config = getTheme(defaultThemeName()); let modelConfig: ModelConfig = defaultModelConfig(); let enabled = true; let needsSave = false; // 1. config.json try { if (existsSync(configPath())) { const raw = JSON.parse(readFileSync(configPath(), "utf8")); if (typeof raw.theme === "string") config = getTheme(raw.theme); if (raw.style) config = { ...config, style: { ...config.style, ...raw.style } }; const segs = normalizeSegments(raw.segments); if (segs) config = { ...config, segments: mergeSegments(segs, config.segments) }; if (raw.models) modelConfig = { ...modelConfig, ...raw.models }; if (raw.enabled === false) enabled = false; // 总开关 // 版本迁移:逐版本补齐新增段并对齐新默认值 const version = typeof raw.version === "number" ? raw.version : 1; if (version < CONFIG_VERSION) { config = { ...migrateConfig(config, version), version: CONFIG_VERSION }; needsSave = true; } } } catch { // 损坏的配置文件忽略,使用默认 } // 迁移:修正旧版 4 位 \uXXXX 转义残留的坏 NerdFont 图标,固化写回避免每次迁移 const migrated = migrateSegments(config); if (migrated.changed) { config = migrated.config; needsSave = true; } if (needsSave) saveConfig(config, enabled); // 2. models.json(优先级更高,独立文件便于单独维护) try { if (existsSync(modelsPath())) { const m = JSON.parse(readFileSync(modelsPath(), "utf8")); modelConfig = { ...modelConfig, ...m }; } } catch { // 忽略 } return { config, modelConfig, enabled }; } /** 保存配置(主题/段/style,可选 enabled 总开关) */ export function saveConfig(config: Config, enabled?: boolean): void { try { mkdirSync(cclineDir(), { recursive: true }); const payload = enabled === undefined ? { ...config, version: CONFIG_VERSION } : { ...config, version: CONFIG_VERSION, enabled }; writeFileSync(configPath(), JSON.stringify(payload, null, 2), "utf8"); } catch { // 忽略写入错误 } } /** 保存模型映射 */ export function saveModelConfig(mc: ModelConfig): void { try { mkdirSync(cclineDir(), { recursive: true }); writeFileSync(modelsPath(), JSON.stringify(mc, null, 2), "utf8"); } catch { // 忽略 } } /** 初始化默认配置文件(若不存在) */ export function initConfig(): void { try { mkdirSync(cclineDir(), { recursive: true }); } catch { // 忽略 } if (!existsSync(configPath())) saveConfig(getTheme(defaultThemeName())); if (!existsSync(modelsPath())) saveModelConfig(defaultModelConfig()); }