import type { ThemeColor } from "@earendil-works/pi-coding-agent"; import { allocateImageId, type Component, getCapabilities, getCellDimensions, Image, } from "@earendil-works/pi-tui"; import type { Browser, Page } from "playwright"; import type { MockupRequest } from "./mockup.js"; const PREVIEW_DURATION_MS = 4_000; const STATIC_OBSERVATION_MS = 600; const LOOP_PAUSE_MS = 1_000; const MAX_REPLAY_FRAME_SETS = 120; const MAX_REPLAY_BYTES = 16 * 1024 * 1024; const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024; const MAX_REPLAY_LOOPS = 10; const MAX_PLAYBACK_FPS = 30; const PREVIEW_WIDTH_PX = 680; const PREVIEW_HEIGHT_PX = 304; const MAX_PREVIEW_HEIGHT_PX = 720; const PREVIEW_BORDER_PX = 4; export type PreviewFrameSet = { capturedAt: number; frames: string[]; cropped?: boolean[]; }; export type MockupPreview = { readonly labels: string[]; readonly done: Promise; current(): readonly string[] | undefined; cropped(): readonly boolean[]; expired(): boolean; version(): number; subscribe(listener: () => void): () => void; close(): void; }; export type MockupPreviewManager = { start( id: string, request: MockupRequest, signal?: AbortSignal, ): Promise; get(id: string): MockupPreview | undefined; close(): Promise; }; export class PreviewCancelledError extends Error { constructor() { super("mockup preview cancelled"); this.name = "PreviewCancelledError"; } } export function createMockupPreviewManager(): MockupPreviewManager { const previews = new Map(); let browser: Browser | undefined; let browserPromise: Promise | undefined; let generation = 0; let managerClosed = false; const acquireBrowser = async (): Promise => { if (managerClosed) throw new PreviewCancelledError(); if (browser?.isConnected()) return browser; const launch = browserPromise ?? import("playwright").then(({ chromium }) => chromium.launch({ headless: true, timeout: 10_000 }), ); browserPromise = launch; try { const candidate = await launch; if (managerClosed) { await candidate.close().catch(() => undefined); throw new PreviewCancelledError(); } browser = candidate; return candidate; } finally { if (browserPromise === launch) browserPromise = undefined; } }; const pruneSnapshots = (): void => { let bytes = [...previews.values()].reduce( (total, preview) => total + preview.snapshotBytes(), 0, ); for (const preview of previews.values()) { if (bytes <= MAX_SNAPSHOT_BYTES) break; bytes -= preview.snapshotBytes(); preview.close(); } }; return { async start(id, request, signal) { const owner = ++generation; for (const preview of previews.values()) preview.freeze(); pruneSnapshots(); const assertActive = () => { if (managerClosed || signal?.aborted || owner !== generation) throw new PreviewCancelledError(); }; assertActive(); const activeBrowser = await acquireBrowser(); assertActive(); const page = await activeBrowser.newPage({ viewport: { width: 720, height: 480 }, deviceScaleFactor: previewDeviceScaleFactor( request.options.length, process.stdout.columns, ), }); const abort = () => void page.close().catch(() => undefined); let preview: PreviewSession | undefined; signal?.addEventListener("abort", abort, { once: true }); try { assertActive(); page.setDefaultTimeout(5_000); page.setDefaultNavigationTimeout(5_000); await page.setContent(renderMockupPreviewPage(request), { waitUntil: "load", timeout: 5_000, }); await page.waitForFunction( () => document.documentElement.dataset.mockupPreviewReady === "true", undefined, { timeout: 5_000 }, ); assertActive(); preview = new PreviewSession(request, page, () => { if (previews.get(id) === preview) previews.delete(id); }); await preview.start(); assertActive(); previews.set(id, preview); return preview; } catch (error) { preview?.close(); await page.close().catch(() => undefined); if (signal?.aborted || owner !== generation || managerClosed) throw new PreviewCancelledError(); throw error; } finally { signal?.removeEventListener("abort", abort); } }, get: (id) => previews.get(id), async close() { managerClosed = true; generation++; for (const preview of previews.values()) preview.close(); previews.clear(); const launching = browserPromise; if (launching) await launching.then((value) => value.close()).catch(() => undefined); await browser?.close().catch(() => undefined); browser = undefined; }, }; } class PreviewSession implements MockupPreview { readonly labels: string[]; readonly done: Promise; private readonly listeners = new Set<() => void>(); private readonly resolveDone: () => void; private readonly rejectDone: (error: Error) => void; private recording: PreviewFrameSet[] = []; private recordingDurationMs = 0; private currentFrames: string[] | undefined; private currentCropped: boolean[] = []; private currentVersion = 0; private completedReplayLoops = 0; private disposed = false; private frozen = false; private doneSettled = false; private replayTimer: NodeJS.Timeout | undefined; constructor( private readonly request: MockupRequest, private readonly page: Page, private readonly onClose: () => void, ) { this.labels = request.options.map((option) => option.label); let resolveDone!: () => void; let rejectDone!: (error: Error) => void; this.done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; }); this.resolveDone = resolveDone; this.rejectDone = rejectDone; } async start(): Promise { const first = await this.capture(); this.present(first); void this.produce(first).then( () => this.finish(), (error) => this.finish(error instanceof Error ? error : new Error(String(error))), ); } current(): readonly string[] | undefined { return this.currentFrames; } cropped(): readonly boolean[] { return this.currentCropped; } expired(): boolean { return this.disposed; } version(): number { return this.currentVersion; } subscribe(listener: () => void): () => void { this.listeners.clear(); this.listeners.add(listener); return () => this.listeners.delete(listener); } snapshotBytes(): number { return this.frozen && this.currentFrames ? this.currentFrames.reduce((total, image) => total + image.length, 0) : 0; } freeze(): void { if (this.disposed || this.frozen) return; this.frozen = true; if (this.replayTimer) clearTimeout(this.replayTimer); this.replayTimer = undefined; const final = this.recording.at(-1); if (final) this.present(final); this.recording = []; void this.page.close().catch(() => undefined); if (!this.doneSettled) { this.doneSettled = true; this.resolveDone(); } } close(): void { if (this.disposed) return; this.freeze(); this.disposed = true; this.currentFrames = undefined; this.currentVersion++; for (const listener of this.listeners) listener(); this.listeners.clear(); void this.page.close().catch(() => undefined); if (!this.doneSettled) { this.doneSettled = true; this.resolveDone(); } this.onClose(); } private async produce(first: PreviewFrameSet): Promise { const startedAt = performance.now(); this.retain(first); let previous = first; let sawChange = false; while ( !this.disposed && !this.frozen && performance.now() - startedAt < PREVIEW_DURATION_MS ) { const untilNextFrame = 1000 / MAX_PLAYBACK_FPS - (performance.now() - previous.capturedAt); if (untilNextFrame > 0) await new Promise((resolve) => setTimeout(resolve, untilNextFrame)); const next = await this.capture(); if (this.disposed || this.frozen) return; if ( !sameFrames(previous.frames, next.frames) || !sameFlags(previous.cropped, next.cropped) ) { sawChange = true; this.retain(next); this.present(next); } previous = next; if (!sawChange && performance.now() - startedAt >= STATIC_OBSERVATION_MS) break; } this.recordingDurationMs = previous.capturedAt - first.capturedAt; if ( !this.disposed && !this.frozen && sawChange && this.recording.length > 1 ) this.scheduleReplay(0, this.endPauseMs()); } private async capture(): Promise { const cards = this.page.locator(".preview-card"); const frames: string[] = []; for (let index = 0; index < this.request.options.length; index++) { const png = await cards.nth(index).screenshot({ type: "png", animations: "allow", omitBackground: true, timeout: 5_000, }); frames.push(png.toString("base64")); if ( frames.reduce((total, frame) => total + frame.length, 0) > MAX_REPLAY_BYTES ) throw new Error("Mockup preview frame set exceeds the 16 MiB limit"); } const cropped = await cards.evaluateAll((elements) => elements.map((card) => { const content = card.querySelector(".fragment-content"); if (!content) return false; const value = content.scrollHeight > content.clientHeight || content.scrollWidth > content.clientWidth; card.toggleAttribute("data-preview-cropped", value); return value; }), ); return { capturedAt: performance.now(), frames, cropped }; } private retain(frame: PreviewFrameSet): void { this.recording = thinReplayFrames([...this.recording, frame]); } private scheduleReplay(index: number, delayMs: number): void { this.replayTimer = setTimeout(() => { this.replayTimer = undefined; if (this.disposed || this.frozen) return; const frame = this.recording[index]; if (!frame) return; this.present(frame); const nextIndex = index + 1; if (nextIndex >= this.recording.length) { this.completedReplayLoops++; if (this.completedReplayLoops < MAX_REPLAY_LOOPS) this.scheduleReplay(0, this.endPauseMs()); return; } const next = this.recording[nextIndex]; this.scheduleReplay( nextIndex, Math.max(1000 / MAX_PLAYBACK_FPS, next.capturedAt - frame.capturedAt), ); }, delayMs); this.replayTimer.unref?.(); } private endPauseMs(): number { const final = this.recording.at(-1); const first = this.recording[0]; const uncapturedTail = first && final ? Math.max( 0, this.recordingDurationMs - (final.capturedAt - first.capturedAt), ) : 0; return uncapturedTail + LOOP_PAUSE_MS; } private present(frame: PreviewFrameSet): void { this.currentFrames = frame.frames; this.currentCropped = frame.cropped ?? []; this.currentVersion++; for (const listener of this.listeners) listener(); } private finish(error?: Error): void { if (this.disposed || this.doneSettled) return; this.doneSettled = true; void this.page.close().catch(() => undefined); if (error) this.rejectDone(error); else this.resolveDone(); } } export class MockupPreviewComponent implements Component { private imageIds: number[]; private readonly unsubscribe: () => void; private cachedLines: string[] | undefined; private complete = false; private cachedVersion = -1; private cachedWidth = -1; constructor( private readonly preview: MockupPreview, private color: (style: ThemeColor, text: string) => string, private requestRender: () => void, private showImages: boolean, ) { this.imageIds = preview.labels.map(() => allocateImageId()); this.unsubscribe = preview.subscribe(() => { this.invalidate(); this.requestRender(); }); } update( color: (style: ThemeColor, text: string) => string, requestRender: () => void, showImages: boolean, ): void { this.color = color; this.requestRender = requestRender; if (this.showImages !== showImages) { this.showImages = showImages; this.invalidate(); } } settle(): void { if (this.complete) return; this.complete = true; this.imageIds = this.preview.labels.map(() => allocateImageId()); this.invalidate(); } render(width: number): string[] { const version = this.preview.version(); if ( this.cachedLines && this.cachedVersion === version && this.cachedWidth === width ) return this.cachedLines; if (this.preview.expired()) { this.cachedLines = [this.color("muted", "Preview expired.")]; return this.cachedLines; } if (!this.showImages || !getCapabilities().images) { this.cachedLines = [ this.color("warning", "Inline image previews are disabled."), ]; return this.cachedLines; } const frames = this.preview.current(); if (!frames) { this.cachedLines = [this.color("accent", "Preparing previews…")]; return this.cachedLines; } const cropped = this.preview.cropped(); const maxHeightCells = frames.length === 1 ? 20 : frames.length === 2 ? 12 : 8; const lines: string[] = []; for (let index = 0; index < frames.length; index++) { if (index > 0) lines.push(""); lines.push( this.color( "accent", this.preview.labels[index] ?? `Option ${index + 1}`, ), ); if (cropped[index]) lines.push( this.color("warning", "Preview cropped · use browser review"), ); const image = new Image( frames[index] ?? "", "image/png", { fallbackColor: (text) => this.color("muted", text) }, { imageId: this.imageIds[index], maxWidthCells: Math.min(80, Math.max(1, width - 2)), maxHeightCells, }, ); lines.push(...image.render(width)); } this.cachedLines = lines; this.cachedVersion = version; this.cachedWidth = width; return lines; } invalidate(): void { this.cachedLines = undefined; this.cachedVersion = -1; this.cachedWidth = -1; } dispose(): void { this.unsubscribe(); } } export function renderMockupPreviewPage(request: MockupRequest): string { const cards = request.options .map( (option) => `
`, ) .join("\n"); return ` ${cards} `; } export function previewDeviceScaleFactor( optionCount: number, terminalColumns = 82, cells = getCellDimensions(), ): number { const maxHeightCells = optionCount === 1 ? 20 : optionCount === 2 ? 12 : 8; const widthScale = (Math.min(80, Math.max(1, terminalColumns - 2)) * cells.widthPx) / PREVIEW_WIDTH_PX; const heightScale = (maxHeightCells * cells.heightPx) / PREVIEW_HEIGHT_PX; return Math.min(2, Math.max(0.5, Math.min(widthScale, heightScale))); } export function thinReplayFrames( frames: readonly PreviewFrameSet[], maxFrames = MAX_REPLAY_FRAME_SETS, maxBytes = MAX_REPLAY_BYTES, ): PreviewFrameSet[] { let retained = [...frames]; let bytes = retained.reduce((total, frame) => total + frameBytes(frame), 0); while ( (retained.length > maxFrames || bytes > maxBytes) && retained.length > 1 ) { if (retained.length === 2) { retained = [retained[1]]; } else { const last = retained.at(-1); retained = retained.filter((_, index) => index % 2 === 0); if (last && retained.at(-1) !== last) retained.push(last); } bytes = retained.reduce((total, frame) => total + frameBytes(frame), 0); } return bytes <= maxBytes ? retained : []; } function frameBytes(frame: PreviewFrameSet): number { return frame.frames.reduce((total, image) => total + image.length, 0); } function sameFrames( left: readonly string[] | undefined, right: readonly string[], ): boolean { return ( left?.length === right.length && left.every((frame, index) => frame === right[index]) ); } function sameFlags( left: readonly boolean[] | undefined, right: readonly boolean[] | undefined, ): boolean { return ( left?.length === right?.length && (left?.every((flag, index) => flag === right?.[index]) ?? true) ); } function jsonScript(value: string): string { return JSON.stringify(value).replaceAll("<", "\\u003c"); }