@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.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.test.ts +170 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -0
- package/themes/themes.test.ts +251 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/canvas/prereqs.ts — survives because: the spawn boundary may
|
|
2
|
+
// resolve absolute paths only, so the candidate lists that feed it are the security surface itself.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Artifacts prerequisites: what the two binary-backed forms need, and where this machine would find it.
|
|
6
|
+
*
|
|
7
|
+
* Why a candidate list instead of PATH: the spawn boundary resolves absolute paths only (no PATH
|
|
8
|
+
* lookup, no environment override), so the list is deliberately short and per-platform. Dedicated
|
|
9
|
+
* headless shells are preferred over the user's app binaries: nothing in their profile is read, and the
|
|
10
|
+
* browser starts faster.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here installs anything, and nothing here spawns: a missing tool degrades to the raw fence,
|
|
13
|
+
* and the named error carries what to run — the human decides.
|
|
14
|
+
*
|
|
15
|
+
* shape: none — ordered candidate scans; each resolver is one first-hit lookup over a static list.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
export interface PrereqProbe {
|
|
23
|
+
exists?: (path: string) => boolean;
|
|
24
|
+
platform?: NodeJS.Platform;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type PrereqKind = "plantuml" | "browser";
|
|
28
|
+
|
|
29
|
+
function shellDirectory(platform: NodeJS.Platform): string {
|
|
30
|
+
return platform === "darwin" ? "chrome-headless-shell-mac-arm64" : "chrome-headless-shell-linux64";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dedicated headless shells, newest first, from the two caches that ship them
|
|
35
|
+
* (Playwright and Puppeteer — each has its own directory layout). They come
|
|
36
|
+
* before the app bundles: a stripped shell touches no user profile and starts
|
|
37
|
+
* in a fraction of the time.
|
|
38
|
+
*/
|
|
39
|
+
export function headlessShellCandidates(
|
|
40
|
+
platform: NodeJS.Platform,
|
|
41
|
+
roots?: { playwright?: string; puppeteer?: string },
|
|
42
|
+
): string[] {
|
|
43
|
+
const playwrightRoot =
|
|
44
|
+
roots?.playwright ??
|
|
45
|
+
(platform === "darwin" ? join(homedir(), "Library/Caches/ms-playwright") : join(homedir(), ".cache/ms-playwright"));
|
|
46
|
+
const puppeteerBase = join(roots?.puppeteer ?? join(homedir(), ".cache/puppeteer"), "chrome-headless-shell");
|
|
47
|
+
const list = (root: string, matches: (name: string) => boolean, toPath: (name: string) => string): string[] => {
|
|
48
|
+
try {
|
|
49
|
+
return readdirSync(root).filter(matches).sort().reverse().map(toPath);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const shellDir = shellDirectory(platform);
|
|
55
|
+
return [
|
|
56
|
+
...list(
|
|
57
|
+
playwrightRoot,
|
|
58
|
+
(name) => name.startsWith("chromium_headless_shell-"),
|
|
59
|
+
(name) => join(playwrightRoot, name, shellDir, "chrome-headless-shell"),
|
|
60
|
+
),
|
|
61
|
+
...list(
|
|
62
|
+
puppeteerBase,
|
|
63
|
+
() => true,
|
|
64
|
+
(name) => join(puppeteerBase, name, shellDir, "chrome-headless-shell"),
|
|
65
|
+
),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The ordered absolute candidates per kind and platform; first existing wins. */
|
|
70
|
+
export function prereqCandidates(
|
|
71
|
+
kind: PrereqKind,
|
|
72
|
+
platform: NodeJS.Platform,
|
|
73
|
+
shells: string[] = headlessShellCandidates(platform),
|
|
74
|
+
): string[] {
|
|
75
|
+
if (platform === "linux") {
|
|
76
|
+
return kind === "plantuml"
|
|
77
|
+
? ["/usr/bin/plantuml", "/usr/local/bin/plantuml", "/snap/bin/plantuml"]
|
|
78
|
+
: [...shells, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium"];
|
|
79
|
+
}
|
|
80
|
+
return kind === "plantuml"
|
|
81
|
+
? ["/opt/homebrew/bin/plantuml", "/usr/local/bin/plantuml"]
|
|
82
|
+
: [
|
|
83
|
+
...shells,
|
|
84
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
85
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const INSTALL_HINTS: Record<PrereqKind, Record<"darwin" | "linux", string>> = {
|
|
90
|
+
plantuml: {
|
|
91
|
+
darwin: "brew install plantuml",
|
|
92
|
+
linux: "apt install plantuml (or snap install plantuml)",
|
|
93
|
+
},
|
|
94
|
+
browser: {
|
|
95
|
+
darwin: "install Brave or Chrome, or: npx playwright install chromium-headless-shell",
|
|
96
|
+
linux: "apt install chromium, or: npx playwright install chromium-headless-shell",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function hintFor(kind: PrereqKind, platform: NodeJS.Platform): string {
|
|
101
|
+
return INSTALL_HINTS[kind][platform === "linux" ? "linux" : "darwin"];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** shape: none — one ordered scan, first hit wins; misses leave the path empty. */
|
|
105
|
+
export function resolvePrereq(
|
|
106
|
+
kind: PrereqKind,
|
|
107
|
+
probe: PrereqProbe = {},
|
|
108
|
+
): { path: string; hint: string; candidates: string[] } {
|
|
109
|
+
const platform = probe.platform ?? process.platform;
|
|
110
|
+
const exists = probe.exists ?? existsSync;
|
|
111
|
+
const candidates = prereqCandidates(kind, platform);
|
|
112
|
+
return { path: candidates.find((candidate) => exists(candidate)) ?? "", hint: hintFor(kind, platform), candidates };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** shape: none — the resolved absolute path, or a named error carrying the install hint. */
|
|
116
|
+
export function resolvePlantuml(probe: PrereqProbe = {}): string {
|
|
117
|
+
const { path, hint, candidates } = resolvePrereq("plantuml", probe);
|
|
118
|
+
if (!path) throw new Error(`plantuml: not found — install with "${hint}" (looked in: ${candidates.join(", ")})`);
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** shape: none — an explicit test seam wins, then the same candidates as the diagnostics. */
|
|
123
|
+
export function resolveBrowserPinned(explicit?: string, probe: PrereqProbe = {}): string {
|
|
124
|
+
if (explicit) return explicit;
|
|
125
|
+
const { path, hint, candidates } = resolvePrereq("browser", probe);
|
|
126
|
+
if (!path) throw new Error(`html: no pinned browser found — ${hint} (looked in: ${candidates.join(", ")})`);
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/canvas/artifact-server.ts — survives because: the loopback
|
|
2
|
+
// bind plus the opaque-id registry is what lets a card's Open link reach the embedded browser without
|
|
3
|
+
// ever serving a request-named path, and its degrade-to-file-link fallback keeps a failed bind off the
|
|
4
|
+
// session. The predecessor's module-level singleton becomes a factory so the composition root owns one
|
|
5
|
+
// instance and a test can start a real server and stop it.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* server.ts — the loopback artifact server: an ephemeral 127.0.0.1 port serving exactly the carded
|
|
9
|
+
* paths, one opaque id each, no directory listing, and a degrade path that never touches the session.
|
|
10
|
+
*
|
|
11
|
+
* Boundary: the served path comes from the carded registry, never from the request URL — the request
|
|
12
|
+
* supplies an opaque id only. A bind failure or a socket idle past the request budget is logged and
|
|
13
|
+
* dropped, never thrown.
|
|
14
|
+
*
|
|
15
|
+
* shape: closure returning an object literal — trigger #4, one bound http server plus the carded-path
|
|
16
|
+
* registry it serves from; no subclassing and no instanceof.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
21
|
+
import type { Logger } from "../../../core/types/log.ts";
|
|
22
|
+
|
|
23
|
+
const HOST = "127.0.0.1";
|
|
24
|
+
const SCOPE = "content.artifacts";
|
|
25
|
+
/** SA §6: a request that takes longer than this is dropped. */
|
|
26
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
27
|
+
|
|
28
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
29
|
+
".svg": "image/svg+xml",
|
|
30
|
+
".png": "image/png",
|
|
31
|
+
".html": "text/html; charset=utf-8",
|
|
32
|
+
".md": "text/markdown; charset=utf-8",
|
|
33
|
+
".json": "application/json; charset=utf-8",
|
|
34
|
+
".txt": "text/plain; charset=utf-8",
|
|
35
|
+
".log": "text/plain; charset=utf-8",
|
|
36
|
+
".puml": "text/plain; charset=utf-8",
|
|
37
|
+
".dot": "text/plain; charset=utf-8",
|
|
38
|
+
".utxt": "text/plain; charset=utf-8",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The bind outcome start() reports; a degraded server leaves every card on its file:// link. */
|
|
42
|
+
export type ArtifactServerState =
|
|
43
|
+
| { readonly ok: true; readonly url: string }
|
|
44
|
+
| { readonly ok: false; readonly reason: string };
|
|
45
|
+
|
|
46
|
+
export interface ArtifactServer {
|
|
47
|
+
/** Binds once on the loopback interface; a bind failure is reported, never thrown. */
|
|
48
|
+
start(): Promise<ArtifactServerState>;
|
|
49
|
+
/** Releases the port and resets the state; idempotent. */
|
|
50
|
+
stop(): Promise<void>;
|
|
51
|
+
/** Registers `path` as carded and returns its loopback URL, or undefined while degraded. */
|
|
52
|
+
urlFor(path: string): string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The listen step — injectable so a test can drive the degraded path without an occupied port. */
|
|
56
|
+
export type ArtifactListen = (server: Server) => Promise<void>;
|
|
57
|
+
|
|
58
|
+
export interface ArtifactServerOptions {
|
|
59
|
+
/** Diagnostics; the degraded path names its cause exactly once through logOnce. */
|
|
60
|
+
log?: Logger;
|
|
61
|
+
/** Socket-idle budget; a request past it is dropped with one logged line. */
|
|
62
|
+
requestTimeoutMs?: number;
|
|
63
|
+
/** Test seam; production binds 127.0.0.1:0. */
|
|
64
|
+
listen?: ArtifactListen;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** shape: none — one promise over the one fallible bind; the error listener keeps it from being unhandled. */
|
|
68
|
+
function bindLoopback(server: Server): Promise<void> {
|
|
69
|
+
return new Promise<void>((resolve, reject) => {
|
|
70
|
+
server.once("error", reject);
|
|
71
|
+
server.listen(0, HOST, () => resolve());
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function message(error: unknown): string {
|
|
76
|
+
return error instanceof Error ? error.message : String(error);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createArtifactServer(options: ArtifactServerOptions = {}): ArtifactServer {
|
|
80
|
+
const log = options.log;
|
|
81
|
+
const requestTimeoutMs = Math.max(1, Math.floor(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS));
|
|
82
|
+
const listen = options.listen ?? bindLoopback;
|
|
83
|
+
// path -> opaque id (dedup) and id -> path (serving). The URL carries the id, so no request can name a path.
|
|
84
|
+
const idsByPath = new Map<string, string>();
|
|
85
|
+
const pathsById = new Map<string, string>();
|
|
86
|
+
let counter = 0;
|
|
87
|
+
let server: Server | undefined;
|
|
88
|
+
let baseUrl: string | undefined;
|
|
89
|
+
let failure: string | undefined;
|
|
90
|
+
|
|
91
|
+
const register = (path: string): string => {
|
|
92
|
+
const existing = idsByPath.get(path);
|
|
93
|
+
if (existing !== undefined) return existing;
|
|
94
|
+
counter += 1;
|
|
95
|
+
const id = counter.toString(36);
|
|
96
|
+
idsByPath.set(path, id);
|
|
97
|
+
pathsById.set(id, path);
|
|
98
|
+
return id;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const contentTypeFor = (file: string): string => {
|
|
102
|
+
const extension = file.slice(file.lastIndexOf(".")).toLowerCase();
|
|
103
|
+
return CONTENT_TYPES[extension] ?? "text/plain; charset=utf-8";
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** shape: none — one guarded lookup then one guarded read; every miss is a 404, never a listing. */
|
|
107
|
+
const respond = (request: IncomingMessage, response: ServerResponse): void => {
|
|
108
|
+
let id: string | undefined;
|
|
109
|
+
try {
|
|
110
|
+
const pathname = new URL(request.url ?? "/", `http://${HOST}`).pathname;
|
|
111
|
+
if (pathname.startsWith("/a/")) id = decodeURIComponent(pathname.slice(3));
|
|
112
|
+
} catch {
|
|
113
|
+
id = undefined;
|
|
114
|
+
}
|
|
115
|
+
const file = id === undefined ? undefined : pathsById.get(id);
|
|
116
|
+
if (file === undefined) {
|
|
117
|
+
response.writeHead(404).end();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const body = readFileSync(file);
|
|
122
|
+
response.writeHead(200, {
|
|
123
|
+
"content-type": contentTypeFor(file),
|
|
124
|
+
"content-length": String(body.length),
|
|
125
|
+
"cache-control": "no-store",
|
|
126
|
+
});
|
|
127
|
+
response.end(body);
|
|
128
|
+
} catch {
|
|
129
|
+
// A carded path whose artifact is gone is a 404, not a broken response.
|
|
130
|
+
response.writeHead(404).end();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
async start(): Promise<ArtifactServerState> {
|
|
136
|
+
if (baseUrl !== undefined) return { ok: true, url: baseUrl };
|
|
137
|
+
if (failure !== undefined) return { ok: false, reason: failure };
|
|
138
|
+
try {
|
|
139
|
+
const bound = createServer(respond);
|
|
140
|
+
// An abandoned or stalled request is dropped instead of holding the socket (SA §6).
|
|
141
|
+
bound.setTimeout(requestTimeoutMs, (socket) => {
|
|
142
|
+
log?.logOnce("artifact-server:timeout", SCOPE, `request dropped after ${requestTimeoutMs}ms`);
|
|
143
|
+
socket.destroy();
|
|
144
|
+
});
|
|
145
|
+
await listen(bound);
|
|
146
|
+
const address = bound.address();
|
|
147
|
+
if (address === null || typeof address === "string") throw new Error("no port was assigned");
|
|
148
|
+
server = bound;
|
|
149
|
+
baseUrl = `http://${HOST}:${address.port}`;
|
|
150
|
+
return { ok: true, url: baseUrl };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
failure = message(error);
|
|
153
|
+
log?.logOnce("artifact-server:start", SCOPE, `artifact server unavailable: ${failure}`);
|
|
154
|
+
return { ok: false, reason: failure };
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async stop(): Promise<void> {
|
|
159
|
+
const bound = server;
|
|
160
|
+
server = undefined;
|
|
161
|
+
baseUrl = undefined;
|
|
162
|
+
failure = undefined;
|
|
163
|
+
if (bound === undefined) return;
|
|
164
|
+
await new Promise<void>((resolve) => {
|
|
165
|
+
bound.close(() => resolve());
|
|
166
|
+
// Keep-alive sockets the client is done with must not hold close() open.
|
|
167
|
+
bound.closeIdleConnections();
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
urlFor(path: string): string | undefined {
|
|
172
|
+
const id = register(path);
|
|
173
|
+
if (baseUrl === undefined) {
|
|
174
|
+
// First click on a degraded server names the cause once; the card itself already rendered.
|
|
175
|
+
log?.logOnce("artifact-server:url", SCOPE, `Open link degraded: ${failure ?? "artifact server not started"}`);
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
return `${baseUrl}/a/${encodeURIComponent(id)}`;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/canvas/code-panel.ts — survives because: the bordered panel
|
|
2
|
+
// with a language header, the content-hugging width, the ANSI-aware truncation and the sync highlight
|
|
3
|
+
// path are the owner's reading surface for a fence; only the color source changed (paint roles plus the
|
|
4
|
+
// injected CodeTheme instead of module-level globals and hardcoded escapes).
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* code-panel.ts — a fenced block becomes a bordered, themed panel; the JSON pane reuses the same frame,
|
|
8
|
+
* the same fence scan and the same markdown embedding.
|
|
9
|
+
*
|
|
10
|
+
* Boundary: markdown is untrusted and is scanned line-by-line, never handed to a parser; the CodeTheme
|
|
11
|
+
* is injected, so no renderer imports shiki. Width math is pi-tui's own ANSI-aware visibleWidth /
|
|
12
|
+
* truncateToWidth — the one host utility the panel needs, and what keeps columns aligned when truecolor
|
|
13
|
+
* escapes sit inside the content (CJK included). A failing highlighter degrades to plain source; the
|
|
14
|
+
* panel never throws.
|
|
15
|
+
*
|
|
16
|
+
* shape: none — a factory plus shared pure helpers; no discriminator to dispatch on. The factory
|
|
17
|
+
* declares its own shape below.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
21
|
+
import type { CodeTheme, ContentPaint } from "../../core/types.ts";
|
|
22
|
+
import type { Surface } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
/** The panel is never narrower than this, so a one-word snippet still reads as a block (P code-panel.ts:20). */
|
|
25
|
+
const MIN_PANEL_WIDTH = 24;
|
|
26
|
+
/** A body row is `│ text │`, so the text budget is width-4 (P code-panel.ts:75). */
|
|
27
|
+
const ROW_CHROME = 4;
|
|
28
|
+
/** Languages left as raw fences: mermaid is the host's own renderer, and the four diagram forms
|
|
29
|
+
* belong to the artifact pass that runs AFTER surfaces (SA §6) — consuming them here would
|
|
30
|
+
* regress every diagram to a plain panel. */
|
|
31
|
+
const RAW_FENCES = new Set(["mermaid", "plantuml", "svg", "dot", "html"]);
|
|
32
|
+
|
|
33
|
+
/** The offered transcript width, clamped to the panel minimum (P transformer.ts:319-320). */
|
|
34
|
+
// shape: none — one clamp, no branch on a discriminator.
|
|
35
|
+
export function panelWidth(availableWidth: number): number {
|
|
36
|
+
return Math.max(MIN_PANEL_WIDTH, Math.floor(availableWidth) || 80);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Highlighted body lines, truncated by visible width; a throwing theme degrades to the plain source. */
|
|
40
|
+
// shape: none — one guarded call plus a one-line fallback; no discriminator.
|
|
41
|
+
function bodyLines(code: string, lang: string, width: number, codeTheme: CodeTheme): string[] {
|
|
42
|
+
const source = code.replace(/\n+$/, "");
|
|
43
|
+
const budget = Math.max(8, width - ROW_CHROME);
|
|
44
|
+
let lines: string[];
|
|
45
|
+
try {
|
|
46
|
+
lines = codeTheme.highlightSync(source, lang).split("\n");
|
|
47
|
+
} catch {
|
|
48
|
+
// A cold core or an unloadable grammar: the panel degrades, the answer render never dies.
|
|
49
|
+
lines = source.split("\n");
|
|
50
|
+
}
|
|
51
|
+
return lines.map((line) => truncateToWidth(line, budget));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One framed block: header label, hugged width, padded rows. Shared by the code and JSON panels.
|
|
56
|
+
*
|
|
57
|
+
* shape: none — one box; every row's visible width is arithmetically bounded to the panel width.
|
|
58
|
+
*/
|
|
59
|
+
export function renderPanel(
|
|
60
|
+
code: string,
|
|
61
|
+
lang: string,
|
|
62
|
+
width: number,
|
|
63
|
+
paint: ContentPaint,
|
|
64
|
+
codeTheme: CodeTheme,
|
|
65
|
+
): string[] {
|
|
66
|
+
const maxWidth = Math.max(MIN_PANEL_WIDTH, Math.floor(width));
|
|
67
|
+
const label = lang.toLowerCase() || "code";
|
|
68
|
+
const body = bodyLines(code, lang, maxWidth, codeTheme);
|
|
69
|
+
const contentWidth = Math.max(1, ...body.map((line) => visibleWidth(line)));
|
|
70
|
+
const prefix = `─ ${label} `;
|
|
71
|
+
const panelSize = Math.min(
|
|
72
|
+
maxWidth,
|
|
73
|
+
Math.max(MIN_PANEL_WIDTH, Math.max(contentWidth + ROW_CHROME, visibleWidth(prefix) + ROW_CHROME)),
|
|
74
|
+
);
|
|
75
|
+
const inner = panelSize - 2;
|
|
76
|
+
const textWidth = Math.max(8, panelSize - ROW_CHROME);
|
|
77
|
+
const dashes = Math.max(0, inner - visibleWidth(prefix));
|
|
78
|
+
const rows: string[] = [paint.codeBlockBorder(`╭${prefix}${"─".repeat(dashes)}╮`)];
|
|
79
|
+
for (const line of body) {
|
|
80
|
+
const pad = " ".repeat(Math.max(0, textWidth - visibleWidth(line)));
|
|
81
|
+
rows.push(`${paint.codeBlockBorder("│")} ${line}${pad} ${paint.codeBlockBorder("│")}`);
|
|
82
|
+
}
|
|
83
|
+
rows.push(paint.codeBlockBorder(`╰${"─".repeat(inner)}╯`));
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** CommonMark code span, delimiting with one backtick more than any run inside (P transformer.ts:67-74). */
|
|
88
|
+
// shape: none — one delimiter choice, no state.
|
|
89
|
+
function codeSpan(line: string): string {
|
|
90
|
+
const content = line || "\u00a0";
|
|
91
|
+
const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), (match) => match[0].length));
|
|
92
|
+
const fence = "`".repeat(longest + 1);
|
|
93
|
+
const pad = content.startsWith("`") || content.endsWith("`") ? " " : "";
|
|
94
|
+
return `${fence}${pad}${content}${pad}${fence}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Panel rows as markdown: one code span per row joined by hard breaks, so the host keeps the frame (P transformer.ts:82-85). */
|
|
98
|
+
// shape: none — one map over lines.
|
|
99
|
+
export function toMarkdownRows(lines: string[]): string {
|
|
100
|
+
return lines.map(codeSpan).join(" \n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Opens a CommonMark fenced block: up to 3 spaces, ``` or ~~~, then the language token. */
|
|
104
|
+
const FENCE_OPEN = /^( {0,3})(`{3,}|~{3,})[ \t]*(\S*)/;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rewrites every fenced block the transform accepts; an untouched block keeps its raw lines, and
|
|
108
|
+
* `changed` is false when nothing matched so the caller can return the same string reference.
|
|
109
|
+
*
|
|
110
|
+
* shape: none — one line walk with a single callback seam, no dispatch on a value.
|
|
111
|
+
*/
|
|
112
|
+
export function mapFencedBlocks(
|
|
113
|
+
markdown: string,
|
|
114
|
+
transform: (lang: string, code: string) => string | undefined,
|
|
115
|
+
): { text: string; changed: boolean } {
|
|
116
|
+
const lines = markdown.split("\n");
|
|
117
|
+
const out: string[] = [];
|
|
118
|
+
let changed = false;
|
|
119
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
120
|
+
const open = FENCE_OPEN.exec(lines[i] ?? "");
|
|
121
|
+
if (open === null) {
|
|
122
|
+
out.push(lines[i] ?? "");
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const marker = open[2] ?? "```";
|
|
126
|
+
const lang = (open[3] ?? "").toLowerCase();
|
|
127
|
+
const close = new RegExp(`^ {0,3}${marker.charAt(0)}{${marker.length},}[ \\t]*$`);
|
|
128
|
+
let end = i + 1;
|
|
129
|
+
const body: string[] = [];
|
|
130
|
+
while (end < lines.length && !close.test(lines[end] ?? "")) {
|
|
131
|
+
body.push(lines[end] ?? "");
|
|
132
|
+
end += 1;
|
|
133
|
+
}
|
|
134
|
+
if (end >= lines.length) {
|
|
135
|
+
out.push(lines[i] ?? ""); // unterminated fence: leave it raw rather than swallow the tail
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const replacement = transform(lang, body.join("\n"));
|
|
139
|
+
if (replacement === undefined) {
|
|
140
|
+
out.push(...lines.slice(i, end + 1));
|
|
141
|
+
} else {
|
|
142
|
+
out.push(replacement);
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
i = end;
|
|
146
|
+
}
|
|
147
|
+
return { text: out.join("\n"), changed };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// shape: closure returning an object literal — trigger #4, one stateless Surface over the captured theme.
|
|
151
|
+
export function createCodePanel(codeTheme: CodeTheme): Surface {
|
|
152
|
+
return {
|
|
153
|
+
rewrite(markdown, ctx, paint) {
|
|
154
|
+
const width = panelWidth(ctx.availableWidth);
|
|
155
|
+
const { text, changed } = mapFencedBlocks(markdown, (lang, code) =>
|
|
156
|
+
lang && !RAW_FENCES.has(lang) ? toMarkdownRows(renderPanel(code, lang, width, paint, codeTheme)) : undefined,
|
|
157
|
+
);
|
|
158
|
+
return changed ? text : markdown;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Fixture provenance — every image fixture is a real file written into a temp dir, so the assertions
|
|
2
|
+
// drive the surface's own default probe (the bounded header read) instead of an injected stub.
|
|
3
|
+
// pic.png — invented: a real 3×2 PNG (73 bytes) built with zlib + CRC32 on 2026-10-06; non-square,
|
|
4
|
+
// so a width/height swap cannot pass.
|
|
5
|
+
// pic.gif — recorded-from: macOS sips converting that 3×2 PNG to GIF on 2026-10-06 (49 bytes, GIF87a).
|
|
6
|
+
// hdr.jpeg — invented: a 33-byte JPEG header (SOI, one APP1 segment to walk over, SOF0 3×2, EOI); the
|
|
7
|
+
// reader's contract is the segment walk, not the scan data.
|
|
8
|
+
// rst.jpeg — invented: a 25-byte JPEG header with a standalone RST0 marker before SOF0 3×2 — the walk
|
|
9
|
+
// must step over a marker that carries no length field.
|
|
10
|
+
// trunc.jpeg — invented: a 10-byte JPEG header whose APP1 segment claims 32 bytes the file does not
|
|
11
|
+
// hold — the walk must give up rather than read past the end.
|
|
12
|
+
// hdr.webp — invented: a 12-byte RIFF/WEBP signature — a known image whose dimensions this module does
|
|
13
|
+
// not walk, which is the path that cards with the path alone.
|
|
14
|
+
// bad.png — invented: the PNG header with both dimensions zeroed (33 bytes) — the shape a corrupt or
|
|
15
|
+
// truncated header produces, where the dimensions must be dropped, not trusted.
|
|
16
|
+
// cut.png — invented: the 8-byte PNG signature alone, no IHDR.
|
|
17
|
+
// notes.txt — invented: real text behind an image reference, the not-an-image case.
|
|
18
|
+
|
|
19
|
+
// shape: none — test module: every behavior is asserted only through the createImageCardSurface seam.
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
24
|
+
import { markerPaint } from "../../../test/fakes/index.ts";
|
|
25
|
+
import type { TransformContext } from "../../core/types/host.ts";
|
|
26
|
+
import { createImageCardSurface } from "./image-card.ts";
|
|
27
|
+
|
|
28
|
+
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAEElEQVR42mP4z8AAQQxwFgBB0gX7b/EWxwAAAABJRU5ErkJggg==";
|
|
29
|
+
const GIF = "R0lGODdhAwACAJEAAAAAAP8AAP///wAAACH5BAQAAAAALAAAAAADAAIAAAICjF8AOw==";
|
|
30
|
+
const JPEG_HEADER = "/9j/4QAIRXhpZgAA/8AAEQgAAgADAwERAAIRAQMRAf/Z";
|
|
31
|
+
const JPEG_RST_HEADER = "/9j/0P/AABEIAAIAAwMBEQACEQEDEQH/2Q==";
|
|
32
|
+
const JPEG_TRUNCATED_HEADER = "/9j/4QAgRXhpZg==";
|
|
33
|
+
const WEBP_HEADER = "UklGRgQAAABXRUJQ";
|
|
34
|
+
const ZEROED_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAAAAAAACAIAAAAAAAAA";
|
|
35
|
+
const CUT_PNG = "iVBORw0KGgo=";
|
|
36
|
+
|
|
37
|
+
/** The offered width every assertion here runs at: a 64-column card, 60 columns of content. */
|
|
38
|
+
const ctx: TransformContext = { messageType: "assistant", isStreaming: false, availableWidth: 100 };
|
|
39
|
+
|
|
40
|
+
let dir: string;
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
dir = mkdtempSync(join(tmpdir(), "pi-render-image-card-"));
|
|
44
|
+
writeFileSync(join(dir, "pic.png"), Buffer.from(PNG, "base64"));
|
|
45
|
+
writeFileSync(join(dir, "pic.gif"), Buffer.from(GIF, "base64"));
|
|
46
|
+
writeFileSync(join(dir, "hdr.jpeg"), Buffer.from(JPEG_HEADER, "base64"));
|
|
47
|
+
writeFileSync(join(dir, "rst.jpeg"), Buffer.from(JPEG_RST_HEADER, "base64"));
|
|
48
|
+
writeFileSync(join(dir, "trunc.jpeg"), Buffer.from(JPEG_TRUNCATED_HEADER, "base64"));
|
|
49
|
+
writeFileSync(join(dir, "hdr.webp"), Buffer.from(WEBP_HEADER, "base64"));
|
|
50
|
+
writeFileSync(join(dir, "bad.png"), Buffer.from(ZEROED_PNG, "base64"));
|
|
51
|
+
writeFileSync(join(dir, "cut.png"), Buffer.from(CUT_PNG, "base64"));
|
|
52
|
+
writeFileSync(join(dir, "notes.txt"), "not an image\n");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("createImageCardSurface — image references", () => {
|
|
60
|
+
it("AC-1: an existing local image renders the golden card with its resolved path and dimensions", () => {
|
|
61
|
+
const paint = markerPaint();
|
|
62
|
+
const surface = createImageCardSurface({ projectDir: dir });
|
|
63
|
+
const path = join(dir, "pic.png");
|
|
64
|
+
const out = surface.rewrite("", ctx, paint);
|
|
65
|
+
// Golden geometry at 64 columns: header rule run 56, content 60, bottom run 62; the frame carries
|
|
66
|
+
// the rule role and every text row the quote role — the only content role that reads as metadata.
|
|
67
|
+
// A temp path outgrows one row on some machines, so the expected path rows are its pinned
|
|
68
|
+
// 60-column chunks: the widths and the row shape are literals, only the path's length varies.
|
|
69
|
+
const pathRows: string[] = [];
|
|
70
|
+
for (let at = 0; at < path.length; at += 60) {
|
|
71
|
+
pathRows.push(`${paint.rule("│")} ${paint.quote(path.slice(at, at + 60).padEnd(60))} ${paint.rule("│")}`);
|
|
72
|
+
}
|
|
73
|
+
const golden = [
|
|
74
|
+
paint.rule(`╭─ png ${"─".repeat(56)}╮`),
|
|
75
|
+
`${paint.rule("│")} ${paint.quote(`pic.png · 3×2${" ".repeat(47)}`)} ${paint.rule("│")}`,
|
|
76
|
+
...pathRows,
|
|
77
|
+
paint.rule(`╰${"─".repeat(62)}╯`),
|
|
78
|
+
].join(" \n");
|
|
79
|
+
// fails_when: card shape drifts or dims are wrong
|
|
80
|
+
expect(out).toBe(`${golden}\n`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("AC-1: PNG, GIF and JPEG headers become dimensions and an unwalked format still cards", () => {
|
|
84
|
+
const paint = markerPaint();
|
|
85
|
+
const surface = createImageCardSurface({ projectDir: dir });
|
|
86
|
+
const titleRow = (ref: string): string => surface.rewrite(``, ctx, paint).split(" \n")[1] ?? "";
|
|
87
|
+
const cases: ReadonlyArray<readonly [string, string]> = [
|
|
88
|
+
["pic.png", "pic.png · 3×2"],
|
|
89
|
+
["pic.gif", "pic.gif · 3×2"],
|
|
90
|
+
["hdr.jpeg", "hdr.jpeg · 3×2"],
|
|
91
|
+
["rst.jpeg", "rst.jpeg · 3×2"],
|
|
92
|
+
];
|
|
93
|
+
for (const [ref, title] of cases) {
|
|
94
|
+
// fails_when: a header's offsets are wrong or a width/height swap goes unnoticed
|
|
95
|
+
expect(titleRow(ref)).toContain(title);
|
|
96
|
+
}
|
|
97
|
+
// fails_when: an image format whose dimensions we do not read drops the card instead of the path
|
|
98
|
+
expect(titleRow("hdr.webp")).toContain("hdr.webp");
|
|
99
|
+
expect(titleRow("hdr.webp")).not.toContain("·");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("AC-1: a corrupt header drops the dimensions instead of the card", () => {
|
|
103
|
+
const paint = markerPaint();
|
|
104
|
+
const surface = createImageCardSurface({ projectDir: dir });
|
|
105
|
+
const titleRow = (ref: string): string => surface.rewrite(``, ctx, paint).split(" \n")[1] ?? "";
|
|
106
|
+
for (const ref of ["bad.png", "cut.png", "trunc.jpeg"]) {
|
|
107
|
+
// fails_when: a header we cannot trust is trusted anyway
|
|
108
|
+
expect(titleRow(ref)).toContain(ref);
|
|
109
|
+
expect(titleRow(ref)).not.toContain("·");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("AC-2: a reference to a file that is not there stays raw with one log line", () => {
|
|
114
|
+
const log: string[] = [];
|
|
115
|
+
const surface = createImageCardSurface({ projectDir: dir, log: (line) => log.push(line) });
|
|
116
|
+
const markdown = "";
|
|
117
|
+
// fails_when: a missing image breaks the answer render
|
|
118
|
+
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
119
|
+
expect(log).toHaveLength(1);
|
|
120
|
+
expect(log[0]).toContain("gone.png");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("AC-2: a local file that is not an image stays raw with one log line", () => {
|
|
124
|
+
const log: string[] = [];
|
|
125
|
+
const surface = createImageCardSurface({ projectDir: dir, log: (line) => log.push(line) });
|
|
126
|
+
const markdown = "";
|
|
127
|
+
// fails_when: a non-image file behind an image reference is carded
|
|
128
|
+
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
129
|
+
expect(log).toHaveLength(1);
|
|
130
|
+
expect(log[0]).toContain("notes.txt");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("AC-2: a probe that throws keeps the reference and logs its message", () => {
|
|
134
|
+
const log: string[] = [];
|
|
135
|
+
const surface = createImageCardSurface({
|
|
136
|
+
projectDir: dir,
|
|
137
|
+
log: (line) => log.push(line),
|
|
138
|
+
probe: () => {
|
|
139
|
+
throw new Error("disk gone");
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const markdown = "";
|
|
143
|
+
// fails_when: a failing probe takes the answer render down
|
|
144
|
+
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
145
|
+
expect(log).toHaveLength(1);
|
|
146
|
+
expect(log[0]).toContain("disk gone");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("AC-2: a remote, protocol-relative or anchored reference is left alone and never logged", () => {
|
|
150
|
+
const log: string[] = [];
|
|
151
|
+
const surface = createImageCardSurface({ projectDir: dir, log: (line) => log.push(line) });
|
|
152
|
+
const hrefs = ["https://example.test/a.png", "//example.test/a.png", "#figure", "data:image/png;base64,AAAA"];
|
|
153
|
+
for (const href of hrefs) {
|
|
154
|
+
const markdown = ``;
|
|
155
|
+
// fails_when: a reference pi-render does not own is read as a local path
|
|
156
|
+
expect(surface.rewrite(markdown, ctx, markerPaint())).toBe(markdown);
|
|
157
|
+
}
|
|
158
|
+
expect(log).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("leaves a reference inside a fenced block alone and cards the one outside it", () => {
|
|
162
|
+
const surface = createImageCardSurface({ projectDir: dir });
|
|
163
|
+
const markdown = ["```md", "", "```", ""].join("\n");
|
|
164
|
+
const out = surface.rewrite(markdown, ctx, markerPaint());
|
|
165
|
+
// fails_when: text inside a code block is rewritten as an image reference
|
|
166
|
+
expect(out).toContain("```md\n\n```");
|
|
167
|
+
// exactly one card, and it is the reference outside the fence
|
|
168
|
+
expect(out.match(/╭─ png /g)).toHaveLength(1);
|
|
169
|
+
});
|
|
170
|
+
});
|