@zenodinh/pi-render 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/index.ts +284 -0
  4. package/package.json +70 -5
  5. package/src/commands/canvas.ts +57 -0
  6. package/src/core/code-theme.ts +275 -0
  7. package/src/core/log.ts +57 -0
  8. package/src/core/paint.ts +64 -0
  9. package/src/core/registry.ts +135 -0
  10. package/src/core/settings.ts +119 -0
  11. package/src/core/types/code-theme.ts +24 -0
  12. package/src/core/types/host.ts +169 -0
  13. package/src/core/types/log.ts +28 -0
  14. package/src/core/types/paint.ts +54 -0
  15. package/src/core/types/registry.ts +44 -0
  16. package/src/core/types/settings.ts +31 -0
  17. package/src/core/types.ts +34 -0
  18. package/src/renderers/content/artifacts/cache.ts +234 -0
  19. package/src/renderers/content/artifacts/cards.ts +136 -0
  20. package/src/renderers/content/artifacts/engines.ts +396 -0
  21. package/src/renderers/content/artifacts/local-binary.ts +80 -0
  22. package/src/renderers/content/artifacts/prereqs.ts +128 -0
  23. package/src/renderers/content/artifacts/server.ts +181 -0
  24. package/src/renderers/content/code-panel.ts +161 -0
  25. package/src/renderers/content/image-card.ts +252 -0
  26. package/src/renderers/content/index.ts +79 -0
  27. package/src/renderers/content/json-panel.ts +116 -0
  28. package/src/renderers/content/table.ts +174 -0
  29. package/src/renderers/content/types.ts +20 -0
  30. package/src/renderers/tool/index.ts +113 -0
  31. package/src/renderers/tool/runtime.ts +267 -0
  32. package/src/renderers/tool/specs/bash.ts +168 -0
  33. package/src/renderers/tool/specs/codemode.ts +248 -0
  34. package/src/renderers/tool/specs/edit.ts +213 -0
  35. package/src/renderers/tool/specs/ls.ts +136 -0
  36. package/src/renderers/tool/specs/read.ts +296 -0
  37. package/src/renderers/tool/specs/search.ts +325 -0
  38. package/src/renderers/tool/specs/write.ts +142 -0
  39. package/src/renderers/tool/types.ts +45 -0
  40. package/themes/dracula-soft.json +81 -0
  41. package/themes/one-dark.json +80 -0
@@ -0,0 +1,136 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/{card,emit}.ts — survives because: the boxed card with
2
+ // a plain-markdown Open link is what the host turns into a real clickable target, and imageLineMetadata
3
+ // is the one place a raster artifact's row count is known (T-25's cache meta leaves rows to this step).
4
+ // The emit.ts terminal-image emission is NOT ported: pi-render draws no inline pixels (the artifact card
5
+ // is the transcript output), so those lines had no caller, and their iTerm2 branch carried a cursor-up
6
+ // escape literal — the repo keeps escapes in src/core/paint.ts alone.
7
+
8
+ /**
9
+ * cards.ts — the artifact card and the raster row count.
10
+ *
11
+ * Boundary: the Open link resolves through the ArtifactServer, which registers the path as carded and
12
+ * names the loopback URL; a degraded server is logged once by urlFor and the card falls back to a
13
+ * file:// URL, so a failed bind never reaches the render path as a throw.
14
+ *
15
+ * shape: none — no module-level state; every export is a straight-line function or one box builder.
16
+ */
17
+
18
+ import { pathToFileURL } from "node:url";
19
+ import { calculateImageRows, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
+ import type { ContentPaint } from "../../../core/types/paint.ts";
21
+ import type { ArtifactServer } from "./server.ts";
22
+
23
+ const OPEN_LABEL = "Open (⌘click)";
24
+ const COPY_LABEL = "Copy path (⌥C)";
25
+ /** The rendered link occupies brackets around the label. */
26
+ const OPEN_WIDTH = visibleWidth(OPEN_LABEL) + 2;
27
+ /** Cards read best narrow; the offered width is clamped into these bounds. */
28
+ const CARD_MIN_WIDTH = 24;
29
+ const CARD_MAX_WIDTH = 64;
30
+ /** pi-tui's default image width; the reference for a raster artifact's transcript row count. */
31
+ const DEFAULT_WIDTH_CELLS = 60;
32
+
33
+ /** The artifact card's own fields; the URL is resolved from the path, never supplied by the caller. */
34
+ export interface ArtifactCardSpec {
35
+ /** The fence form, e.g. "plantuml" — rendered as the card's type label. */
36
+ typeLabel: string;
37
+ /** The artifact's own name where it has one; see artifactTitle. */
38
+ title: string;
39
+ /** Absolute path of the artifact on disk; the Open link points at it. */
40
+ path: string;
41
+ /** Offered transcript width in columns. */
42
+ width: number;
43
+ }
44
+
45
+ /** Markdown would eat these inside a label; escaping keeps the title literal. */
46
+ function escapeMarkdown(text: string): string {
47
+ return text.replace(/([\\`*_[\]#])/g, "\\$1");
48
+ }
49
+
50
+ function clampWidth(text: string, width: number): string {
51
+ return visibleWidth(text) > width ? truncateToWidth(text, width) : text;
52
+ }
53
+
54
+ /**
55
+ * The artifact names itself where it can; the caller passes the fallback (`form`) otherwise.
56
+ *
57
+ * shape: none — dispatch object does not apply: one `||` form test plus ordered title extraction.
58
+ */
59
+ export function artifactTitle(form: string, source: string): string {
60
+ const plantumlStart = source.match(/@start\w+\s+"([^"\n]+)"/);
61
+ if (plantumlStart?.[1]) return plantumlStart[1].trim();
62
+ const plantumlTitle = source.match(/^\s*title\s+(.+)$/m);
63
+ if (plantumlTitle?.[1]) return plantumlTitle[1].trim();
64
+ if (form === "svg" || form === "html") {
65
+ const tag = source.match(/<title[^>]*>([^<]*)<\/title>/i);
66
+ if (tag?.[1]) return tag[1].trim();
67
+ const heading = source.match(/<h1[^>]*>([^<]+)<\/h1>/i);
68
+ if (heading?.[1]) return heading[1].trim();
69
+ }
70
+ return form;
71
+ }
72
+
73
+ /** shape: none — one box; every line's visible width is arithmetically bounded. */
74
+ function renderCard(spec: ArtifactCardSpec & { openUrl: string; pathLabel: string }, paint: ContentPaint): string {
75
+ const width = Math.max(CARD_MIN_WIDTH, Math.min(Math.floor(spec.width), CARD_MAX_WIDTH));
76
+ const inner = width - 2;
77
+ const contentWidth = Math.max(OPEN_WIDTH + 2, inner - 2);
78
+ const label = spec.typeLabel.toLowerCase();
79
+ const prefix = `─ ${label} `;
80
+ const dashes = Math.max(0, inner - visibleWidth(prefix));
81
+ const headerLine = paint.rule(`╭${prefix}${"─".repeat(dashes)}╮`);
82
+
83
+ const title = escapeMarkdown(spec.title);
84
+ // A link whose raw text cannot fit the line must not be emitted: the host wraps by RAW width, would
85
+ // split the token mid-way and print literal brackets with the URL dropped. It then degrades to the
86
+ // plain label, and the path line stays the way in.
87
+ const link = `[${OPEN_LABEL}](${spec.openUrl})`;
88
+ const linkFits = visibleWidth(link) <= Math.max(4, contentWidth - 1);
89
+ const titleBudget = contentWidth - (linkFits ? visibleWidth(link) : visibleWidth(OPEN_LABEL)) - 1;
90
+ const titleFitted = clampWidth(title, Math.max(1, titleBudget));
91
+ const actionText = linkFits ? link : OPEN_LABEL;
92
+ const titlePad = " ".repeat(Math.max(1, contentWidth - visibleWidth(titleFitted) - visibleWidth(actionText)));
93
+ // No escape codes inside the brackets: anything there breaks the link token.
94
+ const titleLine = `${paint.rule("│")} ${paint.quote(titleFitted)}${titlePad}${actionText} ${paint.rule("│")}`;
95
+ const copyPad = " ".repeat(Math.max(0, contentWidth - visibleWidth(COPY_LABEL)));
96
+ const copyLine = `${paint.rule("│")} ${paint.quote(COPY_LABEL)}${copyPad} ${paint.rule("│")}`;
97
+
98
+ // The full path is shown, wrapped — truncation is what made it useless to copy.
99
+ const safePath = spec.pathLabel.replace(/`/g, "'");
100
+ const chunkWidth = Math.max(8, contentWidth);
101
+ const chunks: string[] = [];
102
+ for (let index = 0; index < safePath.length; index += chunkWidth)
103
+ chunks.push(safePath.slice(index, index + chunkWidth));
104
+ if (chunks.length === 0) chunks.push("");
105
+ const pathLines = chunks
106
+ .map((chunk) => {
107
+ const pad = " ".repeat(Math.max(0, contentWidth - visibleWidth(chunk)));
108
+ return `${paint.rule("│")} ${paint.quote(chunk)}${pad} ${paint.rule("│")}`;
109
+ })
110
+ .join(" \n");
111
+ const bottomLine = paint.rule(`╰${"─".repeat(inner)}╯`);
112
+
113
+ // Hard breaks keep the box adjacent; the title line carries the link.
114
+ return `${headerLine} \n${titleLine} \n${copyLine} \n${pathLines} \n${bottomLine}\n`;
115
+ }
116
+
117
+ /**
118
+ * shape: none — one URL resolution plus one box build; the degrade branch lives in urlFor, not here.
119
+ */
120
+ export function renderArtifactCard(server: ArtifactServer, spec: ArtifactCardSpec, paint: ContentPaint): string {
121
+ // urlFor registers the path as carded and names the loopback URL; a degraded server logs once and
122
+ // the card falls back to a file:// link, so the session never sees the failure.
123
+ const openUrl = server.urlFor(spec.path) ?? pathToFileURL(spec.path).href;
124
+ return renderCard({ ...spec, openUrl, pathLabel: spec.path }, paint);
125
+ }
126
+
127
+ /**
128
+ * The transcript row count of a raster artifact: the height the host's image fit would occupy.
129
+ *
130
+ * shape: none — one parse plus one arithmetic; no dispatch.
131
+ */
132
+ export function imageLineMetadata(base64: string): { rows: number } {
133
+ const dimensions = getImageDimensions(base64, "image/png");
134
+ if (!dimensions) return { rows: 1 };
135
+ return { rows: Math.max(1, calculateImageRows(dimensions, DEFAULT_WIDTH_CELLS)) };
136
+ }
@@ -0,0 +1,396 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/engines.ts and adapters.ts — survives because: the engine
2
+ // pre-warm is what lets a synchronous markdown seam call a rendered artifact, and the per-form adapters
3
+ // are the only place where a fence becomes bytes.
4
+
5
+ /**
6
+ * The artifacts render seam (SA §6): one cache-first render per fence form, four warmed engines, and the
7
+ * two binary-backed forms behind the pinned spawn boundary.
8
+ *
9
+ * Why pre-warm: the host's markdown transformer is synchronous while every engine's initialisation is
10
+ * asynchronous, so boot awaits warmup() once and the render pass then calls only synchronous APIs. An
11
+ * engine that failed to warm throws a named error, which the caller turns into the raw-fence fallback.
12
+ *
13
+ * shape: dispatch object — trigger #1, one strategy per fence form, each a stateless function.
14
+ */
15
+
16
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { createRequire } from "node:module";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { initWasm, Resvg } from "@resvg/resvg-wasm";
21
+ import { instance, type Viz } from "@viz-js/viz";
22
+ import type { CanvasKit } from "canvaskit-wasm";
23
+ import type { Logger } from "../../../core/types/log.ts";
24
+ import { type ArtifactCache, type CacheMeta, createArtifactCache, renderCacheKey } from "./cache.ts";
25
+ import { type LocalSpawn, type LocalSpawnResult, plantumlIncludeOffense, spawnLocal } from "./local-binary.ts";
26
+ import { type PrereqProbe, resolveBrowserPinned, resolvePlantuml, resolvePrereq } from "./prereqs.ts";
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const SCOPE = "content.artifacts";
30
+
31
+ /** pi-tui's own default image width; wider fences are clamped to it (P transformer.ts:68). */
32
+ const MAX_IMAGE_WIDTH_CELLS = 60;
33
+ const LOCAL_TIMEOUT_MS = 5_000;
34
+ const BROWSER_TIMEOUT_MS = 15_000;
35
+ const VIEWPORT = "1200,800";
36
+ const VIRTUAL_TIME_BUDGET_MS = 3_000;
37
+
38
+ export type DiagramForm = "plantuml" | "svg" | "dot" | "html";
39
+
40
+ export type DiagramResult =
41
+ | { kind: "image"; base64: string }
42
+ | { kind: "text"; art: string }
43
+ | { kind: "svg"; svg: string };
44
+
45
+ export type EngineId = "plantuml" | "viz" | "resvg" | "canvaskit";
46
+
47
+ /** Ready-or-not per engine; the plantuml row is a file probe, never a spawn. */
48
+ export type EngineStatus = Record<EngineId, boolean>;
49
+
50
+ /**
51
+ * One render backend: warmed once at boot, then called synchronously per artifact.
52
+ */
53
+ export interface Engine {
54
+ readonly id: EngineId;
55
+ /** Warms what the backend needs; unavailability is reported by status(), never thrown. */
56
+ warmup(log?: Logger): Promise<void>;
57
+ /** Ready as of the last warm-up — no probe, no spawn. */
58
+ status(): boolean;
59
+ /**
60
+ * One backend call; throws a named Error while not ready. `source` is the form's own text — a diagram
61
+ * source for the engines behind those forms, the raster bytes base64-encoded for canvaskit.
62
+ */
63
+ render(source: string, opts: DiagramRenderOptions): DiagramResult;
64
+ }
65
+
66
+ export interface DiagramRenderOptions {
67
+ workDir?: string;
68
+ browserPath?: string;
69
+ /** Test seam; production resolves through prereqs.ts's absolute candidates. */
70
+ plantumlPath?: string;
71
+ timeoutMs?: number;
72
+ /** PlantUML only: "text" renders `-tutxt` Unicode art instead of SVG. */
73
+ mode?: "image" | "text";
74
+ /** Cache-key component: the transformer's clamped fence width. */
75
+ widthCells?: number;
76
+ /** Injected store and engines (tests and the composition root); production uses the defaults. */
77
+ cache?: ArtifactCache;
78
+ engines?: Partial<Record<EngineId, Engine>>;
79
+ /** Test seams: the prereq probe and the process runner. */
80
+ prereqs?: PrereqProbe;
81
+ spawn?: LocalSpawn;
82
+ log?: Logger;
83
+ }
84
+
85
+ function message(error: unknown): string {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+
89
+ /**
90
+ * boundary: canvaskit-wasm's CJS default resolves to the module namespace under nodenext, so the
91
+ * callable init is narrowed here rather than trusted from the declaration.
92
+ */
93
+ function isCanvasKitInit(value: unknown): value is () => Promise<CanvasKit> {
94
+ return typeof value === "function";
95
+ }
96
+
97
+ /** shape: closure returning an object literal — trigger #4, one warmed wasm instance per engine. */
98
+ function resvgEngine(): Engine {
99
+ let warmed = false;
100
+ return {
101
+ id: "resvg",
102
+ async warmup(log) {
103
+ if (warmed) return;
104
+ try {
105
+ await initWasm(readFileSync(require.resolve("@resvg/resvg-wasm/index_bg.wasm")));
106
+ warmed = true;
107
+ } catch (error) {
108
+ log?.logOnce(`content.artifacts:engine:resvg`, SCOPE, `svg engine unavailable: ${message(error)}`);
109
+ }
110
+ },
111
+ status: () => warmed,
112
+ render(source) {
113
+ if (!warmed) throw new Error("svg engine not warmed");
114
+ return { kind: "image", base64: Buffer.from(new Resvg(source).render().asPng()).toString("base64") };
115
+ },
116
+ };
117
+ }
118
+
119
+ /** shape: closure returning an object literal — trigger #4, one warmed wasm instance per engine. */
120
+ function vizEngine(): Engine {
121
+ let viz: Viz | null = null;
122
+ return {
123
+ id: "viz",
124
+ async warmup(log) {
125
+ if (viz) return;
126
+ try {
127
+ viz = await instance();
128
+ } catch (error) {
129
+ log?.logOnce("content.artifacts:engine:viz", SCOPE, `graphviz engine unavailable: ${message(error)}`);
130
+ }
131
+ },
132
+ status: () => viz !== null,
133
+ render(source) {
134
+ if (!viz) throw new Error("dot engine not warmed");
135
+ return { kind: "svg", svg: viz.renderString(source, { format: "svg" }) };
136
+ },
137
+ };
138
+ }
139
+
140
+ /** shape: closure returning an object literal — trigger #4, one warmed wasm instance per engine. */
141
+ function canvasKitEngine(): Engine {
142
+ let kit: CanvasKit | null = null;
143
+ return {
144
+ id: "canvaskit",
145
+ async warmup(log) {
146
+ if (kit) return;
147
+ try {
148
+ const loaded: { default: unknown } = await import("canvaskit-wasm");
149
+ const init = loaded.default;
150
+ if (!isCanvasKitInit(init)) throw new Error("canvaskit-wasm exposes no init function");
151
+ kit = await init();
152
+ } catch (error) {
153
+ log?.logOnce("content.artifacts:engine:canvaskit", SCOPE, `image engine unavailable: ${message(error)}`);
154
+ }
155
+ },
156
+ status: () => kit !== null,
157
+ render(source) {
158
+ if (!kit) throw new Error("image engine not warmed");
159
+ const image = kit.MakeImageFromEncoded(Buffer.from(source, "base64"));
160
+ if (!image) throw new Error("image decode failed");
161
+ try {
162
+ const png = image.encodeToBytes();
163
+ if (!png) throw new Error("image encode failed");
164
+ return { kind: "image", base64: Buffer.from(png).toString("base64") };
165
+ } finally {
166
+ image.delete();
167
+ }
168
+ },
169
+ };
170
+ }
171
+
172
+ /**
173
+ * An injected probe wins over the warmed candidate so a test is machine-independent, and a missing
174
+ * binary throws its install hint here — before any spawn site is reached.
175
+ */
176
+ function plantumlPath(warmed: string | undefined, opts: DiagramRenderOptions): string {
177
+ if (opts.plantumlPath) return opts.plantumlPath;
178
+ if (opts.prereqs) return resolvePlantuml(opts.prereqs);
179
+ return warmed ?? resolvePlantuml();
180
+ }
181
+
182
+ /** shape: closure returning an object literal — trigger #4, one resolved binary plus the spawn call. */
183
+ function plantumlEngine(): Engine {
184
+ let warmed: string | undefined;
185
+ return {
186
+ id: "plantuml",
187
+ async warmup() {
188
+ warmed = resolvePrereq("plantuml").path || undefined;
189
+ },
190
+ status: () => warmed !== undefined,
191
+ render(source, opts) {
192
+ const offense = plantumlIncludeOffense(source);
193
+ if (offense) throw new Error(`plantuml: "${offense}" is rejected — includes can read local files`);
194
+ const binary = plantumlPath(warmed, opts);
195
+ return withWorkDir(opts.workDir, (dir) => {
196
+ const file = join(dir, "diagram.puml");
197
+ writeFileSync(file, source);
198
+ const textMode = opts.mode === "text";
199
+ const timeoutMs = opts.timeoutMs ?? LOCAL_TIMEOUT_MS;
200
+ const extension = textMode ? ".utxt" : ".svg";
201
+ // `-o` pins the directory; the FILE name is whatever plantuml chose — it uses
202
+ // the diagram's title when the source has one (observed 2026-10-03), and the
203
+ // stem-swapped input name otherwise. Guessing either name has failed twice,
204
+ // so the output is discovered, not predicted.
205
+ run(opts, "plantuml", binary, [textMode ? "-tutxt" : "-tsvg", "-o", dir, file], timeoutMs);
206
+ const produced = readdirSync(dir).filter((name) => name.toLowerCase().endsWith(extension));
207
+ if (produced.length === 0) throw new Error(`plantuml: no ${extension} output was written`);
208
+ const out = join(dir, produced.sort()[0] as string);
209
+ return textMode
210
+ ? { kind: "text", art: readFileSync(out, "utf8") }
211
+ : { kind: "svg", svg: readFileSync(out, "utf8") };
212
+ });
213
+ },
214
+ };
215
+ }
216
+
217
+ let defaults: Record<EngineId, Engine> | undefined;
218
+
219
+ /** shape: none — one lazy table; each engine holds its own warmed wasm state. */
220
+ function defaultEngines(): Record<EngineId, Engine> {
221
+ defaults ??= {
222
+ plantuml: plantumlEngine(),
223
+ viz: vizEngine(),
224
+ resvg: resvgEngine(),
225
+ canvaskit: canvasKitEngine(),
226
+ };
227
+ return defaults;
228
+ }
229
+
230
+ /** shape: none — one read of the warmed flags plus one absolute-path probe for plantuml. */
231
+ export function status(probe: PrereqProbe = {}): EngineStatus {
232
+ const engines = defaultEngines();
233
+ return {
234
+ plantuml: resolvePrereq("plantuml", probe).path !== "",
235
+ viz: engines.viz.status(),
236
+ resvg: engines.resvg.status(),
237
+ canvaskit: engines.canvaskit.status(),
238
+ };
239
+ }
240
+
241
+ /** shape: none — four independent warm-ups, awaited in turn; a failure is reported, never fatal. */
242
+ export async function warmup(log?: Logger): Promise<EngineStatus> {
243
+ for (const engine of Object.values(defaultEngines())) await engine.warmup(log);
244
+ return status();
245
+ }
246
+
247
+ function spawnFailure(form: DiagramForm, cmd: string, result: LocalSpawnResult, timeoutMs: number): Error {
248
+ const code = (result.error as NodeJS.ErrnoException | undefined)?.code;
249
+ if (code === "ENOENT") return new Error(`${form}: "${cmd}" not found`);
250
+ if (code === "ETIMEDOUT" || result.signal === "SIGTERM") {
251
+ return new Error(`${form}: "${cmd}" timed out after ${timeoutMs}ms`);
252
+ }
253
+ if (result.error) return new Error(`${form}: "${cmd}" failed: ${result.error.message}`);
254
+ const stderr = result.stderr?.toString("utf8").trim().split("\n")[0] ?? "";
255
+ return new Error(`${form}: "${cmd}" exited ${result.status ?? "on signal"}${stderr ? `: ${stderr}` : ""}`);
256
+ }
257
+
258
+ function run(opts: DiagramRenderOptions, form: DiagramForm, cmd: string, args: string[], timeoutMs: number): void {
259
+ const result = (opts.spawn ?? spawnLocal)(cmd, args, { timeoutMs });
260
+ if (result.error || result.status !== 0) throw spawnFailure(form, cmd, result, timeoutMs);
261
+ }
262
+
263
+ /** A caller-provided workDir is left alone (tests inspect it); a created one is removed. */
264
+ function withWorkDir<T>(workDir: string | undefined, fn: (dir: string) => T): T {
265
+ const dir = workDir ?? mkdtempSync(join(tmpdir(), "pi-render-canvas-"));
266
+ try {
267
+ return fn(dir);
268
+ } finally {
269
+ if (!workDir) rmSync(dir, { recursive: true, force: true });
270
+ }
271
+ }
272
+
273
+ /**
274
+ * A minimal dark shell so a bare fragment screenshots legibly and without a
275
+ * white flash. The CSP is part of the boundary: a model-authored fragment may
276
+ * style itself but cannot load anything or reach the network.
277
+ */
278
+ function wrapHtmlFragment(fragment: string): string {
279
+ return `<!doctype html>
280
+ <html><head><meta charset="utf-8">
281
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
282
+ <style>
283
+ :root{color-scheme:dark}
284
+ body{margin:16px;background:#16161e;color:#e6e6ef;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
285
+ </style></head><body>${fragment}</body></html>`;
286
+ }
287
+
288
+ /** shape: none — one temp-dir screenshot run; the browser is a pinned absolute path, never PATH. */
289
+ function renderHtml(source: string, opts: DiagramRenderOptions): DiagramResult {
290
+ return withWorkDir(opts.workDir, (dir) => {
291
+ const file = join(dir, "fragment.html");
292
+ const out = join(dir, "screenshot.png");
293
+ writeFileSync(file, wrapHtmlFragment(source));
294
+ const browser = resolveBrowserPinned(opts.browserPath, opts.prereqs);
295
+ const timeoutMs = opts.timeoutMs ?? BROWSER_TIMEOUT_MS;
296
+ run(
297
+ opts,
298
+ "html",
299
+ browser,
300
+ [
301
+ "--headless",
302
+ "--disable-gpu",
303
+ // No script flag here: `--blink-settings=scriptEnabled=false` makes the
304
+ // Playwright headless shell exit 0 without writing the screenshot
305
+ // (2026-10-03), and the shell's CSP already blocks scripts — `script-src`
306
+ // inherits `default-src 'none'`, so neither inline nor external scripts run.
307
+ // A throwaway profile inside the temp dir: the user's own profile is
308
+ // never read or locked, and the cleanup is the work dir's.
309
+ `--user-data-dir=${join(dir, "browser-profile")}`,
310
+ "--no-first-run",
311
+ "--disable-background-networking",
312
+ "--hide-scrollbars",
313
+ `--screenshot=${out}`,
314
+ `--window-size=${VIEWPORT}`,
315
+ `--virtual-time-budget=${VIRTUAL_TIME_BUDGET_MS}`,
316
+ `file://${file}`,
317
+ ],
318
+ timeoutMs,
319
+ );
320
+ if (!existsSync(out)) throw new Error("html: browser produced no screenshot");
321
+ return { kind: "image", base64: readFileSync(out).toString("base64") };
322
+ });
323
+ }
324
+
325
+ // shape: dispatch object — trigger #1, one strategy per fence form, each a plain function.
326
+ const FORM_RENDERERS: Record<DiagramForm, (source: string, opts: DiagramRenderOptions) => DiagramResult> = {
327
+ plantuml: (source, opts) => engineOf("plantuml", opts).render(source, opts),
328
+ svg: (source) => ({ kind: "svg", svg: source }),
329
+ dot: (source, opts) => engineOf("viz", opts).render(source, opts),
330
+ html: renderHtml,
331
+ };
332
+
333
+ function engineOf(id: EngineId, opts: DiagramRenderOptions): Engine {
334
+ return opts.engines?.[id] ?? defaultEngines()[id];
335
+ }
336
+
337
+ /** A cache hit is the artifact the last identical render wrote; an unreadable one is simply a miss. */
338
+ function readCached(form: DiagramForm, key: string, cache: ArtifactCache): DiagramResult | undefined {
339
+ try {
340
+ if (form === "html") {
341
+ const entry = cache.get(key);
342
+ return entry === undefined ? undefined : { kind: "image", base64: entry.png.toString("base64") };
343
+ }
344
+ const path = cache.vectorPath(key);
345
+ return path === undefined ? undefined : { kind: "svg", svg: readFileSync(path, "utf8") };
346
+ } catch {
347
+ return undefined;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * A write is an optimization: a full disk or an unusable directory must not cost the render that just
353
+ * succeeded, and eviction runs here for the same reason it exists at all.
354
+ */
355
+ function writeCached(
356
+ form: DiagramForm,
357
+ key: string,
358
+ mode: string,
359
+ widthCells: number,
360
+ result: DiagramResult,
361
+ cache: ArtifactCache,
362
+ log: Logger | undefined,
363
+ ): void {
364
+ // rows belongs to the emission step (T-26); the engine seam never knows the rendered line count.
365
+ const meta: CacheMeta = { source: form, mode, mime: "", rows: 0, widthCells, createdAt: new Date().toISOString() };
366
+ try {
367
+ if (result.kind === "svg") cache.putVector(key, result.svg, { ...meta, mime: "image/svg+xml" });
368
+ else if (result.kind === "image")
369
+ cache.put(key, Buffer.from(result.base64, "base64"), { ...meta, mime: "image/png" });
370
+ else return;
371
+ cache.evict();
372
+ } catch (error) {
373
+ log?.logOnce(`content.artifacts:cache:${key}`, SCOPE, `artifact not cached: ${message(error)}`);
374
+ }
375
+ }
376
+
377
+ /**
378
+ * shape: none — cache-first dispatch; the dedup branch is the whole contract (a repeated fence must
379
+ * never run an engine twice) and every other branch is one table lookup.
380
+ */
381
+ export function renderDiagram(form: DiagramForm, source: string, opts: DiagramRenderOptions = {}): DiagramResult {
382
+ const mode = opts.mode === "text" ? "text" : "image";
383
+ const widthCells = Math.max(1, Math.floor(opts.widthCells ?? MAX_IMAGE_WIDTH_CELLS));
384
+ const cache = opts.cache ?? createArtifactCache({ log: opts.log });
385
+ const key = renderCacheKey(form, source, mode, widthCells);
386
+
387
+ // Only artifacts are cached: PlantUML's text art is a view of the render, not a file (P transformer.ts:169-183).
388
+ if (mode === "image") {
389
+ const hit = readCached(form, key, cache);
390
+ if (hit !== undefined) return hit;
391
+ }
392
+
393
+ const result = FORM_RENDERERS[form](source, opts);
394
+ if (mode === "image") writeCached(form, key, mode, widthCells, result, cache, opts.log);
395
+ return result;
396
+ }
@@ -0,0 +1,80 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/local-binary.ts — survives because: a binary cannot be
2
+ // certified from a diff, so what survives is the bounded way to call one — absolute candidates only, no
3
+ // shell, a timeout on every spawn, and the include guard in front of the PlantUML JVM.
4
+
5
+ /**
6
+ * The single audited spawn boundary for the artifacts feature, and the package's only
7
+ * `node:child_process` site. It exists for the two forms whose engine has no Node-native replacement:
8
+ * the PlantUML JVM and a headless browser for HTML fences.
9
+ *
10
+ * What is bounded here, since a binary cannot be certified from a diff:
11
+ * - provenance: absolute paths only, from `prereqs.ts`'s candidate lists —
12
+ * no PATH lookup, no environment override;
13
+ * - no shell: fixed argv straight into spawnSync, caller-supplied temp dirs;
14
+ * - timeouts on every spawn;
15
+ * - PlantUML sources carrying an include directive are rejected before the
16
+ * spawn (includes can read local files into the rendered image);
17
+ * - the HTML wrapper pins `default-src 'none'` (script-src inherits it, so
18
+ * scripts cannot run, and the fragment cannot fetch), and the browser gets
19
+ * a throwaway profile inside the caller's temp dir.
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ export interface LocalSpawnResult {
28
+ status: number | null;
29
+ signal?: NodeJS.Signals | null;
30
+ stdout?: Buffer;
31
+ stderr?: Buffer;
32
+ error?: Error | null;
33
+ }
34
+
35
+ /** The one process-runner signature: fixed argv, caller-supplied temp dirs, always a timeout. */
36
+ export type LocalSpawn = (cmd: string, args: string[], opts: { cwd?: string; timeoutMs: number }) => LocalSpawnResult;
37
+
38
+ /** shape: none — the one process runner; a seam elsewhere lets tests avoid it. */
39
+ export function spawnLocal(cmd: string, args: string[], opts: { cwd?: string; timeoutMs: number }): LocalSpawnResult {
40
+ return spawnSync(cmd, args, { cwd: opts.cwd, timeout: opts.timeoutMs, encoding: "buffer" });
41
+ }
42
+
43
+ /** cmux ships its agent CLI inside the app bundle; PATH is never consulted. */
44
+ const CMUX_CANDIDATES = [
45
+ "/Applications/cmux.app/Contents/Resources/bin/cmux",
46
+ join(homedir(), "Applications/cmux.app/Contents/Resources/bin/cmux"),
47
+ ];
48
+ const CMUX_TIMEOUT_MS = 10_000;
49
+
50
+ /** shape: none — one candidate scan; undefined means "no cmux on this machine". */
51
+ export function resolveCmux(exists: (path: string) => boolean = existsSync): string | undefined {
52
+ return CMUX_CANDIDATES.find((candidate) => exists(candidate));
53
+ }
54
+
55
+ /**
56
+ * Opens a URL as a browser surface beside the caller's terminal (a right
57
+ * split; `--placement dock` exists but is disabled in practice — observed
58
+ * 2026-10-03). cmux routes only http(s) into its embedded browser and file://
59
+ * links fall to the OS handlers, which is why the card's click uses the
60
+ * artifact server's http URL and this CLI call is the keyboard fallback.
61
+ */
62
+ export function openInCmux(
63
+ url: string,
64
+ opts: { binary?: string; resolve?: () => string | undefined; timeoutMs?: number; spawn?: LocalSpawn } = {},
65
+ ): LocalSpawnResult {
66
+ const binary = opts.binary ?? (opts.resolve ?? resolveCmux)();
67
+ if (binary === undefined) throw new Error("cmux: CLI not found");
68
+ const spawn = opts.spawn ?? spawnLocal;
69
+ return spawn(binary, ["new-pane", "--type", "browser", "--url", url, "--focus", "false"], {
70
+ timeoutMs: opts.timeoutMs ?? CMUX_TIMEOUT_MS,
71
+ });
72
+ }
73
+
74
+ const PLANTUML_INCLUDE = /^\s*!(?:include|includeurl|includesub|include_many|import)\b/im;
75
+
76
+ /** shape: none — one directive scan; returns the offending token, or undefined. */
77
+ export function plantumlIncludeOffense(source: string): string | undefined {
78
+ const match = source.match(PLANTUML_INCLUDE);
79
+ return match ? match[0].trim() : undefined;
80
+ }