@zenodinh/pi-render 0.1.3 → 0.1.4

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 (2) hide show
  1. package/index.ts +120 -22
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -4,8 +4,11 @@
4
4
  * Why one file: every lane ends in a plain value (a resolver, a transformer, a surface), so boot is a
5
5
  * table of contents — build the registry, compose the two tables, register once per seam, and warm the
6
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.
7
+ * itself, never the session (SA §2). One ordering constraint survives the predecessor's ordering lore:
8
+ * both live seams register synchronously, before the first await, because the host evaluates
9
+ * getMarkdownTransformers() when it constructs a restored message and stores that list on the component
10
+ * — a transformer registered after our engine warm-up is invisible to every message restored before it
11
+ * (issue #13).
9
12
  *
10
13
  * The command surface is empty on purpose (Req 2): the whole host surface is one tool resolver, one
11
14
  * markdown transformer and one `session_start` hook — nothing for the user to invoke.
@@ -25,7 +28,15 @@ import { createLogger } from "./src/core/log.ts";
25
28
  import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
26
29
  import { createRegistry } from "./src/core/registry.ts";
27
30
  import { createSettingsStore } from "./src/core/settings.ts";
28
- import type { ContentPaint, ExtensionApi, Logger, ModuleDescriptor, Registry, Surface } from "./src/core/types.ts";
31
+ import type {
32
+ CodeTheme,
33
+ ContentPaint,
34
+ ExtensionApi,
35
+ Logger,
36
+ ModuleDescriptor,
37
+ Registry,
38
+ Surface,
39
+ } from "./src/core/types.ts";
29
40
  import { type ArtifactCache, createArtifactCache, renderCacheKey } from "./src/renderers/content/artifacts/cache.ts";
30
41
  import { artifactTitle, renderArtifactCard } from "./src/renderers/content/artifacts/cards.ts";
31
42
  import {
@@ -117,12 +128,25 @@ function isArtifactForm(lang: string): lang is DiagramForm {
117
128
  // ported from pi-pretty-tui/src/features/canvas/transformer.ts:134-226 — survives because: fence → card
118
129
  // with a raw fence on failure is the whole artifacts surface; its settings gate and inline pixels do not.
119
130
  /**
120
- * shape: closure returning an object literal — trigger #4, one stateless Surface over the captured cache,
121
- * server and log.
131
+ * The artifacts lane's late-bound collaborators. The surface must exist when the transformer registers
132
+ * (before piRender's first await, see `piRender`), but the cache and server can only be built in the
133
+ * awaited artifacts lane, so the surface reads this holder per render: while a slot is still empty the
134
+ * whole pass is a no-op and every fence stays byte-identical.
122
135
  */
123
- function createArtifactsSurface(cache: ArtifactCache, server: ArtifactServer, log: Logger): Surface {
136
+ interface ArtifactsHolder {
137
+ cache?: ArtifactCache;
138
+ server?: ArtifactServer;
139
+ }
140
+
141
+ /**
142
+ * shape: closure returning an object literal — trigger #4, one stateless Surface over the late-bound
143
+ * holder and the log.
144
+ */
145
+ function createArtifactsSurface(holder: ArtifactsHolder, log: Logger): Surface {
124
146
  /** One fence to one card, or undefined so the raw fence stays byte-identical. */
125
147
  const card = (form: DiagramForm, source: string, width: number, paint: ContentPaint): string | undefined => {
148
+ const { cache, server } = holder;
149
+ if (cache === undefined || server === undefined) return undefined;
126
150
  try {
127
151
  const key = renderCacheKey(form, source, ARTIFACT_MODE, ARTIFACT_WIDTH_CELLS);
128
152
  const result = renderDiagram(form, source, {
@@ -147,6 +171,10 @@ function createArtifactsSurface(cache: ArtifactCache, server: ArtifactServer, lo
147
171
 
148
172
  return {
149
173
  rewrite(markdown, ctx, paint) {
174
+ const { cache, server } = holder;
175
+ // Cold holder: the artifacts lane has not built its cache or bound its server yet. Return the input
176
+ // by reference so the fence walk never runs and every fence stays byte-identical.
177
+ if (cache === undefined || server === undefined) return markdown;
150
178
  const width = panelWidth(ctx.availableWidth);
151
179
  const fenced = mapFencedBlocks(markdown, (lang, code) =>
152
180
  isArtifactForm(lang) ? card(lang, code, width, paint) : undefined,
@@ -177,6 +205,43 @@ function readLinkedFile(href: string): string | undefined {
177
205
  }
178
206
  }
179
207
 
208
+ // ---------------------------------------------------------------------------
209
+ // The lazy code theme — the seam between the synchronous transformer and the awaited engine
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * The host evaluates getMarkdownTransformers() when it constructs a restored message and stores the
214
+ * result on the component, so the transformer must register before piRender's first await — but
215
+ * createShikiEngine() is itself an await and the panel seam is synchronous. This holder bridges the
216
+ * two: the surfaces read `theme` on every render, and the content warm-up lane installs the real theme
217
+ * once shiki resolves. A cold render passes the source through unchanged, which is exactly the code
218
+ * panel's own degrade contract (bodyLines uses highlightSync's output as-is), so no async ever reaches
219
+ * the render path and nothing here throws.
220
+ */
221
+ interface LazyCodeTheme {
222
+ /** The surfaces' theme: the real CodeTheme once warm, a plain passthrough while cold. */
223
+ readonly theme: CodeTheme;
224
+ /** Installs the warmed theme; the content warm-up lane calls this exactly once. */
225
+ warm(theme: CodeTheme): void;
226
+ }
227
+
228
+ // shape: closure returning an object literal — trigger #4, one mutable engine slot behind two delegates.
229
+ function createLazyCodeTheme(): LazyCodeTheme {
230
+ let engine: CodeTheme | undefined;
231
+ return {
232
+ theme: {
233
+ // Cold: plain source lines are createCodeTheme's own no-engine fallback; this never rejects.
234
+ highlight: (code, lang) =>
235
+ engine === undefined ? Promise.resolve(code.split("\n")) : engine.highlight(code, lang),
236
+ // Cold: the exact string the panel's catch branch produces, so a cold panel reads as a plain one.
237
+ highlightSync: (code, lang) => (engine === undefined ? code : engine.highlightSync(code, lang)),
238
+ },
239
+ warm(next) {
240
+ engine = next;
241
+ },
242
+ };
243
+ }
244
+
180
245
  // ---------------------------------------------------------------------------
181
246
  // Lanes — one install per seam, each settled on its own
182
247
  // ---------------------------------------------------------------------------
@@ -187,26 +252,31 @@ function installRows(pi: ExtensionApi, registry: Registry, log: Logger): void {
187
252
  }
188
253
 
189
254
  /**
190
- * The artifacts lane: warm the engines the synchronous fence pass calls, bind the one server, then hand
191
- * the pass back. A failed bind degrades every card to a file:// link, so only a throw costs this slot.
255
+ * The artifacts lane: warm the engines the synchronous fence pass calls, bind the one server, then fill
256
+ * the holder the already-registered surface reads. A failed bind degrades every card to a file:// link,
257
+ * and a throw costs only this slot — the transformer is registered before this lane runs.
192
258
  */
193
- async function installArtifacts(log: Logger, deps: BootDeps): Promise<Surface> {
259
+ async function installArtifacts(log: Logger, deps: BootDeps, holder: ArtifactsHolder): Promise<void> {
194
260
  await (deps.warmArtifactEngines ?? warmup)(log);
195
261
  const server = (deps.createArtifactServer ?? ((logger: Logger) => createArtifactServer({ log: logger })))(log);
196
262
  await server.start();
197
- return createArtifactsSurface(createArtifactCache({ cacheDir: deps.cacheDir, log }), server, log);
263
+ holder.cache = createArtifactCache({ cacheDir: deps.cacheDir, log });
264
+ holder.server = server;
198
265
  }
199
266
 
200
- /** Region 2: code theme, five surfaces, one transformer — the only registerMarkdownTransformer call. */
201
- async function installContent(
267
+ /**
268
+ * Region 2's synchronous half: five surfaces over lazy holders, then the one registerMarkdownTransformer
269
+ * call. It runs before piRender's first await, so the host's restored-message construction sees the
270
+ * transformer (see `piRender`); the shiki theme and the artifact cache/server stay cold until the
271
+ * awaited lanes fill them.
272
+ */
273
+ function registerContentSeam(
202
274
  pi: ExtensionApi,
203
275
  registry: Registry,
204
276
  log: Logger,
205
- artifacts: Surface | undefined,
206
- ): Promise<void> {
207
- // The panel seam is synchronous while every engine start-up is not, so the highlight core is warmed
208
- // here, before this registration can be reached.
209
- const codeTheme = createCodeTheme(await createShikiEngine(log), { registry, log });
277
+ codeTheme: CodeTheme,
278
+ artifacts: ArtifactsHolder,
279
+ ): void {
210
280
  const surfaces: ContentSurfaces = {
211
281
  table,
212
282
  codePanel: createCodePanel(codeTheme),
@@ -215,7 +285,7 @@ async function installContent(
215
285
  projectDir: PROJECT_DIR,
216
286
  log: (line) => log.logLine(SCOPE, line),
217
287
  }),
218
- artifacts,
288
+ artifacts: createArtifactsSurface(artifacts, log),
219
289
  };
220
290
  // The host's markdown theme is live per call, so its closures are captured once, here.
221
291
  pi.registerMarkdownTransformer(
@@ -223,6 +293,11 @@ async function installContent(
223
293
  );
224
294
  }
225
295
 
296
+ /** Region 2's awaited half: fill the lazy theme once shiki resolves; renders before then already returned. */
297
+ async function warmCodeTheme(registry: Registry, log: Logger, lazy: LazyCodeTheme): Promise<void> {
298
+ lazy.warm(createCodeTheme(await createShikiEngine(log), { registry, log }));
299
+ }
300
+
226
301
  /** shape: none — one guarded call; a lane reports itself and boot carries on. */
227
302
  async function settle<T>(lane: string, log: Logger, install: () => T | Promise<T>): Promise<T | undefined> {
228
303
  try {
@@ -233,6 +308,18 @@ async function settle<T>(lane: string, log: Logger, install: () => T | Promise<T
233
308
  }
234
309
  }
235
310
 
311
+ /**
312
+ * The synchronous twin of {@link settle} for the two lanes that must register before piRender's first
313
+ * await: a throw is logged and contained exactly as in settle, but nothing yields the event loop.
314
+ */
315
+ function settleSync(lane: string, log: Logger, install: () => void): void {
316
+ try {
317
+ install();
318
+ } catch (error) {
319
+ log.logOnce(`boot:${lane}`, SCOPE, `${lane} lane failed: ${messageOf(error)}`);
320
+ }
321
+ }
322
+
236
323
  /** shape: none — one message extraction for a caught value of unknown type. */
237
324
  function messageOf(error: unknown): string {
238
325
  return error instanceof Error ? error.message : String(error);
@@ -260,10 +347,21 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
260
347
  const registry = createRegistry(createSettingsStore({ path: deps.settingsPath, logger: log }), log);
261
348
  for (const descriptor of MODULES) registry.defineModule(descriptor);
262
349
 
263
- // Lane by lane: the artifacts pass resolves before content, because the transformer dispatches it.
264
- await settle("rows", log, () => installRows(pi, registry, log));
265
- const artifacts = await settle("artifacts", log, () => installArtifacts(log, deps));
266
- await settle("content", log, () => installContent(pi, registry, log, artifacts));
350
+ // Both live seams register synchronously, before piRender's first await. The host evaluates
351
+ // getMarkdownTransformers() when it constructs a restored message (session rejoin builds
352
+ // AssistantMessageComponent while our engines are still warming) and stores that list on the
353
+ // component, so a transformer registered after an await is invisible to every message restored in
354
+ // the window — its tables render host-default for the whole session (issue #13). The rows table is
355
+ // synchronous already; the content seam takes lazy holders for the shiki theme and the artifact
356
+ // cache/server, which the awaited lanes below fill in.
357
+ const codeTheme = createLazyCodeTheme();
358
+ const artifacts: ArtifactsHolder = {};
359
+ settleSync("rows", log, () => installRows(pi, registry, log));
360
+ settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts));
361
+
362
+ // The fallible, environment-bound lanes run next and only fill the holders above.
363
+ await settle("artifacts", log, () => installArtifacts(log, deps, artifacts));
364
+ await settle("content-warm", log, () => warmCodeTheme(registry, log, codeTheme));
267
365
 
268
366
  // Collapse-first reading model (owner decision): every tool row starts on one line.
269
367
  // The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
package/package.json CHANGED
@@ -67,5 +67,5 @@
67
67
  "typescript": "^7.0.2",
68
68
  "vitest": "^5.0.3"
69
69
  },
70
- "version": "0.1.3"
70
+ "version": "0.1.4"
71
71
  }