@zenodinh/pi-render 0.0.0-stage → 0.1.0

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/index.ts +279 -0
  4. package/package.json +66 -5
  5. package/src/commands/canvas.test.ts +150 -0
  6. package/src/commands/canvas.ts +57 -0
  7. package/src/core/code-theme.test.ts +266 -0
  8. package/src/core/code-theme.ts +275 -0
  9. package/src/core/log.test.ts +127 -0
  10. package/src/core/log.ts +57 -0
  11. package/src/core/paint.test.ts +322 -0
  12. package/src/core/paint.ts +64 -0
  13. package/src/core/registry.test.ts +307 -0
  14. package/src/core/registry.ts +135 -0
  15. package/src/core/settings.test.ts +183 -0
  16. package/src/core/settings.ts +119 -0
  17. package/src/core/types/code-theme.ts +24 -0
  18. package/src/core/types/host.ts +160 -0
  19. package/src/core/types/log.ts +28 -0
  20. package/src/core/types/paint.ts +54 -0
  21. package/src/core/types/registry.ts +44 -0
  22. package/src/core/types/settings.ts +31 -0
  23. package/src/core/types.ts +34 -0
  24. package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
  25. package/src/renderers/content/artifacts/cache.ts +234 -0
  26. package/src/renderers/content/artifacts/cards.test.ts +216 -0
  27. package/src/renderers/content/artifacts/cards.ts +136 -0
  28. package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
  29. package/src/renderers/content/artifacts/engines.ts +396 -0
  30. package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
  31. package/src/renderers/content/artifacts/local-binary.ts +80 -0
  32. package/src/renderers/content/artifacts/prereqs.ts +128 -0
  33. package/src/renderers/content/artifacts/server.ts +181 -0
  34. package/src/renderers/content/code-panel.ts +161 -0
  35. package/src/renderers/content/image-card.test.ts +170 -0
  36. package/src/renderers/content/image-card.ts +252 -0
  37. package/src/renderers/content/index.ts +79 -0
  38. package/src/renderers/content/json-panel.ts +116 -0
  39. package/src/renderers/content/panels.test.ts +188 -0
  40. package/src/renderers/content/table.test.ts +209 -0
  41. package/src/renderers/content/table.ts +174 -0
  42. package/src/renderers/content/transformer.test.ts +254 -0
  43. package/src/renderers/content/types.ts +20 -0
  44. package/src/renderers/tool/index.ts +113 -0
  45. package/src/renderers/tool/resolver.test.ts +257 -0
  46. package/src/renderers/tool/runtime.test.ts +313 -0
  47. package/src/renderers/tool/runtime.ts +267 -0
  48. package/src/renderers/tool/specs/bash.test.ts +110 -0
  49. package/src/renderers/tool/specs/bash.ts +168 -0
  50. package/src/renderers/tool/specs/codemode.test.ts +212 -0
  51. package/src/renderers/tool/specs/codemode.ts +248 -0
  52. package/src/renderers/tool/specs/edit.test.ts +260 -0
  53. package/src/renderers/tool/specs/edit.ts +213 -0
  54. package/src/renderers/tool/specs/ls.test.ts +173 -0
  55. package/src/renderers/tool/specs/ls.ts +136 -0
  56. package/src/renderers/tool/specs/read.test.ts +340 -0
  57. package/src/renderers/tool/specs/read.ts +296 -0
  58. package/src/renderers/tool/specs/search.test.ts +197 -0
  59. package/src/renderers/tool/specs/search.ts +325 -0
  60. package/src/renderers/tool/specs/write.test.ts +145 -0
  61. package/src/renderers/tool/specs/write.ts +142 -0
  62. package/src/renderers/tool/types.ts +45 -0
  63. package/themes/dracula-soft.json +81 -0
  64. package/themes/one-dark.json +80 -0
  65. package/themes/themes.test.ts +251 -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,188 @@
1
+ /**
2
+ * panels.test.ts — the spec of the two content Surfaces at their seam: fenced markdown in, panel
3
+ * markdown out. Every test drives `createCodePanel(...).rewrite` / `createJsonPanel(...).rewrite` with a
4
+ * plain transform context; no module internals are imported.
5
+ *
6
+ * Fixtures: the code theme is `createCodeTheme` over a deterministic engine double that stamps its bound
7
+ * theme into each line, so a panel names the theme it was colored for. Panels are driven with the marker
8
+ * paint double, so a frame escape that is not the codeBlockBorder role is visible in the golden.
9
+ */
10
+
11
+ import { visibleWidth } from "@earendil-works/pi-tui";
12
+ import { describe, expect, it } from "vitest";
13
+ import { markerPaint } from "../../../test/fakes/index.ts";
14
+ import { type BindHighlightEngine, type CodeThemeName, createCodeTheme } from "../../core/code-theme.ts";
15
+ import type { CodeTheme, TransformContext } from "../../core/types.ts";
16
+ import { createCodePanel } from "./code-panel.ts";
17
+ import { createJsonPanel, type JsonPanelDeps } from "./json-panel.ts";
18
+
19
+ const ESC = String.fromCharCode(27);
20
+ /** The codeBlockBorder role's marker pair (test/fakes/paint.ts), the only frame escape a panel may emit. */
21
+ const border = (text: string): string => `${ESC}[38;5;211m${text}${ESC}[38;5;231m`;
22
+ /** The quote role's marker pair, used for the JSON pane's footer. */
23
+ const quote = (text: string): string => `${ESC}[38;5;209m${text}${ESC}[38;5;229m`;
24
+ /** One highlighted body line as the engine double emits it. */
25
+ const ink = (rgb: string, text: string): string => `${ESC}[38;2;${rgb}m${text}${ESC}[0m`;
26
+ /** Panel rows embedded as markdown: one code span per row, joined by the hard break the host honors. */
27
+ const rows = (lines: string[]): string => lines.map((line) => `\`${line}\``).join(" \n");
28
+
29
+ // invented: the engine is faked, so the golden pins OUR frame and highlight wiring, not shiki's palette.
30
+ // Colours sit above the low-contrast floor (72) so code-theme's normalization leaves them alone.
31
+ const INK: Record<CodeThemeName, string> = { "dracula-soft": "200;100;50", "one-dark-pro": "50;200;100" };
32
+ const THEMES = Object.entries(INK) as Array<[CodeThemeName, string]>;
33
+
34
+ /** Theme double: an engine that stamps the bound theme's ink into each line. */
35
+ function themeNamed(name: CodeThemeName): CodeTheme {
36
+ const engine: BindHighlightEngine = (theme) => (code) => code.split("\n").map((line) => ink(INK[theme], line));
37
+ return createCodeTheme(engine, { registry: { getSettings: () => ({ codeTheme: name }) } });
38
+ }
39
+
40
+ /** Markdown transform context; only availableWidth is read (core/types/host.ts). */
41
+ // invented: the two gate fields are the completed-assistant case, which is the only case a surface sees.
42
+ const ctx = (availableWidth: number): TransformContext => ({
43
+ messageType: "assistant",
44
+ isStreaming: false,
45
+ availableWidth,
46
+ });
47
+
48
+ /** Reader double over a fixed file map (the injected seam T-28 wires to the filesystem). */
49
+ function reader(files: Record<string, string>): JsonPanelDeps {
50
+ return { read: (href) => files[href] };
51
+ }
52
+
53
+ describe("code panel", () => {
54
+ it.each(THEMES)("AC-1: a fence renders as a bordered panel in %s's ink", (name, inkRgb) => {
55
+ const out = createCodePanel(themeNamed(name)).rewrite("```python\nx = 1\n```\n", ctx(80), markerPaint());
56
+
57
+ const expected = `${rows([
58
+ border("╭─ python ─────────────╮"),
59
+ `${border("│")} ${ink(inkRgb, "x = 1")}${" ".repeat(15)} ${border("│")}`,
60
+ border("╰──────────────────────╯"),
61
+ ])}\n`;
62
+ // fails_when: the frame geometry, the language header, or the theme's ink drifts.
63
+ expect(out).toBe(expected);
64
+ });
65
+
66
+ it("AC-1: a bare fence and the mermaid/artifact fences keep their raw text", () => {
67
+ const panel = createCodePanel(themeNamed("dracula-soft"));
68
+ for (const raw of ["```\nx\n```\n", "```mermaid\ngraph TD;\n```\n", "```plantuml\nA -> B\n```\n"]) {
69
+ // fails_when: a host-owned or artifact-bound fence is consumed into a panel.
70
+ expect(panel.rewrite(raw, ctx(80), markerPaint())).toBe(raw);
71
+ }
72
+ });
73
+
74
+ it("AC-1: a fence hugging its content stays under the offered width", () => {
75
+ const out = createCodePanel(themeNamed("dracula-soft")).rewrite("```py\nx = 1\n```\n", ctx(80), markerPaint());
76
+ // fails_when: the panel spans the terminal instead of hugging its content.
77
+ expect(visibleWidth(out.split("\n")[0] ?? "")).toBeLessThan(30);
78
+ });
79
+
80
+ it("AC-2: a throwing highlightSync degrades to a plain panel, no throw", () => {
81
+ const throwing: CodeTheme = {
82
+ highlight: () => Promise.reject(new Error("boom")),
83
+ highlightSync: () => {
84
+ throw new Error("boom");
85
+ },
86
+ };
87
+ const panel = createCodePanel(throwing);
88
+ let out = "";
89
+ // fails_when: a highlighter failure kills the answer render.
90
+ expect(() => {
91
+ out = panel.rewrite("```ts\nlet x = 1;\n```\n", ctx(40), markerPaint());
92
+ }).not.toThrow();
93
+ expect(out).toContain("let x = 1;");
94
+ expect(out).not.toContain(`${ESC}[38;2;`); // no engine ink survived
95
+ });
96
+
97
+ it("AC-2: a throwing engine behind the real CodeTheme degrades the same way", () => {
98
+ const engine: BindHighlightEngine = () => () => {
99
+ throw new Error("boom");
100
+ };
101
+ const theme = createCodeTheme(engine, { registry: { getSettings: () => ({ codeTheme: "dracula-soft" }) } });
102
+ const out = createCodePanel(theme).rewrite("```ts\nlet x = 1;\n```\n", ctx(40), markerPaint());
103
+ // fails_when: code-theme's own degrade does not reach the panel.
104
+ expect(out).toContain("let x = 1;");
105
+ expect(out).not.toContain(`${ESC}[38;2;`);
106
+ });
107
+
108
+ it("AC-3: box columns align when truecolor escapes sit inside CJK content", () => {
109
+ const cjk = "// 撤销转退款场景,根据是否超过保价时间限制判断是否进行汇率决策";
110
+ const out = createCodePanel(themeNamed("dracula-soft")).rewrite(
111
+ `\`\`\`java\n${cjk}\npublic class X {}\n\`\`\`\n`,
112
+ ctx(110),
113
+ markerPaint(),
114
+ );
115
+ const widths = new Set(
116
+ out
117
+ .split("\n")
118
+ .filter((line) => line !== "")
119
+ .map((line) => visibleWidth(line.trimEnd())),
120
+ );
121
+ // fails_when: a wide character is measured as one column, so the closing │ no longer lines up.
122
+ expect(widths.size).toBe(1);
123
+ });
124
+ });
125
+
126
+ describe("json panel", () => {
127
+ // recorded-from: pi-pretty-tui/src/features/canvas/json-panel.test.ts (2026-10-06) — the same two-key body.
128
+ const DATA = '{\n "name": "pi-pretty-tui",\n "version": "0.1.0"\n}\n';
129
+
130
+ it.each(THEMES)("AC-1: a local .json link renders as a json pane in %s's ink", (name, inkRgb) => {
131
+ const panel = createJsonPanel(themeNamed(name), reader({ "/repo/data.json": DATA }));
132
+ const out = panel.rewrite("[data.json](/repo/data.json)\n", ctx(80), markerPaint());
133
+
134
+ const body = ["{", ' "name": "pi-pretty-tui",', ' "version": "0.1.0"', "}"];
135
+ const expected = `${rows([
136
+ border("╭─ json ─────────────────────╮"),
137
+ ...body.map((line) => `${border("│")} ${ink(inkRgb, line)}${" ".repeat(26 - line.length)} ${border("│")}`),
138
+ border("╰────────────────────────────╯"),
139
+ ])}\n\n${quote("/repo/data.json")}\n`;
140
+ // fails_when: the pane frame, the header, the footer, or the theme's ink drifts.
141
+ expect(out).toBe(expected);
142
+ });
143
+
144
+ it("AC-1: a ```json fence renders through the same panel", () => {
145
+ const panel = createJsonPanel(themeNamed("dracula-soft"), reader({}));
146
+ const out = panel.rewrite("```json\n{}\n```\n", ctx(40), markerPaint());
147
+ // fails_when: the JSON fence is left raw or rendered without the json header.
148
+ expect(out).toContain("╭─ json ");
149
+ expect(out).toContain(ink("200;100;50", "{}"));
150
+ });
151
+
152
+ it("caps a long file at 40 lines and leaves one way out", () => {
153
+ // invented: 60 numbered lines is one past the 40-line cap plus a countable remainder.
154
+ const sixty = Array.from({ length: 60 }, (_, index) => `line ${index}`).join("\n");
155
+ const panel = createJsonPanel(themeNamed("dracula-soft"), reader({ "/repo/big.json": sixty }));
156
+ const out = panel.rewrite("[big.json](/repo/big.json)\n", ctx(80), markerPaint());
157
+
158
+ // fails_when: the transcript swallows the whole file, or loses the way to the rest of it.
159
+ expect(out).toContain("╭─ json ");
160
+ expect(out).toContain("… +20 more lines");
161
+ expect(out).toContain("[Open](/repo/big.json)");
162
+ expect(out).not.toContain("line 40");
163
+ });
164
+
165
+ it("leaves a non-JSON or unreadable link as-is", () => {
166
+ const panel = createJsonPanel(themeNamed("dracula-soft"), reader({}));
167
+ const notes = "[notes.md](/repo/notes.md)\n";
168
+ const missing = "[gone.json](/repo/gone.json)\n";
169
+ // fails_when: a markdown link is pane-ized, or a missing file breaks the answer render.
170
+ expect(panel.rewrite(notes, ctx(80), markerPaint())).toBe(notes);
171
+ expect(panel.rewrite(missing, ctx(80), markerPaint())).toBe(missing);
172
+ });
173
+
174
+ it("logs once when the linked file cannot be read", () => {
175
+ const seen: string[] = [];
176
+ const panel = createJsonPanel(themeNamed("dracula-soft"), {
177
+ read: () => undefined,
178
+ log: {
179
+ logOnce: (key) => {
180
+ seen.push(key);
181
+ },
182
+ },
183
+ });
184
+ panel.rewrite("[gone.json](/repo/gone.json)\n", ctx(80), markerPaint());
185
+ // fails_when: an unreadable link is silent.
186
+ expect(seen).toEqual(["read:/repo/gone.json"]);
187
+ });
188
+ });