/** * User configuration loading. * * Reads `~/.pi/agent/lazy-panel.json` (if present) and merges it over the * built-in defaults. Only this module should know where the file lives. * * 用户配置示例(所有字段可选): * { * "locale": "zh", * "defaultScope": "all", * "keymap": { * "global": { "help": "F1", "scope-all": ["A", "ctrl+space"] }, * "sessions": { "session-delete": "ctrl+d", "session-share": null } * } * } * 同一个 action 的用户键位会整体替换默认键位;值为 null 表示解绑。 * locale 缺省时按系统语言自动检测(见 i18n/index.ts)。 */ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { CONFIG_FILE_NAME, KEY_SCOPES, LEFT_COLUMN_RATIO, LEFT_COLUMN_RATIO_MAX, LEFT_COLUMN_RATIO_MIN, SESSION_SORT_MODES } from "../constants.ts"; import { type Locale, SUPPORTED_LOCALES } from "../i18n/index.ts"; import type { ActionId, KeyChord, Keymap, KeyScope, PaneKeymap, SessionSortMode, UserConfig, UserPaneKeymap } from "../types.ts"; import { isReservedKey } from "./keys.ts"; import type { ConfigSaveResult, ConfigSnapshot, SettingsPatch } from "./config-store.ts"; import { readSnapshot, savePatch } from "./config-store.ts"; import { DEFAULT_KEYMAP } from "./keymap.ts"; export type { ConfigSaveResult, ConfigSnapshot, SettingsPatch } from "./config-store.ts"; export function readConfigSnapshot(agentDir: string): Promise { return readSnapshot(join(agentDir, CONFIG_FILE_NAME)); } export function saveConfigPatch(agentDir: string, baseline: ConfigSnapshot, patch: SettingsPatch): Promise { return savePatch(join(agentDir, CONFIG_FILE_NAME), baseline, patch); } /** Fully resolved configuration used at runtime. */ export interface ResolvedConfig { keymap: Keymap; /** UI 语言;缺省(未在用户配置里指定)时按系统语言自动检测。 */ locale?: Locale; defaultScope: NonNullable; defaultSort: NonNullable; leftColumnRatio: number; /** Non-fatal problems found while reading the user file (shown in the footer). */ warnings: string[]; } export const DEFAULT_CONFIG: ResolvedConfig = { keymap: DEFAULT_KEYMAP, defaultScope: "current-folder", defaultSort: "recent", leftColumnRatio: LEFT_COLUMN_RATIO, warnings: [], }; /** * Load user config from disk and merge over defaults. * A missing file yields the defaults; a broken file yields the defaults plus a warning. * @param agentDir pi agent directory (usually ~/.pi/agent) */ export async function loadConfig(agentDir: string): Promise { const file = join(agentDir, CONFIG_FILE_NAME); let raw: string; try { raw = await readFile(file, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_CONFIG; return { ...DEFAULT_CONFIG, warnings: [`config: cannot read ${file}: ${(err as Error).message}`] }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { return { ...DEFAULT_CONFIG, warnings: [`config: invalid JSON in ${file}: ${(err as Error).message}`] }; } return resolveConfig(parsed); } /** Pure merge step, separated from I/O so it can be unit-tested. */ export function resolveConfig(user: unknown): ResolvedConfig { const warnings: string[] = []; if (!isRecord(user)) { return { ...DEFAULT_CONFIG, warnings: ["config: top level must be an object"] }; } const u = user as UserConfig; // locale 只接受我们发布的语言(en / zh),非法值退回系统语言检测(不写入 locale)。 let locale: Locale | undefined; if (u.locale !== undefined) { if ((SUPPORTED_LOCALES as readonly string[]).includes(u.locale as string)) { locale = u.locale; } else { warnings.push(`config: unknown locale "${String(u.locale)}"`); } } const defaultScope = u.defaultScope === "all" || u.defaultScope === "current-folder" ? u.defaultScope : DEFAULT_CONFIG.defaultScope; if (u.defaultScope !== undefined && defaultScope !== u.defaultScope) warnings.push(`config: unknown defaultScope "${String(u.defaultScope)}"`); const defaultSort = (SESSION_SORT_MODES as readonly string[]).includes(u.defaultSort as string) ? (u.defaultSort as SessionSortMode) : DEFAULT_CONFIG.defaultSort; if (u.defaultSort !== undefined && defaultSort !== u.defaultSort) warnings.push(`config: unknown defaultSort "${String(u.defaultSort)}"`); let leftColumnRatio = DEFAULT_CONFIG.leftColumnRatio; if (u.leftColumnRatio !== undefined) { if (typeof u.leftColumnRatio === "number" && u.leftColumnRatio >= LEFT_COLUMN_RATIO_MIN && u.leftColumnRatio <= LEFT_COLUMN_RATIO_MAX) { leftColumnRatio = u.leftColumnRatio; } else { warnings.push("config: leftColumnRatio must be a number between 0.15 and 0.6"); } } const keymap = mergeKeymap(DEFAULT_KEYMAP, u.keymap, warnings); // 主题不在这里配置:面板没有自己的主题,颜色一律取自 pi,主题选择写进 pi 的 // settings.json(见 docs/theme.md)。写了这一项的人多半是以为面板能单独设主题。 if (u.theme !== undefined) { warnings.push("config: \"theme\" is not a plugin setting; the panel follows pi's theme (set it with pi's /settings)"); } // locale 未指定时不写入该键(exactOptionalPropertyTypes),运行时按系统语言。 return { keymap, defaultScope, defaultSort, leftColumnRatio, warnings, ...(locale !== undefined ? { locale } : {}) }; } /** * Deep-merge the user keymap over the defaults, scope by scope, action by action. * 用户为某个 action 提供的键位整体替换默认值(不是追加),null 表示解绑。 */ export function mergeKeymap(base: Keymap, user: UserConfig["keymap"], warnings: string[] = []): Keymap { const out = {} as Keymap; for (const scope of KEY_SCOPES) out[scope] = { ...base[scope] }; if (user === undefined) return out; if (!isRecord(user)) { warnings.push("config: keymap must be an object"); return out; } for (const [scopeName, paneMap] of Object.entries(user)) { if (!KEY_SCOPES.includes(scopeName as KeyScope)) { warnings.push(`config: unknown keymap scope "${scopeName}"`); continue; } if (!isRecord(paneMap)) { warnings.push(`config: keymap.${scopeName} must be an object`); continue; } const target: PaneKeymap = out[scopeName as KeyScope]; for (const [action, value] of Object.entries(paneMap as UserPaneKeymap)) { if (value === null) { delete target[action as ActionId]; continue; } if (!isChordValue(value)) { warnings.push(`config: keymap.${scopeName}.${action} must be a string, string[] or null`); continue; } // Esc / Backspace 是保留键(见 keys.ts 的 RESERVED_KEYS):写进来也不会触发, // 这里丢掉并提示,而不是留一条看起来生效的失效绑定。 const chords = (Array.isArray(value) ? value : [value]).filter((chord) => { if (!isReservedKey(chord)) return true; warnings.push(`config: keymap.${scopeName}.${action}: "${chord}" is reserved by the panel and cannot be bound`); return false; }); const kept: KeyChord | KeyChord[] | undefined = chords.length === 0 ? undefined : Array.isArray(value) ? chords : chords[0]; if (kept !== undefined) target[action as ActionId] = kept; } } return out; } function isChordValue(v: unknown): v is KeyChord | KeyChord[] { if (typeof v === "string") return v.trim().length > 0; return Array.isArray(v) && v.length > 0 && v.every((c) => typeof c === "string" && c.trim().length > 0); } function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); }