@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,252 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/images.ts:24-33 (resolve, read, every miss
2
+ // `undefined`), card.ts:46-101 (the boxed path card) and transformer.ts:106-108 (isLocalRef) —
3
+ // survives because: "a local reference becomes a card and any miss leaves the model's own text in
4
+ // place" is the rule all three carried. The registration-era halves do not port: no pixels, no base64,
5
+ // no canvaskit conversion, no artifact URL and no Open link — pi-render draws no pixels and owns no
6
+ // server (T-26).
7
+
8
+ /**
9
+ * image-card.ts — the surface that turns a local image reference into a card: path and dimensions,
10
+ * never pixels.
11
+ *
12
+ * Boundary: untrusted markdown in, and the only fallible step is the local header read — a missing,
13
+ * unreadable, or non-image target leaves the reference byte-identical and never throws, so a broken
14
+ * image reference can never take an answer down.
15
+ *
16
+ * shape: closure returning an object literal — trigger #4, the probe and the log sink are captured per
17
+ * surface instance; no subclassing and no instanceof.
18
+ */
19
+
20
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
21
+ import { basename, extname, isAbsolute, resolve } from "node:path";
22
+ import type { TransformContext } from "../../core/types/host.ts";
23
+ import type { ContentPaint } from "../../core/types/paint.ts";
24
+ import type { Surface } from "./types.ts";
25
+
26
+ /** Dimensions read from a local image header; a format we do not walk yields neither field. */
27
+ export interface ImageFacts {
28
+ /** Pixel width. Absent when the header read did not produce it. */
29
+ width?: number;
30
+ /** Pixel height. Absent when the header read did not produce it. */
31
+ height?: number;
32
+ }
33
+
34
+ /**
35
+ * The local-probe seam: one absolute path in, `undefined` when no image is readable there — the
36
+ * reference then stays raw — otherwise the facts, where an empty object still cards (a real image
37
+ * whose dimensions this probe did not read).
38
+ */
39
+ export type ImageProbe = (absPath: string) => ImageFacts | undefined;
40
+
41
+ export interface ImageCardDeps {
42
+ /** Base for relative references; the transform context carries no cwd. Defaults to process.cwd(). */
43
+ projectDir?: string;
44
+ /** Dimension reader. Defaults to one bounded header read of the file itself. */
45
+ probe?: ImageProbe;
46
+ /** Diagnostics, at most one call per miss; the composition root injects the shared logger. */
47
+ log?: (line: string) => void;
48
+ }
49
+
50
+ /** Cards read best narrow — they do not sprawl across the transcript (the predecessor's 64). */
51
+ const CARD_MAX_WIDTH = 64;
52
+ /** Below this a path cannot be shown at all, so the card wins over the offered width. */
53
+ const CARD_MIN_WIDTH = 24;
54
+ /** One bounded read: JPEG's frame marker can sit behind up to 64 KiB of EXIF. */
55
+ const HEAD_BYTES = 64 * 1024;
56
+
57
+ /** A malformed header can hold nonsense; only two positive integers become card dimensions. */
58
+ function dims(width: number, height: number): ImageFacts | undefined {
59
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return undefined;
60
+ return { width, height };
61
+ }
62
+
63
+ /** PNG: the IHDR chunk is first by spec, so both dimensions sit at fixed offsets. */
64
+ function pngFacts(head: Buffer): ImageFacts | undefined {
65
+ if (head.length < 24 || head.toString("latin1", 12, 16) !== "IHDR") return undefined;
66
+ return dims(head.readUInt32BE(16), head.readUInt32BE(20));
67
+ }
68
+
69
+ /** GIF: the logical screen descriptor follows the 6-byte signature. */
70
+ function gifFacts(head: Buffer): ImageFacts | undefined {
71
+ if (head.length < 10) return undefined;
72
+ return dims(head.readUInt16LE(6), head.readUInt16LE(8));
73
+ }
74
+
75
+ /** JPEG: no fixed offset — walk the segment chain to the first SOFn, which carries the frame size. */
76
+ function jpegFacts(head: Buffer): ImageFacts | undefined {
77
+ let at = 2;
78
+ while (at + 9 <= head.length) {
79
+ if (head[at] !== 0xff) return undefined;
80
+ const marker = head[at + 1] ?? 0;
81
+ // SOF0-3, SOF5-7, SOF9-11 carry the frame size; 0xc4/0xc8/0xcc are DHT/JPG/DAC.
82
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
83
+ return dims(head.readUInt16BE(at + 7), head.readUInt16BE(at + 5));
84
+ }
85
+ // RST/SOI/EOI/TEM carry no length field, so the walk steps over them instead of misreading one.
86
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
87
+ at += 2;
88
+ continue;
89
+ }
90
+ const length = head.readUInt16BE(at + 2);
91
+ if (length < 2) return undefined;
92
+ at += 2 + length;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /** shape: dispatch object — trigger #1, three signature-keyed readers, each one stateless header read. */
98
+ const HEADERS: ReadonlyArray<readonly [string, (head: Buffer) => ImageFacts | undefined]> = [
99
+ ["\u0089PNG", pngFacts],
100
+ ["GIF8", gifFacts],
101
+ ["\u00ff\u00d8\u00ff", jpegFacts],
102
+ ];
103
+
104
+ /** The image signatures this module knows. `undefined` = not an image, so the reference stays raw. */
105
+ function parseFacts(head: Buffer): ImageFacts | undefined {
106
+ for (const [magic, read] of HEADERS) {
107
+ if (head.toString("latin1", 0, magic.length) === magic) return read(head) ?? {};
108
+ }
109
+ // WebP is a RIFF container whose dimensions live in a bit-packed chunk this module does not walk;
110
+ // recognising it keeps the card (path alone) instead of dropping a real image reference.
111
+ if (head.toString("latin1", 0, 4) === "RIFF" && head.toString("latin1", 8, 12) === "WEBP") return {};
112
+ return undefined;
113
+ }
114
+
115
+ /** The open descriptor, or `undefined` when there is no readable file at the path. */
116
+ function openImage(absPath: string): number | undefined {
117
+ try {
118
+ if (!statSync(absPath).isFile()) return undefined;
119
+ return openSync(absPath, "r");
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The default probe: one stat plus one bounded header read — the pixels are never read, and a miss
127
+ * resolves to `undefined` so it can never become an exception in the render path.
128
+ */
129
+ function readFacts(absPath: string): ImageFacts | undefined {
130
+ const fd = openImage(absPath);
131
+ if (fd === undefined) return undefined;
132
+ try {
133
+ const head = Buffer.allocUnsafe(HEAD_BYTES);
134
+ const read = readSync(fd, head, 0, HEAD_BYTES, 0);
135
+ return parseFacts(head.subarray(0, read));
136
+ } catch {
137
+ // The file is there but its header is not readable; a card with the path alone still helps.
138
+ return {};
139
+ } finally {
140
+ closeSync(fd);
141
+ }
142
+ }
143
+
144
+ /** A scheme, a protocol-relative URL, or an anchor is not a local path — a data: URI is a scheme. */
145
+ function isLocalReference(href: string): boolean {
146
+ return !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith("//") && !href.startsWith("#");
147
+ }
148
+
149
+ /** A row wider than the box wraps onto the next row; truncation is what made the path useless. */
150
+ function chunk(text: string, width: number): string[] {
151
+ const rows: string[] = [];
152
+ for (let at = 0; at < text.length; at += width) rows.push(text.slice(at, at + width));
153
+ return rows;
154
+ }
155
+
156
+ /** The box hugs the offered width but never more than a card reads well at. */
157
+ function cardWidth(availableWidth: number): number {
158
+ return Math.max(CARD_MIN_WIDTH, Math.min(availableWidth, CARD_MAX_WIDTH));
159
+ }
160
+
161
+ /** shape: none — one box; every row is a constant plus a value whose width is known before painting. */
162
+ function renderImageCard(absPath: string, facts: ImageFacts, paint: ContentPaint, width: number): string {
163
+ const inner = width - 2;
164
+ const content = Math.max(1, inner - 2);
165
+ const extension = extname(absPath).slice(1).toLowerCase();
166
+ const label = extension === "" ? "image" : extension;
167
+ const header = `─ ${label} `;
168
+ const name = basename(absPath);
169
+ const size = facts.width === undefined || facts.height === undefined ? "" : `${facts.width}×${facts.height}`;
170
+ const title = size === "" ? name : `${name} · ${size}`;
171
+ const rows = [...chunk(title, content), ...chunk(absPath, content)];
172
+ const lines = [
173
+ paint.rule(`╭${header}${"─".repeat(Math.max(0, inner - header.length))}╮`),
174
+ ...rows.map((row) => `${paint.rule("│")} ${paint.quote(row.padEnd(content))} ${paint.rule("│")}`),
175
+ paint.rule(`╰${"─".repeat(inner)}╯`),
176
+ ];
177
+ // Hard breaks keep each box row on its own line when the reference sat inside a paragraph.
178
+ return `${lines.join(" \n")}\n`;
179
+ }
180
+
181
+ /** A fenced block is code: the host's own markdown parser sees no image token inside it either. */
182
+ const FENCE = /^\s*(`{3,}|~{3,})/;
183
+ /** CommonMark image syntax; a destination holding a space or a parenthesis is left alone, not mangled. */
184
+ const IMAGE_REF = /!\[[^\]]*\]\(([^()\s]+)(?:\s+"[^"]*")?\)/g;
185
+
186
+ /** shape: none — one line pass with a fence flag; there is no discriminator to dispatch on. */
187
+ function rewriteOutsideFences(markdown: string, rewriteLine: (line: string) => string): string {
188
+ const lines = markdown.split("\n");
189
+ let open: string | undefined;
190
+ let changed = false;
191
+ for (let at = 0; at < lines.length; at += 1) {
192
+ const line = lines[at];
193
+ if (line === undefined) continue;
194
+ const fence = FENCE.exec(line)?.[1];
195
+ if (open !== undefined) {
196
+ // A closing fence is at least as long as the run that opened the block.
197
+ if (fence?.startsWith(open)) open = undefined;
198
+ continue;
199
+ }
200
+ if (fence !== undefined) {
201
+ open = fence;
202
+ continue;
203
+ }
204
+ const rewritten = rewriteLine(line);
205
+ if (rewritten !== line) {
206
+ lines[at] = rewritten;
207
+ changed = true;
208
+ }
209
+ }
210
+ return changed ? lines.join("\n") : markdown;
211
+ }
212
+
213
+ /**
214
+ * shape: closure returning an object literal — trigger #4, the probe, the log sink and the base
215
+ * directory are captured once and the module holds no other state.
216
+ */
217
+ export function createImageCardSurface(deps: ImageCardDeps = {}): Surface {
218
+ const probe = deps.probe ?? readFacts;
219
+ const log = deps.log ?? ((): void => {});
220
+ const projectDir = deps.projectDir ?? process.cwd();
221
+
222
+ /** The card for one reference, or `undefined` when the reference must stay exactly as written. */
223
+ const cardFor = (href: string, paint: ContentPaint, availableWidth: number): string | undefined => {
224
+ if (!isLocalReference(href)) return undefined;
225
+ const absPath = isAbsolute(href) ? href : resolve(projectDir, href);
226
+ let facts: ImageFacts | undefined;
227
+ try {
228
+ facts = probe(absPath);
229
+ } catch (error) {
230
+ // The probe is caller-supplied and this path never throws: one line, and the reference stays.
231
+ log(`image ${href}: ${error instanceof Error ? error.message : String(error)}`);
232
+ return undefined;
233
+ }
234
+ if (facts === undefined) {
235
+ log(`image ${href}: no image readable at ${absPath}`);
236
+ return undefined;
237
+ }
238
+ return renderImageCard(absPath, facts, paint, cardWidth(availableWidth));
239
+ };
240
+
241
+ return {
242
+ rewrite(markdown: string, ctx: TransformContext, paint: ContentPaint): string {
243
+ const rewriteLine = (line: string): string =>
244
+ line.replace(IMAGE_REF, (reference: string, href: unknown) => {
245
+ // The captured group is host markdown text: narrowed, never assumed.
246
+ if (typeof href !== "string") return reference;
247
+ return cardFor(href, paint, ctx.availableWidth) ?? reference;
248
+ });
249
+ return rewriteOutsideFences(markdown, rewriteLine);
250
+ },
251
+ };
252
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * index.ts — the markdown transformer: final-assistant gate, per-surface registry check, dispatch,
3
+ * identity return.
4
+ *
5
+ * Boundary: the host calls this on the transcript render path, so nothing here throws and nothing
6
+ * allocates per message. A disabled surface is skipped, and the input string returns by reference
7
+ * whenever no surface changed it — the host's own caching reads that reference.
8
+ *
9
+ * shape: closure returning a function — trigger #4, the factory captures its injected collaborators
10
+ * (registry, surfaces, paint, log) and holds no state to classify.
11
+ *
12
+ * ported from pi-pretty-tui/src/features/canvas/transformer.ts — survives because: the final-assistant
13
+ * gate, the degrade-to-the-input-verbatim fallback, and mermaid staying with the host's own transformer
14
+ * are the host-aligned behaviors that file got right. Its parser, settings load, and per-form render
15
+ * code do not port: each surface owns one rewrite here, and the registry owns every toggle.
16
+ */
17
+
18
+ import type { TransformContext } from "../../core/types/host.ts";
19
+ import type { Logger } from "../../core/types/log.ts";
20
+ import type { ContentPaint } from "../../core/types/paint.ts";
21
+ import type { Registry } from "../../core/types/registry.ts";
22
+ import type { Surface, SurfaceKey } from "./types.ts";
23
+
24
+ /** Registry key of the artifacts fence pass — T-28 injects its surface when artifacts land (T-25/T-26). */
25
+ const ARTIFACTS_KEY = "artifacts";
26
+
27
+ /** Dispatch order: the four markdown surfaces, then the fence pass over their output. */
28
+ const SURFACE_ORDER: readonly SurfaceKey[] = ["table", "codePanel", "jsonPanel", "imageCard"];
29
+
30
+ const SCOPE = "content";
31
+ const LOG_KEY = "content:transform";
32
+
33
+ /** The surface table T-28 composes: one entry per SurfaceKey, plus the optional fence pass. */
34
+ export type ContentSurfaces = Record<SurfaceKey, Surface> & {
35
+ /** Artifact fences (plantuml/dot/svg/html). Absent until T-28 wires it; the slot is dispatched last. */
36
+ artifacts?: Surface;
37
+ };
38
+
39
+ /** The transformer's injected collaborators — the region-2 paint roles, plus the keyed logger. */
40
+ export interface ContentTransformerDeps {
41
+ /** Live content paint; surfaces receive it per invocation. Required. */
42
+ paint: ContentPaint;
43
+ /** Keyed diagnostics; the never-throw path logs exactly one line. Required. */
44
+ log: Logger;
45
+ }
46
+
47
+ // shape: closure returning a function — trigger #4, collaborators captured once, the per-message body
48
+ // mutates no captured state.
49
+ export function createContentTransformer(
50
+ registry: Registry,
51
+ surfaces: ContentSurfaces,
52
+ deps: ContentTransformerDeps,
53
+ ): (markdown: string, ctx: TransformContext) => string {
54
+ const { paint, log } = deps;
55
+ const artifacts = surfaces.artifacts;
56
+
57
+ /** One dispatch step: an absent or disabled slot is skipped, otherwise it rewrites the current text. */
58
+ const run = (key: string, surface: Surface | undefined, input: string, ctx: TransformContext): string => {
59
+ if (surface === undefined || !registry.isEnabled(key)) return input;
60
+ const next = surface.rewrite(input, ctx, paint);
61
+ // A surface signals "nothing matched" by returning its input; comparing by value also discards an
62
+ // equal copy, so a no-op pass keeps the reference the host handed us.
63
+ return next === input ? input : next;
64
+ };
65
+
66
+ return (markdown, ctx) => {
67
+ // The predecessor's gate, unchanged: only a settled assistant message is rewritten.
68
+ if (ctx.messageType !== "assistant" || ctx.isStreaming) return markdown;
69
+ try {
70
+ let out = markdown;
71
+ for (const key of SURFACE_ORDER) out = run(`content.${key}`, surfaces[key], out, ctx);
72
+ return run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx);
73
+ } catch (error) {
74
+ // One failing surface must not cost the answer: the original input returns untouched.
75
+ log.logOnce(LOG_KEY, SCOPE, `transform failed: ${error instanceof Error ? error.message : String(error)}`);
76
+ return markdown;
77
+ }
78
+ };
79
+ }
@@ -0,0 +1,116 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/transformer.ts:262-307 (standaloneFileCard, JSON branch)
2
+ // + json-panel.test.ts — survives because: a local JSON file the message links to is the one content
3
+ // kind whose value is its colored lines, so it renders as a pane (40-line cap plus a way out) rather
4
+ // than as a card; only the color source changed (paint roles plus the injected CodeTheme).
5
+
6
+ /**
7
+ * json-panel.ts — a ```json fence or a standalone link to a local `.json`/`.jsonc`/`.jsonl` file renders
8
+ * through the same panel frame as a code fence, with `json` as the language.
9
+ *
10
+ * Boundary: the linked file's bytes cross a trust boundary, so they are read through an injected reader
11
+ * (the composition root supplies the real one; a test injects a fake) and, when unreadable, the raw link
12
+ * is kept and one keyed line is logged. Nothing here throws.
13
+ *
14
+ * shape: none — a factory over injected collaborators; the factory declares its own shape below.
15
+ */
16
+
17
+ import type { CodeTheme, ContentPaint, Logger } from "../../core/types.ts";
18
+ import { mapFencedBlocks, panelWidth, renderPanel, toMarkdownRows } from "./code-panel.ts";
19
+ import type { Surface } from "./types.ts";
20
+
21
+ /** JSON's content is what the reader wants up to here; past it the file stays one click away (P transformer.ts:265). */
22
+ const MAX_PANE_LINES = 40;
23
+ /** A fence spelling that still highlights as JSON (P transformer.ts:264). */
24
+ const JSON_LANGS = new Set(["json", "jsonc", "jsonl"]);
25
+ /** A paragraph that is nothing but a link, captured as its href (P transformer.ts:270-275). */
26
+ const LINK_ONLY = /^\[[^\]]*\]\(([^)\s]+)\)$/;
27
+ const JSON_EXTENSION = /\.(jsonc?|jsonl)$/i;
28
+
29
+ /** The collaborators the pane reads through: the file reader and the optional diagnostics sink. */
30
+ export interface JsonPanelDeps {
31
+ /** Reads the linked file; returns undefined when it cannot be read (missing, unreadable, a directory). */
32
+ read(href: string): string | undefined;
33
+ /** Once-per-key diagnostics for an unreadable link. Optional; absent is silent. */
34
+ log?: Pick<Logger, "logOnce">;
35
+ }
36
+
37
+ /** A scheme-bearing, protocol-relative or fragment href is not a local file (P transformer.ts:110-112). */
38
+ // shape: none — one classification, no branch on a discriminator.
39
+ function isLocalRef(href: string): boolean {
40
+ return !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith("//") && !href.startsWith("#");
41
+ }
42
+
43
+ /** The pane markdown for one readable JSON file, or undefined when the reader could not produce content. */
44
+ // shape: none — one guarded call plus a footer choice; no discriminator.
45
+ function jsonFilePane(
46
+ href: string,
47
+ width: number,
48
+ paint: ContentPaint,
49
+ codeTheme: CodeTheme,
50
+ deps: JsonPanelDeps,
51
+ ): string | undefined {
52
+ const content = deps.read(href);
53
+ if (content === undefined) {
54
+ deps.log?.logOnce(`read:${href}`, "json-panel", `could not read ${href} — leaving the link as-is`);
55
+ return undefined;
56
+ }
57
+ const all = content.replace(/\n+$/, "").split("\n");
58
+ const shown = all.slice(0, MAX_PANE_LINES);
59
+ const panel = toMarkdownRows(renderPanel(shown.join("\n"), "json", width, paint, codeTheme));
60
+ const hidden = all.length - shown.length;
61
+ // The link text is the href as given: this pane knows no artifact server (T-26 owns it), so the
62
+ // target stays a plain path the terminal can open.
63
+ const footer = hidden > 0 ? `${paint.quote(`… +${hidden} more lines`)} · [Open](${href})` : paint.quote(href);
64
+ return `${panel}\n\n${footer}`;
65
+ }
66
+
67
+ /**
68
+ * Rewrites standalone local-JSON links into panes; a link that is not exactly a standalone JSON
69
+ * reference keeps its raw line, and `changed` tracks whether anything matched.
70
+ *
71
+ * shape: none — one line walk with a single callback seam, no dispatch on a value.
72
+ */
73
+ function mapJsonFileLinks(
74
+ markdown: string,
75
+ width: number,
76
+ paint: ContentPaint,
77
+ codeTheme: CodeTheme,
78
+ deps: JsonPanelDeps,
79
+ ): { text: string; changed: boolean } {
80
+ const lines = markdown.split("\n");
81
+ const out: string[] = [];
82
+ let changed = false;
83
+ for (let i = 0; i < lines.length; i += 1) {
84
+ const line = lines[i] ?? "";
85
+ const href = LINK_ONLY.exec(line.trim())?.[1];
86
+ const alone =
87
+ line.trim() !== "" &&
88
+ (i === 0 || (lines[i - 1] ?? "").trim() === "") &&
89
+ (i === lines.length - 1 || (lines[i + 1] ?? "").trim() === "");
90
+ const pane =
91
+ href !== undefined && alone && isLocalRef(href) && JSON_EXTENSION.test(href.replace(/[?#].*$/, ""))
92
+ ? jsonFilePane(href, width, paint, codeTheme, deps)
93
+ : undefined;
94
+ if (pane === undefined) {
95
+ out.push(line);
96
+ continue;
97
+ }
98
+ out.push(pane);
99
+ changed = true;
100
+ }
101
+ return { text: out.join("\n"), changed };
102
+ }
103
+
104
+ // shape: closure returning an object literal — trigger #4, one stateless Surface over the captured theme and reader.
105
+ export function createJsonPanel(codeTheme: CodeTheme, deps: JsonPanelDeps): Surface {
106
+ return {
107
+ rewrite(markdown, ctx, paint) {
108
+ const width = panelWidth(ctx.availableWidth);
109
+ const fences = mapFencedBlocks(markdown, (lang, code) =>
110
+ JSON_LANGS.has(lang) ? toMarkdownRows(renderPanel(code, "json", width, paint, codeTheme)) : undefined,
111
+ );
112
+ const links = mapJsonFileLinks(fences.text, width, paint, codeTheme, deps);
113
+ return fences.changed || links.changed ? links.text : markdown;
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,174 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/table.ts — survives because: the banded layout,
2
+ // the column-width solver and the cell wrapping are the reading experience this requirement keeps.
3
+ // Only the escape source changed: frame and bars go through paint.rule, inline code through
4
+ // paint.code, and the predecessor's own bold/italic literals are dropped because no escape may
5
+ // originate outside core/paint.ts.
6
+
7
+ /**
8
+ * table.ts — the table surface: a markdown table becomes a banded box.
9
+ *
10
+ * Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
11
+ * out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
12
+ *
13
+ * shape: module-scope const object — trigger #3, one stateless Surface per process; the helpers
14
+ * stay module functions (no state, no discriminator to dispatch on).
15
+ */
16
+
17
+ import { Marked, type Token, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
18
+ import type { TransformContext } from "../../core/types/host.ts";
19
+ import type { ContentPaint } from "../../core/types/paint.ts";
20
+ import type { Surface } from "./types.ts";
21
+
22
+ type InlineToken = { type?: string; text?: string; raw?: string; tokens?: InlineToken[] };
23
+ interface CellLike {
24
+ text?: string;
25
+ tokens?: InlineToken[];
26
+ }
27
+ interface TableToken {
28
+ header: CellLike[];
29
+ rows: CellLike[][];
30
+ }
31
+
32
+ /** Per-cell padding plus the bars: below this a column cannot hold its own frame. */
33
+ const MIN_COLUMN = 6;
34
+ /** A column wider than this is a paragraph, not a table cell. */
35
+ const MAX_COLUMN = 60;
36
+
37
+ const markdownParser = new Marked();
38
+
39
+ /** Marked parses untrusted markdown, so the token shape is narrowed, never asserted (BND-1). */
40
+ function isInlineToken(value: unknown): value is InlineToken {
41
+ if (typeof value !== "object" || value === null) return false;
42
+ if (!("tokens" in value)) return true;
43
+ return Array.isArray(value.tokens) && value.tokens.every(isInlineToken);
44
+ }
45
+
46
+ function isCell(value: unknown): value is CellLike {
47
+ if (typeof value !== "object" || value === null) return false;
48
+ if ("text" in value && typeof value.text !== "string") return false;
49
+ if (!("tokens" in value)) return true;
50
+ return Array.isArray(value.tokens) && value.tokens.every(isInlineToken);
51
+ }
52
+
53
+ /** Marked's Token union carries a Generic member, so `type` alone cannot narrow — the shape does. */
54
+ function isTableToken(token: Token): token is Token & TableToken {
55
+ if (token.type !== "table") return false;
56
+ if (!("header" in token)) return false;
57
+ if (!(Array.isArray(token.header) && token.header.every(isCell))) return false;
58
+ if (!("rows" in token)) return false;
59
+ return Array.isArray(token.rows) && token.rows.every((row) => Array.isArray(row) && row.every(isCell));
60
+ }
61
+
62
+ /** Inline styling is a ContentPaint role or nothing — the host tokenizer's own `codespan` is code. */
63
+ function inlineText(token: InlineToken, paint: ContentPaint): string {
64
+ switch (token.type) {
65
+ case "codespan":
66
+ return paint.code(token.text ?? "");
67
+ case "strong":
68
+ case "em":
69
+ case "link":
70
+ return (token.tokens ?? []).map((child) => inlineText(child, paint)).join("") || (token.text ?? "");
71
+ default:
72
+ return token.text ?? token.raw ?? "";
73
+ }
74
+ }
75
+
76
+ function cellText(cell: CellLike | undefined, paint: ContentPaint): string {
77
+ const tokens = cell?.tokens;
78
+ const text =
79
+ Array.isArray(tokens) && tokens.length > 0
80
+ ? tokens.map((token) => inlineText(token, paint)).join("")
81
+ : (cell?.text ?? "");
82
+ // Soft wraps inside a cell are one logical line before the width solve.
83
+ return text.replace(/\s+/g, " ").trim();
84
+ }
85
+
86
+ function padCell(content: string, width: number): string {
87
+ const fit = visibleWidth(content) > width ? truncateToWidth(content, width) : content;
88
+ return fit + " ".repeat(Math.max(0, width - visibleWidth(fit)));
89
+ }
90
+
91
+ /** shape: none — one greedy width solve; a table is small and the rule is exact. */
92
+ function renderTable(token: TableToken, width: number, paint: ContentPaint): string[] {
93
+ const header = token.header.map((cell) => cellText(cell, paint));
94
+ const rows = token.rows.map((row) => row.map((cell) => cellText(cell, paint)));
95
+ const columns = Math.max(1, header.length);
96
+ const natural: number[] = [];
97
+ for (let index = 0; index < columns; index++) {
98
+ const cells = [header[index] ?? "", ...rows.map((row) => row[index] ?? "")];
99
+ natural.push(Math.min(MAX_COLUMN, Math.max(MIN_COLUMN, ...cells.map((cell) => visibleWidth(cell)))));
100
+ }
101
+ const widths = [...natural];
102
+ // Width budget: per-cell padding (2 each), the column bars, and the two outer bars.
103
+ const total = (): number => widths.reduce((sum, column) => sum + column + 2, 0) + (columns - 1) + 2;
104
+ while (total() > width) {
105
+ const widest = widths.indexOf(Math.max(...widths));
106
+ if ((widths[widest] ?? 0) <= MIN_COLUMN) break;
107
+ widths[widest] = (widths[widest] ?? 0) - 1;
108
+ }
109
+
110
+ // A light box around and between everything: the frame is what keeps a wrapped cell visually
111
+ // inside the table instead of trailing off its right edge.
112
+ const ruleLine = (left: string, middle: string, right: string): string =>
113
+ paint.rule(`${left}${widths.map((column) => "─".repeat(column + 2)).join(middle)}${right}`);
114
+ const bar = paint.rule("│");
115
+ const physical: string[] = [];
116
+
117
+ const renderBand = (cells: string[][]): void => {
118
+ const height = Math.max(1, ...cells.map((wrapped) => wrapped.length));
119
+ for (let line = 0; line < height; line++) {
120
+ const parts = cells.map((wrapped, index) => ` ${padCell(wrapped[line] ?? "", widths[index] ?? MIN_COLUMN)} `);
121
+ physical.push(`${bar}${parts.join(bar)}${bar}`);
122
+ }
123
+ };
124
+
125
+ const wrapBand = (cells: string[]): string[][] =>
126
+ cells.map((cell, index) => wrapTextWithAnsi(cell, widths[index] ?? MIN_COLUMN));
127
+
128
+ physical.push(ruleLine("┌", "┬", "┐"));
129
+ renderBand(wrapBand(header));
130
+ physical.push(ruleLine("├", "┼", "┤"));
131
+ rows.forEach((row, index) => {
132
+ if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
133
+ renderBand(wrapBand(row));
134
+ });
135
+ physical.push(ruleLine("└", "┴", "┘"));
136
+ return physical.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width) : line));
137
+ }
138
+
139
+ /** CommonMark code span, with a delimiter longer than any backtick run inside. */
140
+ function codeSpan(line: string): string {
141
+ const content = line || "\u00a0";
142
+ const longestBacktickRun = Math.max(0, ...Array.from(content.matchAll(/`+/g), (match) => (match[0] ?? "").length));
143
+ const fence = "`".repeat(longestBacktickRun + 1);
144
+ const padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
145
+ return `${fence}${padding}${content}${padding}${fence}`;
146
+ }
147
+
148
+ /** Hard breaks keep every emitted line on its own row, like the host's own diagram rows. */
149
+ function toMarkdownRows(lines: string[]): string {
150
+ return `${lines.map(codeSpan).join(" \n")}\n`;
151
+ }
152
+
153
+ /** shape: module-scope const object — trigger #3, one stateless table surface per process. */
154
+ export const table: Surface = { rewrite };
155
+
156
+ /** A table block is rewritten; every other block returns by the host's own text. */
157
+ function rewrite(markdown: string, ctx: TransformContext, paint: ContentPaint): string {
158
+ // Every table block contains a pipe, so a block without one never pays for a lex.
159
+ if (!markdown.includes("|")) return markdown;
160
+ const width = Math.max(24, Math.floor(ctx.availableWidth));
161
+ // Unchanged tokens are spliced back by their own raw text; only table blocks are rebuilt.
162
+ const parts: string[] = [];
163
+ let changed = false;
164
+ for (const token of markdownParser.lexer(markdown)) {
165
+ if (!isTableToken(token)) {
166
+ parts.push(token.raw);
167
+ continue;
168
+ }
169
+ changed = true;
170
+ parts.push(toMarkdownRows(renderTable(token, width, paint)));
171
+ }
172
+ // No table matched: return the input itself — joining raw text is not byte-exact (Marked eats CRLF).
173
+ return changed ? parts.join("") : markdown;
174
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * renderers/content/types.ts — the content-lane contracts (a markdown surface plus its registry keys).
3
+ *
4
+ * Boundary: lane-local. Surfaces and the content transformer import these; no other lane does. The
5
+ * host shapes they build on live in core (types/host.ts, types/paint.ts).
6
+ *
7
+ * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
8
+ */
9
+
10
+ import type { TransformContext } from "../../core/types/host.ts";
11
+ import type { ContentPaint } from "../../core/types/paint.ts";
12
+
13
+ /** Surface registry keys. The module key is "content." + the key, e.g. "content.table". */
14
+ export type SurfaceKey = "table" | "codePanel" | "jsonPanel" | "imageCard";
15
+
16
+ /** One markdown rewrite stage: the input string returns by reference whenever nothing matched. */
17
+ export interface Surface {
18
+ /** Rewrites (or passes through) one block of markdown. Required; never throws on unknown input. */
19
+ rewrite(markdown: string, ctx: TransformContext, paint: ContentPaint): string;
20
+ }