@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,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
+ }