/** * /codemode configuration TUI. * * Shows the effective merged configuration (defaults ← global file ← project * file ← env overrides, with per-setting sources), lets the operator edit * every supported setting, validates with the existing TypeBox schema, and * persists to `.pi/codemode.json` (project) or `~/.pi/agent/codemode.json` * (global). Degrades gracefully headless: prints the effective settings and * a hint, never crashes. */ import { mkdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { CONFIG_DIR_NAME, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Check } from "typebox/value"; import { codemodeSettingsSchema, loadCodemodeSettings, resolveEnabledLanguages, type CodemodeSettingsInput, type LoadedCodemodeSettings, } from "./settings.ts"; import { createInterpreterDetector, type InterpreterDetection } from "../interpreters/detect.ts"; type Draft = ResolvedDraft; type ResolvedDraft = { languages: { py: boolean; js: boolean; rb: boolean }; cellTimeoutSeconds: number; bridgeTimeoutSeconds: number; parallelPoolWidth: number; hardenedCells: boolean; jitless: boolean; taskTools: { task: string; output: string }; outputSink: { headBytes: number; maxColumns: number; chunkThrottleMs?: number }; statusEvents: boolean; promotion: { enabled: boolean }; }; function toDraft(loaded: LoadedCodemodeSettings): Draft { const settings = loaded.settings; return structuredClone({ languages: settings.languages, cellTimeoutSeconds: settings.cellTimeoutSeconds, bridgeTimeoutSeconds: settings.bridgeTimeoutSeconds, parallelPoolWidth: settings.parallelPoolWidth, hardenedCells: settings.hardenedCells, jitless: settings.jitless, taskTools: settings.taskTools, outputSink: settings.outputSink, statusEvents: settings.statusEvents, promotion: settings.promotion, }); } function draftToInput(draft: Draft): CodemodeSettingsInput { return { languages: { ...draft.languages }, cellTimeoutSeconds: draft.cellTimeoutSeconds, bridgeTimeoutSeconds: draft.bridgeTimeoutSeconds, parallelPoolWidth: draft.parallelPoolWidth, hardenedCells: draft.hardenedCells, jitless: draft.jitless, taskTools: { ...draft.taskTools }, outputSink: { ...draft.outputSink }, statusEvents: draft.statusEvents, promotion: { ...draft.promotion }, }; } function detectionLabel(detection: InterpreterDetection): string { return detection.ok ? `${detection.version} (${detection.path})` : "not detected"; } /** Headless output: effective settings JSON plus a hint; never crashes. */ export function printEffectiveSettings( loaded: LoadedCodemodeSettings, envOverrides: Record ): void { const output = { source: loaded.source, settings: loaded.settings, sources: loaded.sources, envOverrides, hint: "run /codemode in a TUI session to edit settings interactively", }; process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); } export async function openCodemodeMenu( ctx: ExtensionContext, cwd: string, env: NodeJS.ProcessEnv = process.env ): Promise { const loaded = await loadCodemodeSettings({ cwd, homeDir: homedir() }); const envLanguages = resolveEnabledLanguages(loaded.settings, env); const envOverrides: Record = {}; for (const key of ["PI_CODEMODE_PY", "PI_CODEMODE_JS", "PI_CODEMODE_RB"] as const) { if (env[key] !== undefined) { envOverrides[key] = env[key] ?? ""; } } if (!ctx.hasUI) { printEffectiveSettings(loaded, envOverrides); return; } const detector = createInterpreterDetector(); const detections = { py: await detector.detect("py"), js: await detector.detect("js"), rb: await detector.detect("rb"), }; let draft = toDraft(loaded); let sources = { ...loaded.sources }; const markEdited = (key: string): void => { sources = { ...sources, [key]: "edited" }; }; const languageLabel = (d: Draft): string => { const parts: string[] = []; for (const lang of ["py", "js", "rb"] as const) { const effective = envLanguages[lang]; const envMark = env[`PI_CODEMODE_${lang.toUpperCase()}`] !== undefined ? " (env)" : ""; const source = sources[`languages.${lang}`] ?? "default"; parts.push( `${lang}=${d.languages[lang] ? "on" : "off"}${envMark} [${source}] · ${detectionLabel(detections[lang])}` ); } return parts.join(" | "); }; const save = async (path: string, label: string): Promise => { const input = draftToInput(draft); if (!Check(codemodeSettingsSchema, input)) { ctx.ui.notify("Refused: the draft does not validate against the codemode settings schema.", "error"); return; } await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, `${JSON.stringify(input, null, 2)}\n`, "utf8"); ctx.ui.notify(`Saved codemode settings to ${label}.`, "info"); }; let close = false; while (!close) { const languageLabelText = languageLabel(draft); const options = [ `Languages — ${languageLabelText}`, `cellTimeoutSeconds: ${draft.cellTimeoutSeconds} [${sources.cellTimeoutSeconds ?? "default"}]`, `bridgeTimeoutSeconds: ${draft.bridgeTimeoutSeconds} [${sources.bridgeTimeoutSeconds ?? "default"}]`, `parallelPoolWidth: ${draft.parallelPoolWidth} [${sources.parallelPoolWidth ?? "default"}]`, `outputSink.headBytes: ${draft.outputSink.headBytes} [${sources["outputSink.headBytes"] ?? "default"}]`, `outputSink.maxColumns: ${draft.outputSink.maxColumns} [${sources["outputSink.maxColumns"] ?? "default"}]`, `outputSink.chunkThrottleMs: ${draft.outputSink.chunkThrottleMs ?? "unset"} [${sources["outputSink.chunkThrottleMs"] ?? "default"}]`, `statusEvents: ${draft.statusEvents ? "on" : "off"} [${sources.statusEvents ?? "default"}]`, `hardenedCells: ${draft.hardenedCells ? "on" : "off"} [${sources.hardenedCells ?? "default"}]`, `jitless: ${draft.jitless ? "on" : "off"} [${sources.jitless ?? "default"}]`, `taskTools.task: ${draft.taskTools.task} [${sources["taskTools.task"] ?? "default"}]`, `taskTools.output: ${draft.taskTools.output} [${sources["taskTools.output"] ?? "default"}]`, `promotion.enabled: ${draft.promotion.enabled ? "on" : "off"} [${sources["promotion.enabled"] ?? "default"}]`, "Save to project (.pi/codemode.json)", "Save to global (~/.pi/agent/codemode.json)", "Close", ]; const selected = await ctx.ui.select("codemode settings", options); if (selected === undefined) { close = true; continue; } if (selected === "Close") { close = true; continue; } if (selected === "Save to project (.pi/codemode.json)") { await save(join(cwd, CONFIG_DIR_NAME, "codemode.json"), ".pi/codemode.json"); continue; } if (selected === "Save to global (~/.pi/agent/codemode.json)") { await save(join(homedir(), CONFIG_DIR_NAME, "agent", "codemode.json"), "~/.pi/agent/codemode.json"); continue; } if (selected.startsWith("Languages")) { const langChoice = await ctx.ui.select( "toggle language", ["py", "js", "rb"].map((lang) => `${lang} — ${draft.languages[lang as "py" | "js" | "rb"] ? "on" : "off"}`) ); const lang = langChoice?.split(" ")[0] as "py" | "js" | "rb" | undefined; if (lang !== undefined) { draft.languages[lang] = !draft.languages[lang]; markEdited(`languages.${lang}`); } continue; } const [key, current] = parseSettingLabel(selected); if (key === undefined) { continue; } if (key in BOOLEAN_TOGGLES) { const target = BOOLEAN_TOGGLES[key as keyof typeof BOOLEAN_TOGGLES]; if (target === "promotion") { draft.promotion.enabled = !draft.promotion.enabled; } else { draft[target] = !draft[target]; } markEdited(key); continue; } const value = await ctx.ui.input(`codemode: ${key}`, String(current)); if (value === null || value === undefined) { continue; } if (!applyEditedValue(draft, key, value.trim())) { ctx.ui.notify(`Refused: invalid value for ${key} (${value.trim()}).`, "error"); continue; } markEdited(key); } } /** Toggle-style settings; selecting their menu entry flips the value. */ const BOOLEAN_TOGGLES = { "statusEvents": "statusEvents", "hardenedCells": "hardenedCells", "jitless": "jitless", "promotion.enabled": "promotion", } as const; function parseSettingLabel( selected: string ): [key: string | undefined, current: string | number | boolean] { const match = /^([a-zA-Z.]+): (.*)$/.exec(selected); if (!match) { return [undefined, ""]; } const key = match[1]; const value = match[2] ?? ""; if (key === undefined) { return [undefined, ""]; } const current = value.replace(/ \[.*\]$/, ""); if (current === "on") { return [key, true]; } if (current === "off") { return [key, false]; } if (current === "unset") { return [key, ""]; } return [key, current]; } function applyEditedValue(draft: Draft, key: string, value: string): boolean { const numeric = (): number | null => { if (value === "") { return null; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }; switch (key) { case "cellTimeoutSeconds": { const next = numeric(); if (next === null || next < 1) { return false; } draft.cellTimeoutSeconds = next; return true; } case "bridgeTimeoutSeconds": { const next = numeric(); if (next === null || next < 1) { return false; } draft.bridgeTimeoutSeconds = next; return true; } case "parallelPoolWidth": { const next = numeric(); if (next === null || next < 1) { return false; } draft.parallelPoolWidth = Math.trunc(next); return true; } case "outputSink.headBytes": { const next = numeric(); if (next === null || next < 0) { return false; } draft.outputSink.headBytes = Math.trunc(next); return true; } case "outputSink.maxColumns": { const next = numeric(); if (next === null || next < 0) { return false; } draft.outputSink.maxColumns = Math.trunc(next); return true; } case "outputSink.chunkThrottleMs": { if (value === "") { draft.outputSink.chunkThrottleMs = undefined; return true; } const next = numeric(); if (next === null || next < 0) { return false; } draft.outputSink.chunkThrottleMs = Math.trunc(next); return true; } case "taskTools.task": if (value.length === 0) { return false; } draft.taskTools.task = value; return true; case "taskTools.output": if (value.length === 0) { return false; } draft.taskTools.output = value; return true; default: return false; } }