@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quan Dinh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,111 @@
1
- # Temporary Holding Version
1
+ # pi-render
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A render-only presentation layer for **pi**: styled tool rows and a richer answer
4
+ canvas, with zero tool-name registrations. pi-render draws; the host and its peers
5
+ execute.
6
+
7
+ It registers one row renderer resolver and one markdown transformer, draws eight
8
+ built-in tool rows collapsed to a single line, and renders tables, code/JSON
9
+ panels, diagram fences, and images inside assistant answers. It adds no tool
10
+ schema, no prompt text, and no execution wrapper.
11
+
12
+ Replaces [pi-pretty-tui](https://github.com/zenodinh/pi-pretty-tui), fixing
13
+ [#21](https://github.com/zenodinh/pi-pretty-tui/issues/21) (expand freeze) and
14
+ [#22](https://github.com/zenodinh/pi-pretty-tui/issues/22) (table frame now takes
15
+ its color from the host theme, at 4.5:1 or better).
16
+
17
+ ## Requirements
18
+
19
+ pi 1.0.4 or later.
20
+
21
+ pi-render and pi-pretty-tui must never be active together: the two do not share a
22
+ session. One leaves pi's installed-packages list as the other enters.
23
+
24
+ ## Install
25
+
26
+ Installing is one edit to pi's installed-packages list. Both commands write
27
+ `~/.pi/agent/settings.json`.
28
+
29
+ ```bash
30
+ pi list # pi-pretty-tui's entry appears on its own line (a path)
31
+ pi remove <exactly what pi list printed for it> # a path, absolute or relative — not the npm: prefix
32
+ pi install npm:@zenodinh/pi-render
33
+ ```
34
+
35
+ Then re-enable the built-in `codemode` tool, which the predecessor displaced —
36
+ delete this entry from `~/.pi/agent/settings.json` if it is present:
37
+
38
+ ```json
39
+ "extensions": ["-builtin:codemode"]
40
+ ```
41
+
42
+ Start a new pi session. The `packages` list now holds the new entry and no
43
+ predecessor entry:
44
+
45
+ ```json
46
+ "packages": ["npm:@zenodinh/pi-render"]
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ Every tool row renders collapsed to one line, with errors summarized on that line.
52
+ `ctrl+o` — or a click — expands the row; expanding a call again reuses the cached
53
+ render, so there is no re-parse and no freeze.
54
+
55
+ `/canvas` reports the canvas surfaces and the artifact cache, and opens the
56
+ artifact browser in a TUI host. In a non-TUI host it prints a summary instead.
57
+
58
+ ## What you get, and what changes
59
+
60
+ | | pi-pretty-tui | pi-render |
61
+ |---|---|---|
62
+ | Tool rows | `bash`, `ls`, `read`, `edit`, `write`, `codemode`, `find`, `grep` | same eight, drawn through the resolver chain |
63
+ | Search execution | `find`/`grep` re-registered and routed to FFF via a library shim | the host's built-in `find`/`grep`; `@ff-labs/pi-fff` is an optional peer extension |
64
+ | Answer canvas | tables, panels, diagram fences, images | same, plus a host-theme-driven table frame |
65
+ | Chrome (user-message marker, prompt icon, working shimmer, thinking label) | drawn here | not drawn — install pi-zentui independently, or keep host defaults |
66
+ | Tool registrations | 8 | 0 |
67
+ | Diagnostics log file | yes | gone (removed with the slow-startup root cause) |
68
+
69
+ ## Themes
70
+
71
+ Two host theme definitions ship with the package and register additively: **Dracula
72
+ Soft** and **One Dark**. Pick either in pi's theme picker; installing the package
73
+ never changes your active theme.
74
+
75
+ Code blocks highlight with one of two shiki themes, `dracula-soft` (default) and
76
+ `one-dark-pro`.
77
+
78
+ ## Search
79
+
80
+ pi 1.0.4 already ships built-in `find` and `grep`, so search works after the
81
+ cutover with nothing else installed. `@ff-labs/pi-fff` is **optional**: it adds
82
+ FFF's frecency and fuzzy index on top.
83
+
84
+ ```bash
85
+ pi install npm:@ff-labs/pi-fff
86
+ ```
87
+
88
+ pi-fff owns the names and the execution; pi-render only styles the rows it
89
+ registers (`find`/`grep`, or `fffind`/`ffgrep` in its non-override modes).
90
+
91
+ ## Rollback
92
+
93
+ Reinstall the predecessor — the same hard switch, in reverse:
94
+
95
+ ```bash
96
+ git clone https://github.com/zenodinh/pi-pretty-tui ~/personal/pi-pretty-tui
97
+ cd ~/personal/pi-pretty-tui && npm install --omit=dev --omit=peer
98
+ pi remove npm:@zenodinh/pi-render
99
+ pi install ~/personal/pi-pretty-tui
100
+ ```
101
+
102
+ The predecessor loads pi-fff as a library and registers `find`/`grep` itself, so
103
+ also remove pi-fff as a separate extension if you installed it above:
104
+
105
+ ```bash
106
+ pi remove npm:@ff-labs/pi-fff
107
+ ```
108
+
109
+ ## License
110
+
111
+ MIT — see [LICENSE](LICENSE).
package/index.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * index.ts — the composition root: the package's only file that calls pi.*.
3
+ *
4
+ * Why one file: every lane ends in a plain value (a resolver, a transformer, a command), so boot is a
5
+ * table of contents — build the registry, compose the two tables, register once per seam, and warm the
6
+ * engines the synchronous seams call. Each lane installs in its own try/catch: a broken lane costs
7
+ * itself, never the session (SA §2). The predecessor's ordering lore does not port — with zero
8
+ * registrations there is no inter-extension race left to sequence around.
9
+ *
10
+ * Boundary: the host arrives as the parameter. Nothing here parses markdown, draws a row or decides
11
+ * module state; the one adapter this file owns is the artifacts fence pass, which no lane supplies.
12
+ *
13
+ * shape: none — wiring only: `piRender` is the runtime unit, and every install below is straight-line.
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { isAbsolute, resolve } from "node:path";
18
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
19
+ import { installCommands } from "./src/commands/canvas.ts";
20
+ import { CODE_THEME_MODULE, createCodeTheme, createShikiEngine } from "./src/core/code-theme.ts";
21
+ import { createLogger } from "./src/core/log.ts";
22
+ import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
23
+ import { createRegistry } from "./src/core/registry.ts";
24
+ import { createSettingsStore } from "./src/core/settings.ts";
25
+ import type {
26
+ CommandContext,
27
+ ContentPaint,
28
+ ExtensionApi,
29
+ Logger,
30
+ ModuleDescriptor,
31
+ Registry,
32
+ Surface,
33
+ } from "./src/core/types.ts";
34
+ import { type ArtifactCache, createArtifactCache, renderCacheKey } from "./src/renderers/content/artifacts/cache.ts";
35
+ import { artifactTitle, renderArtifactCard } from "./src/renderers/content/artifacts/cards.ts";
36
+ import {
37
+ type DiagramForm,
38
+ type EngineStatus,
39
+ renderDiagram,
40
+ status,
41
+ warmup,
42
+ } from "./src/renderers/content/artifacts/engines.ts";
43
+ import { type ArtifactServer, createArtifactServer } from "./src/renderers/content/artifacts/server.ts";
44
+ import { createCodePanel, mapFencedBlocks, panelWidth } from "./src/renderers/content/code-panel.ts";
45
+ import { createImageCardSurface } from "./src/renderers/content/image-card.ts";
46
+ import { type ContentSurfaces, createContentTransformer } from "./src/renderers/content/index.ts";
47
+ import { createJsonPanel } from "./src/renderers/content/json-panel.ts";
48
+ import { table } from "./src/renderers/content/table.ts";
49
+ import { createMasterResolver, type SpecEntry } from "./src/renderers/tool/index.ts";
50
+ import { bashRow } from "./src/renderers/tool/specs/bash.ts";
51
+ import {
52
+ descriptor as codemodeModule,
53
+ name as codemodeName,
54
+ spec as codemodeSpec,
55
+ } from "./src/renderers/tool/specs/codemode.ts";
56
+ import { editRow } from "./src/renderers/tool/specs/edit.ts";
57
+ import { lsRow } from "./src/renderers/tool/specs/ls.ts";
58
+ import { readRow } from "./src/renderers/tool/specs/read.ts";
59
+ import { SEARCH_SPECS } from "./src/renderers/tool/specs/search.ts";
60
+ import { writeRow } from "./src/renderers/tool/specs/write.ts";
61
+
62
+ const SCOPE = "boot";
63
+
64
+ /** Relative transcript paths resolve against the launch directory — the transform context carries no cwd. */
65
+ const PROJECT_DIR = process.cwd();
66
+
67
+ /** The ten rendered names: the specs own them, so a pi-fff rename fails here instead of falling to the host. */
68
+ const ROW_RECORDS = [
69
+ bashRow,
70
+ lsRow,
71
+ readRow,
72
+ editRow,
73
+ writeRow,
74
+ { name: codemodeName, spec: codemodeSpec, descriptor: codemodeModule },
75
+ ...SEARCH_SPECS,
76
+ ];
77
+
78
+ /** Region 1's table: tool name → spec plus governing key (explicit, because search's four share one key). */
79
+ const ROW_SPECS: Record<string, SpecEntry> = Object.fromEntries(
80
+ ROW_RECORDS.map((row): [string, SpecEntry] => [row.name, { spec: row.spec, key: row.descriptor.key }]),
81
+ );
82
+
83
+ /**
84
+ * Region 2's modules: one per surface slot, keyed as the transformer polls them ("content." + slot,
85
+ * SA §6). The surface lanes export `rewrite` only, so their descriptors live here.
86
+ */
87
+ const CONTENT_MODULES: readonly ModuleDescriptor[] = [
88
+ { key: "content.table", name: "Table blocks", defaultEnabled: true, settings: [] },
89
+ { key: "content.codePanel", name: "Code panels", defaultEnabled: true, settings: [] },
90
+ { key: "content.jsonPanel", name: "JSON panels", defaultEnabled: true, settings: [] },
91
+ { key: "content.imageCard", name: "Image cards", defaultEnabled: true, settings: [] },
92
+ { key: "content.artifacts", name: "Artifact cards", defaultEnabled: true, settings: [] },
93
+ ];
94
+
95
+ /** Every module this package exposes — what the /render panel and both render seams read. */
96
+ const MODULES: readonly ModuleDescriptor[] = uniqueModules([
97
+ ...ROW_RECORDS.map((row) => row.descriptor),
98
+ CODE_THEME_MODULE,
99
+ ...CONTENT_MODULES,
100
+ ]);
101
+
102
+ /** shape: none — one dedupe pass over a fixed list; search's four names carry one shared descriptor. */
103
+ function uniqueModules(descriptors: readonly ModuleDescriptor[]): ModuleDescriptor[] {
104
+ const byKey = new Map<string, ModuleDescriptor>();
105
+ for (const descriptor of descriptors) byKey.set(descriptor.key, descriptor);
106
+ return [...byKey.values()];
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // The artifacts fence pass — the one adapter this file owns (SA §6)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /** Raster width the cache key is computed from; pi-tui's own image width (P transformer.ts:68). */
114
+ const ARTIFACT_WIDTH_CELLS = 60;
115
+ const ARTIFACT_MODE = "image";
116
+
117
+ /** shape: none — one membership test narrowing a fence language to a form the pass can render. */
118
+ function isArtifactForm(lang: string): lang is DiagramForm {
119
+ return lang === "plantuml" || lang === "dot" || lang === "svg" || lang === "html";
120
+ }
121
+
122
+ // ported from pi-pretty-tui/src/features/canvas/transformer.ts:134-226 — survives because: fence → card
123
+ // with a raw fence on failure is the whole artifacts surface; its settings gate and inline pixels do not.
124
+ /**
125
+ * shape: closure returning an object literal — trigger #4, one stateless Surface over the captured cache,
126
+ * server and log.
127
+ */
128
+ function createArtifactsSurface(cache: ArtifactCache, server: ArtifactServer, log: Logger): Surface {
129
+ /** One fence to one card, or undefined so the raw fence stays byte-identical. */
130
+ const card = (form: DiagramForm, source: string, width: number, paint: ContentPaint): string | undefined => {
131
+ try {
132
+ const key = renderCacheKey(form, source, ARTIFACT_MODE, ARTIFACT_WIDTH_CELLS);
133
+ const result = renderDiagram(form, source, {
134
+ mode: ARTIFACT_MODE,
135
+ widthCells: ARTIFACT_WIDTH_CELLS,
136
+ cache,
137
+ log,
138
+ });
139
+ // A diagram opens its vector; html renders to a raster the cache exposes only through get(), so
140
+ // its saved source is the open target (P fenceCard).
141
+ const path =
142
+ (result.kind === "svg" ? cache.vectorPath(key) : undefined) ??
143
+ cache.putSource(key, source, form === "plantuml" ? "puml" : form);
144
+ if (path === undefined) return undefined;
145
+ return renderArtifactCard(server, { typeLabel: form, title: artifactTitle(form, source), path, width }, paint);
146
+ } catch (error) {
147
+ // A missing binary or an unloadable engine: raw fence plus one line, never a throw into the transform.
148
+ log.logOnce(`artifact:${form}`, SCOPE, `artifact ${form} not rendered: ${messageOf(error)}`);
149
+ return undefined;
150
+ }
151
+ };
152
+
153
+ return {
154
+ rewrite(markdown, ctx, paint) {
155
+ const width = panelWidth(ctx.availableWidth);
156
+ const { text, changed } = mapFencedBlocks(markdown, (lang, code) =>
157
+ isArtifactForm(lang) ? card(lang, code, width, paint) : undefined,
158
+ );
159
+ return changed ? text : markdown;
160
+ },
161
+ };
162
+ }
163
+
164
+ /** The JSON pane's reader: a relative link resolves from the launch directory; every miss is undefined. */
165
+ // shape: none — one guarded read; the pane owns the degrade.
166
+ function readLinkedFile(href: string): string | undefined {
167
+ try {
168
+ return readFileSync(isAbsolute(href) ? href : resolve(PROJECT_DIR, href), "utf8");
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Lanes — one install per seam, each settled on its own
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /** Region 1: the ten-name table behind one resolver — the package's only registerToolRenderer call. */
179
+ function installRows(pi: ExtensionApi, registry: Registry, log: Logger): void {
180
+ pi.registerToolRenderer(createMasterResolver(registry, ROW_SPECS, { paint: createRowPaint, log }));
181
+ }
182
+
183
+ /**
184
+ * The artifacts lane: warm the engines the synchronous fence pass calls, bind the one server, then hand
185
+ * the pass back. A failed bind degrades every card to a file:// link, so only a throw costs this slot.
186
+ */
187
+ async function installArtifacts(log: Logger, deps: BootDeps): Promise<Surface> {
188
+ await (deps.warmArtifactEngines ?? warmup)(log);
189
+ const server = (deps.createArtifactServer ?? ((logger: Logger) => createArtifactServer({ log: logger })))(log);
190
+ await server.start();
191
+ return createArtifactsSurface(createArtifactCache({ cacheDir: deps.cacheDir, log }), server, log);
192
+ }
193
+
194
+ /** Region 2: code theme, five surfaces, one transformer — the only registerMarkdownTransformer call. */
195
+ async function installContent(
196
+ pi: ExtensionApi,
197
+ registry: Registry,
198
+ log: Logger,
199
+ artifacts: Surface | undefined,
200
+ ): Promise<void> {
201
+ // The panel seam is synchronous while every engine start-up is not, so the highlight core is warmed
202
+ // here, before this registration can be reached.
203
+ const codeTheme = createCodeTheme(await createShikiEngine(log), { registry, log });
204
+ const surfaces: ContentSurfaces = {
205
+ table,
206
+ codePanel: createCodePanel(codeTheme),
207
+ jsonPanel: createJsonPanel(codeTheme, { read: readLinkedFile, log }),
208
+ imageCard: createImageCardSurface({
209
+ projectDir: PROJECT_DIR,
210
+ log: (line) => log.logLine(SCOPE, line),
211
+ }),
212
+ artifacts,
213
+ };
214
+ // The host's markdown theme is live per call, so its closures are captured once, here.
215
+ pi.registerMarkdownTransformer(
216
+ createContentTransformer(registry, surfaces, { paint: createContentPaint(getMarkdownTheme()), log }),
217
+ );
218
+ }
219
+
220
+ /** The /canvas view: what the artifact engines can draw this session. The browser pane is T-32's. */
221
+ function canvasView(ctx: CommandContext): Promise<void> {
222
+ const ready = Object.entries(status())
223
+ .filter(([, on]) => on)
224
+ .map(([id]) => id);
225
+ ctx.ui.notify(`canvas: artifact engines ready — ${ready.join(", ") || "none"}`);
226
+ return Promise.resolve();
227
+ }
228
+
229
+ /** Region 9: the one command, which reaches the rest of the package through the registry alone (SA §7). */
230
+ function installCanvasCommand(pi: ExtensionApi, registry: Registry): void {
231
+ installCommands(pi, { registry, canvasView });
232
+ }
233
+
234
+ /** shape: none — one guarded call; a lane reports itself and boot carries on. */
235
+ async function settle<T>(lane: string, log: Logger, install: () => T | Promise<T>): Promise<T | undefined> {
236
+ try {
237
+ return await install();
238
+ } catch (error) {
239
+ log.logOnce(`boot:${lane}`, SCOPE, `${lane} lane failed: ${messageOf(error)}`);
240
+ return undefined;
241
+ }
242
+ }
243
+
244
+ /** shape: none — one message extraction for a caught value of unknown type. */
245
+ function messageOf(error: unknown): string {
246
+ return error instanceof Error ? error.message : String(error);
247
+ }
248
+
249
+ /**
250
+ * The composition root's injected collaborators. Production passes none; a boot test replaces the heavy
251
+ * or fallible ones — the settings document, the wasm warm-up, the loopback bind, the cache directory —
252
+ * and can throw from one to prove a failed lane costs only itself.
253
+ */
254
+ export interface BootDeps {
255
+ /** Settings document path. Optional; the default is the host's agent directory. */
256
+ settingsPath?: string;
257
+ /** Artifact engine warm-up; resolves once the four engines are ready. Optional. */
258
+ warmArtifactEngines?: (log: Logger) => Promise<EngineStatus>;
259
+ /** Artifact server factory. Optional; the default binds one loopback port. */
260
+ createArtifactServer?: (log: Logger) => ArtifactServer;
261
+ /** Rendered-artifact cache directory. Optional; the default is the agent cache directory. */
262
+ cacheDir?: string;
263
+ }
264
+
265
+ /** shape: none — the boot sequence itself; every runtime lives in the lane that installs it. */
266
+ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): Promise<void> {
267
+ const log = createLogger();
268
+ const registry = createRegistry(createSettingsStore({ path: deps.settingsPath, logger: log }), log);
269
+ for (const descriptor of MODULES) registry.defineModule(descriptor);
270
+
271
+ // Lane by lane: the artifacts pass resolves before content, because the transformer dispatches it.
272
+ await settle("rows", log, () => installRows(pi, registry, log));
273
+ const artifacts = await settle("artifacts", log, () => installArtifacts(log, deps));
274
+ await settle("content", log, () => installContent(pi, registry, log, artifacts));
275
+ await settle("commands", log, () => installCanvasCommand(pi, registry));
276
+
277
+ // Collapse-first reading model (owner decision): every tool row starts on one line.
278
+ pi.setToolsExpanded(false);
279
+ }
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
- "name": "@zenodinh/pi-render",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@zenodinh/pi-render",
3
+ "private": false,
4
+ "description": "Render-only presentation layer for pi: tool rows and content surfaces, zero tool registrations.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=26"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "themes"
14
+ ],
15
+ "scripts": {
16
+ "check": "tsc --noEmit",
17
+ "lint": "biome check .",
18
+ "lint:fix": "biome check --write .",
19
+ "test": "vitest run --passWithNoTests",
20
+ "test:coverage": "vitest run --coverage --passWithNoTests",
21
+ "scan": "node scripts/security-scan.ts"
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ],
27
+ "themes": [
28
+ "themes/dracula-soft.json",
29
+ "themes/one-dark.json"
30
+ ]
31
+ },
32
+ "dependencies": {
33
+ "@resvg/resvg-wasm": "^2.6.2",
34
+ "@shikijs/cli": "^4.5.0",
35
+ "@shikijs/themes": "^4.5.0",
36
+ "@viz-js/viz": "^3.31.0",
37
+ "canvaskit-wasm": "^0.42.0",
38
+ "shiki": "4.5.0"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-ai": "*",
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@earendil-works/pi-ai": {
47
+ "optional": true
48
+ },
49
+ "@earendil-works/pi-coding-agent": {
50
+ "optional": true
51
+ },
52
+ "@earendil-works/pi-tui": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@biomejs/biome": "2.5.15",
58
+ "@earendil-works/pi-ai": "^1.0.0",
59
+ "@earendil-works/pi-coding-agent": "^1.0.0",
60
+ "@earendil-works/pi-tui": "^1.0.0",
61
+ "@types/node": "^26.6.4",
62
+ "@vitest/coverage-v8": "^5.0.3",
63
+ "typescript": "^7.0.2",
64
+ "vitest": "^5.0.3"
65
+ },
66
+ "version": "0.1.0"
67
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * canvas through its seam — installCommands(pi, deps) on the recording host API, its handler driven
3
+ * with a host-shaped context and the REAL registry (the command's only channel to the package).
4
+ *
5
+ * invented: the settings documents and the content.artifacts descriptor below are hand-built to the
6
+ * SA §3 literal (version + modules map) keyed on SA §6's artifacts module — T-25/T-26 own the real
7
+ * descriptor and T-07 the on-disk file, so there is no recorded payload to reuse; the documents are
8
+ * what drives the enabled/disabled state this command reports.
9
+ * invented: the context double and the view recorder stand in for the host and for the browser T-28
10
+ * injects — the command's own seam is exactly these two collaborators.
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { describe, expect, test } from "vitest";
15
+ import type { RecordedApiCall, RecordedCommandRegistration } from "../../test/fakes/index.ts";
16
+ import { recordingExtensionApi } from "../../test/fakes/index.ts";
17
+ import { createRegistry } from "../core/registry.ts";
18
+ import type { CommandContext, Logger, ModuleDescriptor, SettingsDoc } from "../core/types.ts";
19
+ import { installCommands } from "./canvas.ts";
20
+
21
+ const ARTIFACTS: ModuleDescriptor = { key: "content.artifacts", name: "Artifacts", defaultEnabled: true, settings: [] };
22
+ const ENABLED: SettingsDoc = { version: 1, modules: { "content.artifacts": { enabled: true } } };
23
+ const DISABLED: SettingsDoc = { version: 1, modules: { "content.artifacts": { enabled: false } } };
24
+ const NON_TUI = ["rpc", "json", "print"] as const;
25
+
26
+ /** The registry's own diagnostics are T-09's concern, not this seam's. */
27
+ const SILENT: Logger = { logLine: () => {}, logOnce: () => {}, drain: () => [] };
28
+
29
+ /** Every `from "<specifier>"` in a source file. */
30
+ const IMPORT_SPECIFIER = /from\s+"([^"]+)"/g;
31
+
32
+ interface Harness {
33
+ /** Every registration the install made, in order. */
34
+ readonly calls: readonly RecordedApiCall[];
35
+ /** The command registrations, in order. */
36
+ readonly commands: readonly RecordedCommandRegistration[];
37
+ /** Every ctx the injected view opened with. */
38
+ readonly opened: CommandContext[];
39
+ /** Every document the registry saved — a /canvas run must leave this empty (SA §7). */
40
+ readonly saved: SettingsDoc[];
41
+ }
42
+
43
+ function harness(doc: SettingsDoc): Harness {
44
+ const pi = recordingExtensionApi();
45
+ const opened: CommandContext[] = [];
46
+ const saved: SettingsDoc[] = [];
47
+ const registry = createRegistry({ load: () => structuredClone(doc), save: (next) => void saved.push(next) }, SILENT);
48
+ registry.defineModule(ARTIFACTS);
49
+ installCommands(pi, {
50
+ registry,
51
+ canvasView: async (ctx) => {
52
+ // One macrotask, like the real panel open: an implementation that drops the await leaves
53
+ // `opened` empty when the test asserts (ASY-1), so the assertion pins the await too.
54
+ await new Promise((resolve) => setImmediate(resolve));
55
+ opened.push(ctx);
56
+ },
57
+ });
58
+ return { calls: pi.calls, commands: pi.commands, opened, saved };
59
+ }
60
+
61
+ interface Run {
62
+ /** The context the host handed the handler. */
63
+ readonly ctx: CommandContext;
64
+ /** Every notify message the handler produced, in order. */
65
+ readonly notices: readonly string[];
66
+ }
67
+
68
+ /** Enters where the host enters: the registered handler, with a host-shaped context, awaited. */
69
+ async function runCanvas(h: Harness, mode: CommandContext["mode"]): Promise<Run> {
70
+ const handler = h.commands[0]?.options.handler;
71
+ if (handler === undefined) throw new Error("no canvas command registered");
72
+ const notices: string[] = [];
73
+ const ctx: CommandContext = {
74
+ mode,
75
+ ui: {
76
+ notify: (message) => {
77
+ notices.push(message);
78
+ },
79
+ },
80
+ };
81
+ await handler("", ctx);
82
+ return { ctx, notices };
83
+ }
84
+
85
+ describe("canvas command", () => {
86
+ // spec: installCommands(pi, deps) -> exactly one registerCommand call, named "canvas", handler callable.
87
+ // fails_when: zero, duplicate, or extra commands (a second site, a renamed or missing command).
88
+ test("AC-1 installCommands registers exactly one command named canvas", () => {
89
+ const h = harness(ENABLED);
90
+
91
+ expect(h.commands.map((command) => command.name)).toEqual(["canvas"]);
92
+ expect(typeof h.commands[0]?.options.handler).toBe("function");
93
+ // The command lane touches its own seam only — no setToolsExpanded, no resolver, no transformer.
94
+ expect(h.calls.map((call) => call.method)).toEqual(["registerCommand"]);
95
+ });
96
+
97
+ // spec: /canvas in a non-TUI host (rpc/json/print) -> one notify carrying the status summary; the
98
+ // view never opens and nothing throws.
99
+ // fails_when: a non-TUI host crashes, goes silent, or opens a view it cannot draw.
100
+ test("AC-2 a non-TUI host gets a notify summary and never the view", async () => {
101
+ for (const mode of NON_TUI) {
102
+ const h = harness(ENABLED);
103
+
104
+ const run = await runCanvas(h, mode);
105
+
106
+ expect(run.notices).toEqual([
107
+ `canvas: content.artifacts is enabled — the artifact browser needs a TUI host (mode: ${mode})`,
108
+ ]);
109
+ expect(h.opened).toEqual([]);
110
+ }
111
+ });
112
+
113
+ // spec: the module's imports -> exactly ["../core/types.ts"]; status comes from the registry, not a lane.
114
+ // fails_when: the command reaches into a renderer lane for state instead of the registry (SA §1 rule 3).
115
+ test("AC-3 the command reaches the package only through core", () => {
116
+ const source = readFileSync(new URL("./canvas.ts", import.meta.url), "utf8");
117
+
118
+ const specifiers = [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1] ?? "");
119
+
120
+ expect(specifiers).toEqual(["../core/types.ts"]);
121
+ });
122
+
123
+ // spec: content.artifacts disabled in the registry + a TUI host -> one notify reporting the disabled
124
+ // state, the view never opens, the registry is never written.
125
+ // fails_when: a disabled module's command acts anyway, or the report stays silent.
126
+ test("AC-4 a disabled content.artifacts reports instead of opening the view", async () => {
127
+ const h = harness(DISABLED);
128
+
129
+ const run = await runCanvas(h, "tui");
130
+
131
+ expect(run.notices).toEqual(["canvas: content.artifacts is disabled — enable it to open the artifact browser"]);
132
+ expect(h.opened).toEqual([]);
133
+ expect(h.saved).toEqual([]);
134
+ });
135
+
136
+ // spec: content.artifacts enabled + a TUI host -> the injected view opens once, with the host's own
137
+ // ctx, no notify, and no registry write (SA §7: /canvas reads state, never writes it).
138
+ // fails_when: the view never opens, opens twice, gets a rebuilt ctx, or the command writes settings —
139
+ // the positive control that keeps AC-4 from passing on a command that never acts at all.
140
+ test("AC-4 positive control: an enabled TUI host opens the injected view", async () => {
141
+ const h = harness(ENABLED);
142
+
143
+ const run = await runCanvas(h, "tui");
144
+
145
+ expect(h.opened).toHaveLength(1);
146
+ expect(h.opened[0]).toBe(run.ctx);
147
+ expect(run.notices).toEqual([]);
148
+ expect(h.saved).toEqual([]);
149
+ });
150
+ });