import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@mariozechner/pi-coding-agent"; export type SpawnAgent = "pi" | "codex" | "claude" | "cursor"; export interface SpawnConfig { defaultAgent: SpawnAgent; shortcut: string; commands: Record; defaultArgs: Record; worktree: boolean; worktreeBaseDir?: string; } export interface InteractiveShellConfig { exitAutoCloseDelay: number; overlayWidthPercent: number; overlayHeightPercent: number; focusShortcut: string; spawn: SpawnConfig; scrollbackLines: number; ansiReemit: boolean; handoffPreviewEnabled: boolean; handoffPreviewLines: number; handoffPreviewMaxChars: number; handoffSnapshotEnabled: boolean; handoffSnapshotLines: number; handoffSnapshotMaxChars: number; transferLines: number; transferMaxChars: number; completionNotifyLines: number; completionNotifyMaxChars: number; handsFreeUpdateMode: "on-quiet" | "interval"; handsFreeUpdateInterval: number; handsFreeQuietThreshold: number; autoExitGracePeriod: number; handsFreeUpdateMaxChars: number; handsFreeMaxTotalChars: number; minQueryIntervalSeconds: number; } const DEFAULT_SPAWN_CONFIG: SpawnConfig = { defaultAgent: "pi", shortcut: "alt+shift+p", commands: { pi: "pi", codex: "codex", claude: "claude", cursor: "agent", }, defaultArgs: { pi: [], codex: [], claude: [], cursor: ["--model", "composer-2-fast"], }, worktree: false, worktreeBaseDir: undefined, }; const DEFAULT_CONFIG: InteractiveShellConfig = { exitAutoCloseDelay: 10, overlayWidthPercent: 95, overlayHeightPercent: 60, focusShortcut: "alt+shift+f", spawn: DEFAULT_SPAWN_CONFIG, scrollbackLines: 5000, ansiReemit: true, handoffPreviewEnabled: true, handoffPreviewLines: 30, handoffPreviewMaxChars: 2000, handoffSnapshotEnabled: false, handoffSnapshotLines: 200, handoffSnapshotMaxChars: 12000, transferLines: 200, transferMaxChars: 20000, completionNotifyLines: 50, completionNotifyMaxChars: 5000, handsFreeUpdateMode: "on-quiet", handsFreeUpdateInterval: 60000, handsFreeQuietThreshold: 8000, autoExitGracePeriod: 15000, handsFreeUpdateMaxChars: 1500, handsFreeMaxTotalChars: 100000, minQueryIntervalSeconds: 60, }; export function loadConfig(cwd: string): InteractiveShellConfig { const projectPath = join(cwd, ".pi", "interactive-shell.json"); const globalPath = join(getAgentDir(), "interactive-shell.json"); let globalConfig: Partial = {}; let projectConfig: Partial = {}; if (existsSync(globalPath)) { try { globalConfig = JSON.parse(readFileSync(globalPath, "utf-8")); } catch (error) { console.error(`Warning: Could not parse ${globalPath}:`, error); } } if (existsSync(projectPath)) { try { projectConfig = JSON.parse(readFileSync(projectPath, "utf-8")); } catch (error) { console.error(`Warning: Could not parse ${projectPath}:`, error); } } const mergedSpawn = mergeSpawnConfig(globalConfig.spawn, projectConfig.spawn); const merged = { ...DEFAULT_CONFIG, ...globalConfig, ...projectConfig, spawn: mergedSpawn }; return { ...merged, exitAutoCloseDelay: clampInt(merged.exitAutoCloseDelay, DEFAULT_CONFIG.exitAutoCloseDelay, 0, 60), overlayWidthPercent: clampPercent(merged.overlayWidthPercent, DEFAULT_CONFIG.overlayWidthPercent), overlayHeightPercent: clampInt(merged.overlayHeightPercent, DEFAULT_CONFIG.overlayHeightPercent, 20, 90), focusShortcut: resolveShortcut(merged.focusShortcut, DEFAULT_CONFIG.focusShortcut), spawn: mergedSpawn, scrollbackLines: clampInt(merged.scrollbackLines, DEFAULT_CONFIG.scrollbackLines, 200, 50000), ansiReemit: merged.ansiReemit !== false, handoffPreviewEnabled: merged.handoffPreviewEnabled !== false, handoffPreviewLines: clampInt(merged.handoffPreviewLines, DEFAULT_CONFIG.handoffPreviewLines, 0, 500), handoffPreviewMaxChars: clampInt( merged.handoffPreviewMaxChars, DEFAULT_CONFIG.handoffPreviewMaxChars, 0, 50000, ), handoffSnapshotEnabled: merged.handoffSnapshotEnabled === true, handoffSnapshotLines: clampInt(merged.handoffSnapshotLines, DEFAULT_CONFIG.handoffSnapshotLines, 0, 5000), handoffSnapshotMaxChars: clampInt( merged.handoffSnapshotMaxChars, DEFAULT_CONFIG.handoffSnapshotMaxChars, 0, 200000, ), transferLines: clampInt(merged.transferLines, DEFAULT_CONFIG.transferLines, 10, 1000), transferMaxChars: clampInt(merged.transferMaxChars, DEFAULT_CONFIG.transferMaxChars, 1000, 100000), completionNotifyLines: clampInt(merged.completionNotifyLines, DEFAULT_CONFIG.completionNotifyLines, 10, 500), completionNotifyMaxChars: clampInt(merged.completionNotifyMaxChars, DEFAULT_CONFIG.completionNotifyMaxChars, 1000, 50000), handsFreeUpdateMode: merged.handsFreeUpdateMode === "interval" ? "interval" : "on-quiet", handsFreeUpdateInterval: clampInt( merged.handsFreeUpdateInterval, DEFAULT_CONFIG.handsFreeUpdateInterval, 5000, 300000, ), handsFreeQuietThreshold: clampInt( merged.handsFreeQuietThreshold, DEFAULT_CONFIG.handsFreeQuietThreshold, 1000, 30000, ), autoExitGracePeriod: clampInt( merged.autoExitGracePeriod, DEFAULT_CONFIG.autoExitGracePeriod, 5000, 120000, ), handsFreeUpdateMaxChars: clampInt( merged.handsFreeUpdateMaxChars, DEFAULT_CONFIG.handsFreeUpdateMaxChars, 500, 50000, ), handsFreeMaxTotalChars: clampInt( merged.handsFreeMaxTotalChars, DEFAULT_CONFIG.handsFreeMaxTotalChars, 10000, 1000000, ), minQueryIntervalSeconds: clampInt( merged.minQueryIntervalSeconds, DEFAULT_CONFIG.minQueryIntervalSeconds, 5, 300, ), }; } function mergeSpawnConfig(globalValue: unknown, projectValue: unknown): SpawnConfig { const globalSpawn = isPlainObject(globalValue) ? globalValue : undefined; const projectSpawn = isPlainObject(projectValue) ? projectValue : undefined; const globalCommands = isPlainObject(globalSpawn?.commands) ? globalSpawn.commands : undefined; const projectCommands = isPlainObject(projectSpawn?.commands) ? projectSpawn.commands : undefined; const globalArgs = isPlainObject(globalSpawn?.defaultArgs) ? globalSpawn.defaultArgs : undefined; const projectArgs = isPlainObject(projectSpawn?.defaultArgs) ? projectSpawn.defaultArgs : undefined; const mergedCommands = { pi: resolveCommand(projectCommands?.pi ?? globalCommands?.pi, DEFAULT_SPAWN_CONFIG.commands.pi), codex: resolveCommand(projectCommands?.codex ?? globalCommands?.codex, DEFAULT_SPAWN_CONFIG.commands.codex), claude: resolveCommand(projectCommands?.claude ?? globalCommands?.claude, DEFAULT_SPAWN_CONFIG.commands.claude), cursor: resolveCommand(projectCommands?.cursor ?? globalCommands?.cursor, DEFAULT_SPAWN_CONFIG.commands.cursor), }; const mergedDefaultArgs = { pi: resolveStringArray(projectArgs?.pi ?? globalArgs?.pi, DEFAULT_SPAWN_CONFIG.defaultArgs.pi), codex: resolveStringArray(projectArgs?.codex ?? globalArgs?.codex, DEFAULT_SPAWN_CONFIG.defaultArgs.codex), claude: resolveStringArray(projectArgs?.claude ?? globalArgs?.claude, DEFAULT_SPAWN_CONFIG.defaultArgs.claude), cursor: resolveStringArray(projectArgs?.cursor ?? globalArgs?.cursor, DEFAULT_SPAWN_CONFIG.defaultArgs.cursor), }; return { defaultAgent: resolveSpawnAgent(projectSpawn?.defaultAgent ?? globalSpawn?.defaultAgent, DEFAULT_SPAWN_CONFIG.defaultAgent), shortcut: resolveShortcut(projectSpawn?.shortcut ?? globalSpawn?.shortcut, DEFAULT_SPAWN_CONFIG.shortcut), commands: mergedCommands, defaultArgs: mergedDefaultArgs, worktree: resolveBoolean(projectSpawn?.worktree ?? globalSpawn?.worktree, DEFAULT_SPAWN_CONFIG.worktree), worktreeBaseDir: resolveOptionalString(projectSpawn?.worktreeBaseDir ?? globalSpawn?.worktreeBaseDir), }; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function resolveSpawnAgent(value: unknown, fallback: SpawnAgent): SpawnAgent { return value === "pi" || value === "codex" || value === "claude" || value === "cursor" ? value : fallback; } function resolveCommand(value: unknown, fallback: string): string { return resolveShortcut(typeof value === "string" ? value : undefined, fallback); } function resolveStringArray(value: unknown, fallback: string[]): string[] { if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) return fallback; return value; } function resolveBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } function resolveOptionalString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function clampPercent(value: number | undefined, fallback: number): number { if (typeof value !== "number" || Number.isNaN(value)) return fallback; return Math.min(100, Math.max(10, value)); } function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { if (typeof value !== "number" || Number.isNaN(value)) return fallback; const rounded = Math.trunc(value); return Math.min(max, Math.max(min, rounded)); } function resolveShortcut(value: string | undefined, fallback: string): string { if (typeof value !== "string") return fallback; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : fallback; }