import { randomUUID } from "node:crypto"; import { access, mkdir, readdir, readFile, rm, stat, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { defineTool, type ExtensionContext, keyHint, } from "@earendil-works/pi-coding-agent"; import { getCapabilities, hyperlink, Text } from "@earendil-works/pi-tui"; import { MOCKUP_DESCRIPTION, mockupParameters } from "./contract.js"; import { type MockupRequest, type MockupResult, type MockupServer, startMockupServer, validateMockupRequest, } from "./mockup.js"; import { createMockupPreviewManager, MockupPreviewComponent, type MockupPreviewManager, PreviewCancelledError, } from "./mockup-preview.js"; const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; const CACHE_LIMIT_BYTES = 256 * 1024 * 1024; export type MockupHistoryEntry = { iteration: number; question: string; options: Array<{ id: string; label: string }>; action: MockupResult["action"]; selected: string[]; notes: string; annotations: MockupResult["annotations"]; cacheManifest?: string; }; export type MockupToolDetails = MockupResult & { sessionId: string; chainId: string; iteration: number; url: string; cacheManifest?: string; cacheError?: string; history: MockupHistoryEntry[]; contextCapsule: string; }; type MockupProgressDetails = { kind: "waiting"; status: "opening" | "waiting"; url: string; iteration: number; browserFailed?: boolean; timings?: { serverMs?: number; openerMs?: number; }; }; type PreviewProgressDetails = { kind: "preview"; status: "starting" | "running" | "complete"; }; type CancelledDetails = { kind: "cancelled"; }; type ActiveChain = { server: MockupServer; chainId: string; }; type RuntimeOptions = { cacheRoot?: string; /** Resolved per browser-lane execution; defaults to opening. */ openBrowser?: (ctx: ExtensionContext) => boolean; browserOpener?: (url: string) => Promise; previewManager?: MockupPreviewManager; }; export function createMockupRuntime(options: RuntimeOptions = {}) { const active = new Map(); const contexts = new Map(); const previews = options.previewManager ?? createMockupPreviewManager(); const cacheRoot = options.cacheRoot ?? mockupCacheRoot(); const remember = (ctx: ExtensionContext): string => { const sessionId = ctx.sessionManager.getSessionId(); contexts.set(sessionId, ctx); return sessionId; }; const abortAgent = (sessionId: string): boolean => { const ctx = contexts.get(sessionId); if (!ctx || ctx.isIdle()) return false; try { ctx.abort(); return true; } catch { return false; } }; const schedulePrune = (protectedSessionId?: string): void => { const timer = setTimeout(() => { void pruneMockupCache(cacheRoot, protectedSessionId).catch( () => undefined, ); }, 0); timer.unref?.(); }; const tool = defineTool({ name: "mockup", label: "mockup", description: MOCKUP_DESCRIPTION, promptSnippet: "Preview HTML mockups in Pi or review them interactively in a browser", parameters: mockupParameters, renderShell: "self", renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const options = Array.isArray(args.options) ? args.options : []; const optionCount = options.length; const optionWord = `option${optionCount === 1 ? "" : "s"}`; const htmlCharacters = options.reduce( (total, option) => total + (typeof option.html === "string" ? option.html.length : 0), 0, ); const phase = context.isPartial && !context.argsComplete ? ` · generating HTML${htmlCharacters > 0 ? ` · ${htmlCharacters} chars` : ""}` : context.isPartial && !context.executionStarted ? " · waiting to run" : ""; let output = `${theme.fg("toolTitle", theme.bold("mockup"))} ${theme.fg("accent", args.title ?? "untitled")} ${theme.fg("toolOutput", `· ${optionCount} ${optionWord}${args.multi ? " · multi-select" : ""}${phase}`)}`; if (!context.expanded) { output += theme.fg( "dim", ` (${keyHint("app.tools.expand", "to expand")})`, ); } else { if (args.question) output += `\n${theme.fg("toolOutput", args.question)}`; for (const option of args.options ?? []) { const description = option.description ? theme.fg("dim", ` · ${option.description}`) : ""; output += `\n${theme.fg("accent", String(option.id))} ${theme.fg("toolOutput", option.label)}${description}`; } } text.setText(output); return text; }, renderResult(result, { expanded, isPartial }, theme, context) { const details = result.details as | Partial | MockupProgressDetails | PreviewProgressDetails | CancelledDetails | undefined; if (details && "kind" in details && details.kind === "preview") { const preview = previews.get(context.toolCallId); if (preview) { if (context.lastComponent instanceof MockupPreviewComponent) { context.lastComponent.update( (style, value) => theme.fg(style, value), context.invalidate, context.showImages, ); if (details.status === "complete") context.lastComponent.settle(); return context.lastComponent; } return new MockupPreviewComponent( preview, (style, value) => theme.fg(style, value), context.invalidate, context.showImages, ); } if (details.status === "starting") return new Text(theme.fg("accent", "Preparing previews…"), 0, 0); return new Text( theme.fg( "muted", "Preview expired. Run the mockup again to render it.", ), 0, 0, ); } const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0); if (details && "kind" in details && details.kind === "cancelled") { text.setText(theme.fg("muted", "Mockup cancelled.")); return text; } if ( isPartial && details && "kind" in details && details.kind === "waiting" ) { const status = details.browserFailed ? theme.fg("warning", "Browser did not open. Open this mockup:") : details.status === "opening" ? theme.fg("accent", "Review ready · opening browser…") : theme.fg("accent", "Review ready · awaiting feedback"); let diagnostics = expanded ? theme.fg("dim", ` · iteration ${details.iteration}`) : ""; if (expanded && details.timings) { if (details.timings.serverMs !== undefined) diagnostics += theme.fg( "dim", ` · server ${details.timings.serverMs}ms`, ); if (details.timings.openerMs !== undefined) diagnostics += theme.fg( "dim", ` · opener ${details.timings.openerMs}ms`, ); } text.setText(`${status}${diagnostics}\n${linkedUrl(details.url)}`); return text; } if (context.isError || !details || "kind" in details) { text.setText( expandableFallback( result.content, expanded, theme, context.isError ? "error" : "muted", ), ); return text; } const action = details.action ?? "submitted"; const status = action === "accept" ? theme.fg("success", "✓ accepted") : theme.fg("warning", "↻ iterate"); const selected = (details.selected ?? []).join(", ") || "none"; const annotations = details.annotations ?? []; let output = `${status}${theme.fg("dim", ` · selected ${selected} · ${annotations.length} annotation${annotations.length === 1 ? "" : "s"}`)}`; if (!expanded) { output += theme.fg( "dim", ` (${keyHint("app.tools.expand", "to expand")})`, ); } else { if (details.notes) output += `\n${theme.fg("toolOutput", `notes: ${details.notes}`)}`; for (const annotation of annotations) { output += `\n${theme.fg("accent", annotation.optionId)} ${theme.fg("dim", `@ ${Math.round(annotation.x * 100)}%,${Math.round(annotation.y * 100)}%`)} ${theme.fg("toolOutput", annotation.note)}`; } if (details.url) output += `\n${linkedUrl(details.url)}`; if (details.history && details.history.length > 1) output += `\n${theme.fg("dim", `iterations: ${details.history.length}`)}`; if (details.cacheManifest) output += `\n${theme.fg("dim", `cache: ${details.cacheManifest}`)}`; else if (details.cacheError) output += `\n${theme.fg("warning", `cache unavailable: ${details.cacheError}`)}`; } text.setText(output); return text; }, async execute(toolCallId, params, signal, onUpdate, ctx) { const request = validateMockupRequest(params); const sessionId = remember(ctx); const previous = latestMockupDetails(ctx); const continuing = previous?.sessionId === sessionId && previous.action === "iterate"; const pendingChain = active.get(sessionId); const continuingInBrowser = continuing && pendingChain?.chainId === previous.chainId; const lane = continuingInBrowser ? "browser" : await selectMockupLane(ctx, signal); if (lane === "cancel") return cancelledResult(); if (pendingChain && !continuingInBrowser) { pendingChain.server.close("superseded"); active.delete(sessionId); } if (lane === "preview") { onUpdate?.({ content: [{ type: "text", text: "Preparing mockup previews in Pi." }], details: { kind: "preview", status: "starting" }, }); try { const preview = await previews.start(toolCallId, request, signal); onUpdate?.({ content: [{ type: "text", text: "Showing mockup previews in Pi." }], details: { kind: "preview", status: "running" }, }); await abortable(preview.done, signal, () => preview.close()); return { content: [ { type: "text" as const, text: JSON.stringify({ action: "previewed", next: "Wait for the human's conversational feedback. Do not infer acceptance, rejection, or selection from previewing.", }), }, ], details: { kind: "preview" as const, status: "complete" as const }, terminate: true, }; } catch (error) { previews.get(toolCallId)?.close(); if (signal?.aborted || error instanceof PreviewCancelledError) return cancelledResult(); ctx.ui.notify( `Pi preview unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning", ); if ((await selectBrowserFallback(ctx, signal)) !== "browser") return cancelledResult(); } } let chain = continuingInBrowser ? pendingChain : undefined; const created = !chain; let serverMs: number | undefined; if (!chain) { const serverStartedAt = performance.now(); chain = { chainId: continuing ? previous.chainId : randomUUID(), server: await startMockupServer(request, { openBrowser: options.openBrowser?.(ctx) ?? true, browserOpener: options.browserOpener, abortAgent: () => abortAgent(sessionId), }), }; serverMs = Math.round(performance.now() - serverStartedAt); active.set(sessionId, chain); } const iteration = continuing ? previous.iteration + 1 : 1; const history = continuing ? previous.history : []; try { const connected = chain.server.connected(); const resultPromise = created ? chain.server.result : chain.server.present(request); let openerMs: number | undefined; const progress = ( status: MockupProgressDetails["status"], browserFailed = false, ) => onUpdate?.({ content: [ { type: "text" as const, text: status === "opening" ? `Review ready; opening browser: ${chain.server.url}` : `Review ready; awaiting feedback: ${chain.server.url}`, }, ], details: { kind: "waiting" as const, status, url: chain.server.url, iteration, timings: { ...(serverMs !== undefined && { serverMs }), ...(openerMs !== undefined && { openerMs }), }, ...(browserFailed && { browserFailed: true }), }, }); if (!connected) { progress("opening"); const openerStartedAt = performance.now(); const opened = await chain.server.open(); openerMs = Math.round(performance.now() - openerStartedAt); progress("waiting", opened === false); } else { progress("waiting"); } const result = await abortable(resultPromise, signal, () => chain?.server.close("aborted"), ); let cacheManifest: string | undefined; let cacheError: string | undefined; try { cacheManifest = await persistMockupIteration(cacheRoot, { sessionId, sessionFile: ctx.sessionManager.getSessionFile(), chainId: chain.chainId, iteration, toolCallId, request, result, previousManifest: continuing ? previous.cacheManifest : undefined, }); } catch (error) { cacheError = error instanceof Error ? error.message : String(error); } const current = historyEntry(iteration, request, result, cacheManifest); const nextHistory = [...history, current]; const contextCapsule = formatContextCapsule( chain.chainId, nextHistory, cacheError, ); const details: MockupToolDetails = { ...result, sessionId, chainId: chain.chainId, iteration, url: chain.server.url, cacheManifest, cacheError, history: nextHistory, contextCapsule, }; if (result.action === "accept") { chain.server.close("accepted"); active.delete(sessionId); schedulePrune(sessionId); } return { content: [ { type: "text" as const, text: JSON.stringify({ action: result.action, selected: result.selected, notes: result.notes, annotations: result.annotations, cacheManifest, contextCapsule, next: result.action === "iterate" ? "Apply the feedback and call mockup again unless the human redirects or stops the review." : "Continue with the accepted direction.", }), }, ], details, }; } catch (error) { chain.server.close("failed"); active.delete(sessionId); schedulePrune(sessionId); throw error; } }, }); return { tool, remember, contextCapsule(ctx: ExtensionContext): string | undefined { const sessionId = remember(ctx); const details = latestMockupDetails(ctx); return details?.sessionId === sessionId ? details.contextCapsule : undefined; }, agentWorking(ctx: ExtensionContext): void { const sessionId = remember(ctx); active.get(sessionId)?.server.progress("Pi is working on the revision."); }, agentSettled(ctx: ExtensionContext): void { const sessionId = remember(ctx); const chain = active.get(sessionId); if (chain?.server.settled()) { active.delete(sessionId); schedulePrune(sessionId); } }, async shutdown(ctx: ExtensionContext): Promise { const sessionId = ctx.sessionManager.getSessionId(); active.get(sessionId)?.server.close("session ended"); active.delete(sessionId); contexts.delete(sessionId); await previews.close(); schedulePrune(); }, }; } type MockupLane = "preview" | "browser" | "cancel"; async function selectMockupLane( ctx: ExtensionContext, signal?: AbortSignal, ): Promise { if (ctx.mode !== "tui") return "browser"; const unavailable = previewUnavailableReason(); if (unavailable) { ctx.ui.notify(unavailable, "warning"); return selectBrowserFallback(ctx, signal); } const choice = await ctx.ui.select( "How should this mockup open?", ["Show previews in Pi", "Open interactive browser", "Cancel"], { signal }, ); if (choice === "Show previews in Pi") return "preview"; if (choice === "Open interactive browser") return "browser"; return "cancel"; } async function selectBrowserFallback( ctx: ExtensionContext, signal?: AbortSignal, ): Promise<"browser" | "cancel"> { if (ctx.mode !== "tui") return "browser"; const choice = await ctx.ui.select( "Use the interactive browser instead?", ["Open interactive browser", "Cancel"], { signal }, ); return choice === "Open interactive browser" ? "browser" : "cancel"; } function previewUnavailableReason(): string | undefined { if (!getCapabilities().images) return "This terminal does not support inline image previews."; const fullscreen = process.argv.some( (argument, index) => argument === "--tui-mode=fullscreen" || (argument === "--tui-mode" && process.argv[index + 1] === "fullscreen"), ); if (fullscreen && getCapabilities().images === "kitty") return "Inline previews are unavailable in fullscreen mode until Pi's image redraw regression is fixed."; return undefined; } function cancelledResult() { return { content: [ { type: "text" as const, text: JSON.stringify({ action: "cancelled", next: "Do not continue the mockup review.", }), }, ], details: { kind: "cancelled" as const }, terminate: true, }; } function latestMockupDetails( ctx: ExtensionContext, ): MockupToolDetails | undefined { const branch = ctx.sessionManager.getBranch(); for (let index = branch.length - 1; index >= 0; index--) { const entry = branch[index] as unknown as Record; if (entry.type !== "message") continue; const message = entry.message; if (!isRecord(message)) continue; if (message.role !== "toolResult" || message.toolName !== "mockup") continue; const details = message.details; if (!isRecord(details) || typeof details.contextCapsule !== "string") return undefined; return details as unknown as MockupToolDetails; } return undefined; } function historyEntry( iteration: number, request: MockupRequest, result: MockupResult, cacheManifest?: string, ): MockupHistoryEntry { return { iteration, question: request.question, options: request.options.map(({ id, label }) => ({ id, label })), action: result.action, selected: result.selected, notes: result.notes, annotations: result.annotations, cacheManifest, }; } export function formatContextCapsule( chainId: string, history: MockupHistoryEntry[], cacheError?: string, ): string { const latest = history.at(-1); if (!latest) return ""; const lines = [ "Mockup context capsule", `Chain: ${chainId}`, `Current: iteration ${latest.iteration}, ${latest.action}`, `Question: ${latest.question}`, `Selected: ${selectedLabels(latest)}`, `Notes: ${latest.notes || "none"}`, ]; if (latest.annotations.length === 0) lines.push("Annotations: none"); else { lines.push("Annotations:"); for (const annotation of latest.annotations) lines.push( `- ${annotation.optionId} @ ${Math.round(annotation.x * 100)}%,${Math.round(annotation.y * 100)}%: ${annotation.note}`, ); } lines.push( "Rejected rationale: not recorded separately; never infer rejection from non-selection.", `Cache manifest: ${latest.cacheManifest ?? `unavailable${cacheError ? ` (${cacheError})` : ""}`}`, ); if (history.length > 1) { lines.push("Earlier iterations, newest first:"); for (const entry of history.slice(0, -1).reverse()) { lines.push( `- ${entry.iteration} ${entry.action}; selected ${selectedLabels(entry)}; notes: ${entry.notes || "none"}`, ); for (const annotation of entry.annotations) lines.push(` annotation ${annotation.optionId}: ${annotation.note}`); if (entry.cacheManifest) lines.push(` cache: ${entry.cacheManifest}`); } } return lines.join("\n"); } function linkedUrl(url: string): string { return getCapabilities().hyperlinks ? hyperlink(url, url) : url; } function expandableFallback( content: Array<{ type: string; text?: string }>, expanded: boolean, theme: { fg(style: string, text: string): string }, style: "error" | "muted", ): string { const full = content .flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])) .join("\n") .trim(); if (expanded) return theme.fg(style, full); const firstLine = full.split(/\r?\n/, 1)[0] ?? ""; const normalized = firstLine.replace(/\s+/g, " ").trim(); const summary = normalized.length > 160 ? `${normalized.slice(0, 159)}…` : normalized; return full !== firstLine || summary !== normalized ? `${theme.fg(style, summary)}\n${theme.fg("muted", "↳")} ${keyHint("app.tools.expand", "to expand")}` : theme.fg(style, summary); } function selectedLabels(entry: MockupHistoryEntry): string { if (entry.selected.length === 0) return "none"; const labels = new Map( entry.options.map((option) => [option.id, option.label]), ); return entry.selected .map((id) => `${id} (${labels.get(id) ?? id})`) .join(", "); } export function mockupCacheRoot( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, home: string = homedir(), ): string { if (platform === "win32") return path.join( env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "pi", "cache", "mockups", ); if (platform === "darwin") return path.join(home, "Library", "Caches", "pi", "mockups"); return path.join( env.XDG_CACHE_HOME ?? path.join(home, ".cache"), "pi", "mockups", ); } type PersistInput = { sessionId: string; sessionFile: string | undefined; chainId: string; iteration: number; toolCallId: string; request: MockupRequest; result: MockupResult; previousManifest?: string; }; export async function persistMockupIteration( root: string, input: PersistInput, ): Promise { const sessionDir = path.join(root, safeSegment(input.sessionId)); const iterationDir = path.join( sessionDir, safeSegment(input.chainId), `${String(input.iteration).padStart(3, "0")}-${safeSegment(input.toolCallId)}`, ); await mkdir(iterationDir, { recursive: true }); await Promise.all([ writeFile( path.join(sessionDir, "session.json"), `${JSON.stringify({ sessionId: input.sessionId, sessionFile: input.sessionFile }, null, 2)}\n`, ), writeFile( path.join(iterationDir, "request.json"), `${JSON.stringify(input.request, null, 2)}\n`, ), writeFile( path.join(iterationDir, "feedback.json"), `${JSON.stringify(input.result, null, 2)}\n`, ), ...input.request.options.map((option) => writeFile( path.join(iterationDir, `${safeSegment(option.id)}.html`), option.html, ), ), ]); const manifest = path.join(iterationDir, "manifest.json"); await writeFile( manifest, `${JSON.stringify( { version: 1, recordedAt: new Date().toISOString(), sessionId: input.sessionId, chainId: input.chainId, iteration: input.iteration, toolCallId: input.toolCallId, previousManifest: input.previousManifest, request: "request.json", feedback: "feedback.json", options: input.request.options.map((option) => ({ id: option.id, label: option.label, html: `${safeSegment(option.id)}.html`, })), }, null, 2, )}\n`, ); return manifest; } export async function pruneMockupCache( root: string, protectedSessionId?: string, now = Date.now(), policy: { ttlMs: number; maxBytes: number } = { ttlMs: CACHE_TTL_MS, maxBytes: CACHE_LIMIT_BYTES, }, ): Promise { const sessions = await directories(root); const candidates: Array<{ path: string; modified: number; bytes: number }> = []; for (const session of sessions) { if (session.name === safeSegment(protectedSessionId ?? "")) continue; const metadataPath = path.join(session.path, "session.json"); const metadata = await readJson(metadataPath); if ( isRecord(metadata) && typeof metadata.sessionFile === "string" && !(await exists(metadata.sessionFile)) ) { await rm(session.path, { recursive: true, force: true }); continue; } for (const chain of await directories(session.path)) { for (const iteration of await directories(chain.path)) { const info = await stat(iteration.path); if (now - info.mtimeMs >= policy.ttlMs) { await rm(iteration.path, { recursive: true, force: true }); continue; } candidates.push({ path: iteration.path, modified: info.mtimeMs, bytes: await directorySize(iteration.path), }); } } } let total = candidates.reduce((sum, candidate) => sum + candidate.bytes, 0); for (const candidate of candidates.sort( (left, right) => left.modified - right.modified, )) { if (total <= policy.maxBytes) break; await rm(candidate.path, { recursive: true, force: true }); total -= candidate.bytes; } } async function directories( root: string, ): Promise> { try { return (await readdir(root, { withFileTypes: true })) .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) .map((entry) => ({ name: entry.name, path: path.join(root, entry.name), })); } catch (error) { if (isNotFound(error)) return []; throw error; } } async function directorySize(root: string): Promise { let bytes = 0; for (const entry of await readdir(root, { withFileTypes: true })) { if (entry.isSymbolicLink()) continue; const child = path.join(root, entry.name); if (entry.isDirectory()) bytes += await directorySize(child); else if (entry.isFile()) bytes += (await stat(child)).size; } return bytes; } async function readJson(file: string): Promise { try { return JSON.parse(await readFile(file, "utf8")); } catch (error) { if (isNotFound(error)) return undefined; throw error; } } async function exists(file: string): Promise { try { await access(file); return true; } catch { return false; } } function safeSegment(value: string): string { return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_") || "unknown"; } function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } function isNotFound(error: unknown): boolean { return isRecord(error) && error.code === "ENOENT"; } function abortable( promise: Promise, signal: AbortSignal | undefined, abort: () => void, ): Promise { if (!signal) return promise; if (signal.aborted) { abort(); return Promise.reject(new Error("mockup aborted")); } return new Promise((resolve, reject) => { const onAbort = () => { abort(); reject(new Error("mockup aborted")); }; signal.addEventListener("abort", onAbort, { once: true }); promise .then(resolve, reject) .finally(() => signal.removeEventListener("abort", onAbort)); }); }