import { validateConfigLayer } from "../domain/config-normalization.js"; import type { NormalizedPiStyleConfig } from "../domain/config-types.js"; import { type ConfigFilePort, type ConfigStoragePaths, writeScopedConfig } from "./config-storage.js"; export type Mutation = Record; export interface CommandUi { select(title: string, options: string[]): Promise; notify(message: string, type?: "info" | "warning" | "error"): void; } export interface CommandHost { readonly ui: CommandUi; readonly cwd: string; isProjectTrusted(): boolean; } export interface CommandApp { readonly config: NormalizedPiStyleConfig; applySession(patch: Mutation): void; setProjectTrusted?(trusted: boolean): void; reload(): Promise; doctor(): Readonly>; } const surfaces = new Set(["status", "editor", "startup", "messages", "tools"]); const presets = ["default", "minimal", "compact", "full", "ascii", "native"]; const styles = ["compact", "boxed", "dock", "native"]; const frames = ["auto", "halfblock", "line", "solid", "outline", "native"]; const startupModes = ["off", "compact", "overlay"]; const allowedPaths = new Set([ "enabled", "preset", "placement", "startup.mode", "startup.showResources", "statusLine.enabled", "statusLine.separator", "statusLine.layout.left", "statusLine.layout.right", "statusLine.layout.secondary", "statusLine.disabledSegments", "statusLine.customItems", "statusLine.bottomMargin", "statusLine.contextBarWidth", "editor.enabled", "editor.style", "editor.frame", "editor.showMetadata", "messages.enabled", "messages.assistantPrefix", "messages.specialBlocks", "messages.hideThinkingLabel", "messages.thoughtSummary", "messages.showImagePreviews", "messages.clipboardImages", "messages.previewMaxWidth", "tools.enabled", "tools.style", "tools.maxCollapsedLines", "tools.maxExpandedLines", "tools.dimOutput", "tools.showElapsed", "tools.collapseAfterTurn", "tools.collapseMutatingTools", "theme.nerdFonts", "theme.terminalBackgroundSync", "theme.autoApply", "theme.colors", "theme.glyphs", "compatibility.allowSafePatches", "compatibility.allowCorePatches", "compatibility.preferExistingEditor", "compatibility.preferExistingFooter", "debug", ]); function validatePathValue(path: string, value: unknown): boolean { if (!allowedPaths.has(path)) return false; if ( [ "enabled", "startup.showResources", "statusLine.enabled", "editor.enabled", "editor.showMetadata", "messages.enabled", "messages.assistantPrefix", "messages.specialBlocks", "messages.hideThinkingLabel", "messages.thoughtSummary", "messages.showImagePreviews", "messages.clipboardImages", "messages.previewMaxWidth", "tools.enabled", "tools.showElapsed", "tools.dimOutput", "tools.collapseAfterTurn", "tools.collapseMutatingTools", "compatibility.allowSafePatches", "compatibility.allowCorePatches", "compatibility.preferExistingEditor", "compatibility.preferExistingFooter", "debug", ].includes(path) ) return typeof value === "boolean"; if (path === "preset") return typeof value === "string" && presets.includes(value); if (path === "placement") return value === "above" || value === "below"; if (path === "startup.mode") return typeof value === "string" && startupModes.includes(value); if (path === "editor.style") return typeof value === "string" && styles.includes(value); if (path === "editor.frame") return typeof value === "string" && frames.includes(value); if (path === "theme.autoApply") return typeof value === "string" && value !== ""; if (["theme.nerdFonts", "theme.terminalBackgroundSync", "statusLine.separator", "tools.style"].includes(path)) return typeof value === "string"; if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines") return typeof value === "number" && Number.isFinite(value) && value >= 0; if (path === "messages.previewMaxWidth") return typeof value === "number" && Number.isFinite(value) && value >= 8 && value <= 60; if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth") return typeof value === "number" && Number.isFinite(value) && value >= 0; if (path.endsWith(".colors") || path.endsWith(".glyphs")) return ( typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string") ); if (path.includes("layout.") || path === "statusLine.disabledSegments") return Array.isArray(value) && value.every((item) => typeof item === "string"); return ( path === "statusLine.customItems" && validateConfigLayer({ statusLine: { customItems: value } }).diagnostics.length === 0 ); } function validateMutation(patch: Mutation, prefix = ""): boolean { return Object.entries(patch).every(([key, value]) => { const path = prefix ? `${prefix}.${key}` : key; if ( [ "theme.colors", "theme.glyphs", "statusLine.layout.left", "statusLine.layout.right", "statusLine.layout.secondary", "statusLine.disabledSegments", "statusLine.customItems", ].includes(path) ) return validatePathValue(path, value); if (typeof value === "object" && value !== null && !Array.isArray(value)) return Object.keys(value).length > 0 && validateMutation(value as Mutation, path); return validatePathValue(path, value); }); } function sanitizeMutation(patch: Mutation): Mutation | undefined { return validateMutation(patch) ? patch : undefined; } function mutation(parts: readonly string[]): Mutation | undefined { if (parts[0] === "set" && parts[1] && parts[2]) { try { const value = JSON.parse(parts.slice(2).join(" ")) as unknown; const patch: Mutation = {}; let cursor = patch; const keys = parts[1].split("."); for (const key of keys.slice(0, -1)) { const next = cursor[key]; if (typeof next === "object" && next !== null && !Array.isArray(next)) cursor = next as Mutation; else { cursor[key] = {}; cursor = cursor[key] as Mutation; } } cursor[keys.at(-1) as string] = value; return sanitizeMutation(patch); } catch { return undefined; } } const [action, value, extra] = parts; if (action === "on" || action === "off") return { enabled: action === "on" }; if (action === "preset" && value && presets.includes(value)) return { preset: value }; if (action === "placement" && (value === "above" || value === "below")) return { placement: value }; if (action === "editor" && value && styles.includes(value) && (!extra || frames.includes(extra))) return { editor: { style: value, ...(extra ? { frame: extra } : {}) } }; if (action === "startup" && value && startupModes.includes(value)) return { startup: { mode: value } }; if (action === "surface" && value && surfaces.has(value) && (extra === "on" || extra === "off")) { if (value === "startup") return { startup: { mode: extra === "on" ? "compact" : "off" } }; return { [value === "status" ? "statusLine" : value]: { enabled: extra === "on" } }; } return undefined; } export async function executePiStyleCommand( args: string, host: CommandHost, app: CommandApp, storage: { readonly port: ConfigFilePort; readonly paths: ConfigStoragePaths }, ): Promise { const parts = args.trim().split(/\s+/).filter(Boolean); const [action, scope, ...rest] = parts; if (!action) { host.ui.notify( `pi-style preset ${app.config.preset}; status ${app.config.statusLine.enabled ? "on" : "off"}`, "info", ); return; } if (action === "reload") { await app.reload(); return; } if (action === "doctor") { host.ui.notify(JSON.stringify(app.doctor()), "info"); return; } if (action === "persist") { if (scope !== "global" && scope !== "project") { host.ui.notify("persistence requires explicit global or project scope", "warning"); return; } if (scope === "project" && !host.isProjectTrusted()) { host.ui.notify("pi-style project persistence requires a trusted project", "warning"); return; } const patch = mutation(rest); if (!patch) { host.ui.notify("invalid pi-style mutation or value", "warning"); return; } const sanitized = sanitizeMutation(patch); if (!sanitized) { host.ui.notify("mutation contains fields that cannot be persisted", "warning"); return; } try { await writeScopedConfig( storage.port, scope === "global" ? storage.paths.globalPath : storage.paths.projectPath, sanitized, ); } catch (error) { host.ui.notify( `pi-style settings write failed: ${error instanceof Error ? error.message : "unknown error"}`, "error", ); return; } app.applySession(patch); if ( (patch.messages as Record | undefined)?.enabled === true || (patch.tools as Record | undefined)?.enabled === true ) host.ui.notify("desired Tier C state stored; awaiting session authorization", "info"); return; } const patch = mutation(parts); if (patch) app.applySession(patch); else if (action === "preset" && !scope) { const selected = await host.ui.select("pi-style preset", presets); if (selected) app.applySession({ preset: selected }); } else host.ui.notify("invalid pi-style command or value", "warning"); }