@zenodinh/pi-render 0.1.6 → 0.1.7

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/index.ts CHANGED
@@ -28,8 +28,6 @@ import { createLogger } from "./src/core/log.ts";
28
28
  import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
29
29
  import { createRegistry } from "./src/core/registry.ts";
30
30
  import { createSettingsStore } from "./src/core/settings.ts";
31
- import { createThemeHolder } from "./src/core/theme-holder.ts";
32
- import type { ThemeHolder } from "./src/core/types/paint.ts";
33
31
  import type {
34
32
  CodeTheme,
35
33
  ContentPaint,
@@ -284,7 +282,6 @@ function registerContentSeam(
284
282
  log: Logger,
285
283
  codeTheme: CodeTheme,
286
284
  artifacts: ArtifactsHolder,
287
- holder: ThemeHolder,
288
285
  ): void {
289
286
  const surfaces: ContentSurfaces = {
290
287
  heading,
@@ -297,11 +294,10 @@ function registerContentSeam(
297
294
  }),
298
295
  artifacts: createArtifactsSurface(artifacts, log),
299
296
  };
300
- // The host's markdown theme is live per call, so its closures are captured once, here. The band's ink
301
- // is the one input that arrives late, through the holder: the paint reads it per render.
297
+ // The host's markdown theme is live per call, so its closures are captured once, here.
302
298
  pi.registerMarkdownTransformer(
303
299
  createContentTransformer(registry, surfaces, {
304
- paint: createContentPaint(getMarkdownTheme(), "theme", holder),
300
+ paint: createContentPaint(getMarkdownTheme()),
305
301
  log,
306
302
  }),
307
303
  );
@@ -370,11 +366,8 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
370
366
  // cache/server, which the awaited lanes below fill in.
371
367
  const codeTheme = createLazyCodeTheme();
372
368
  const artifacts: ArtifactsHolder = {};
373
- // The band's theme reach: one long-lived handle, fed from the per-event UI context below and read
374
- // per render by the content paint, so a theme switch repaints with no restart.
375
- const themeHolder = createThemeHolder();
376
369
  settleSync("rows", log, () => installRows(pi, registry, log));
377
- settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts, themeHolder));
370
+ settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts));
378
371
 
379
372
  // The fallible, environment-bound lanes run next and only fill the holders above.
380
373
  await settle("artifacts", log, () => installArtifacts(log, deps, artifacts));
@@ -383,10 +376,8 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
383
376
  // Collapse-first reading model (owner decision): every tool row starts on one line.
384
377
  // The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
385
378
  // interactive mode implements it (the runner's non-UI fallback is a no-op stub), so the
386
- // call rides session_start: real in TUI, harmless in text/rpc. The same event carries the
387
- // live theme handle; the feed tolerates a context with no UI and a UI with no theme.
379
+ // call rides session_start: real in TUI, harmless in text/rpc.
388
380
  pi.on("session_start", (_event, ctx) => {
389
- themeHolder.set(ctx.ui?.theme);
390
381
  ctx.ui?.setToolsExpanded?.(false);
391
382
  });
392
383
  }
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.6"
70
+ "version": "0.1.7"
71
71
  }
package/src/core/paint.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { HostTheme, MarkdownTheme } from "./types/host.ts";
18
- import type { ContentPaint, RowPaint, ThemeHolder } from "./types/paint.ts";
18
+ import type { ContentPaint, RowPaint } from "./types/paint.ts";
19
19
 
20
20
  /** Host `fg` throws on a token the active theme lacks (`Theme.tokenAnsi`); a row degrades, never dies. */
21
21
  function fgOrPlain(theme: HostTheme, key: string, text: string): string {
@@ -70,79 +70,39 @@ export function createRowPaint(theme: HostTheme): RowPaint {
70
70
  }
71
71
 
72
72
  /**
73
- * How the header band fills its cell: `"theme"` reads the ACTIVE theme's own background token (the
74
- * default, and reverse video on any frame where no theme is reachable), `"reverse"` swaps the cell's
75
- * ink and background (SGR reverse video), `"none"` leaves the bare header. Picked once at
76
- * construction, so a side-by-side can sweep it.
73
+ * The header band: the header ink with its cell's foreground and background swapped — SGR reverse video.
74
+ * Reversal is what makes the band safe on any theme: it preserves the theme's own ink-on-background
75
+ * contrast ratio instead of pairing an ink with a fill that was picked for something else, and it keeps
76
+ * the band theme-driven, because the header ink is the theme's own. No token, no construction-time
77
+ * choice (owner ruling 2026-10-09; issue #52).
77
78
  */
78
- export type BandStyle = "theme" | "reverse" | "none";
79
-
80
79
  /**
81
- * SGR reverse video, on and off. The band's fallback and the ONE escape pair that originates here
82
- * rather than in a theme: reverse video needs no token, so it survives a frame rendered before any
83
- * theme is reachable — a restore, or a session whose event context carried no theme.
80
+ * SGR reverse video, on and off — the ONE escape pair that originates here rather than in a theme.
81
+ * Reverse video needs no token, so it works on any theme and on a frame rendered before one arrives.
84
82
  */
85
83
  const REVERSE_ON = "\x1b[7m";
86
84
  const REVERSE_OFF = "\x1b[27m";
87
85
 
88
- /**
89
- * The band's background token. The VALUE is never hard-coded here: it is read from the active theme,
90
- * so the owner overrides the band by editing one string in their own theme document. `selectedBg` is
91
- * a `colors` token both shipped themes carry and the host already keeps in its background family.
92
- */
93
- const BAND_BG_TOKEN = "selectedBg";
94
-
95
- /** The fallback band: reverse video over the header ink, needing no theme at all. */
86
+ /** The band: reverse video over the header ink — the cell's own ink and background swapped. */
96
87
  function reverseBand(text: string): string {
97
88
  return `${REVERSE_ON}${text}${REVERSE_OFF}`;
98
89
  }
99
90
 
100
- /**
101
- * The band's ink from the live theme — read per call, so a theme switch repaints with no restart and
102
- * no reload. `undefined` is the caller's fallback signal, and covers all three normal states: no
103
- * theme was fed, the active theme lacks the token (the host throws `Unknown theme color`), and the
104
- * host's theme proxy is read before initialisation.
105
- */
106
- function themeBand(holder: ThemeHolder | undefined, text: string): string | undefined {
107
- const theme = holder?.get();
108
- if (theme === undefined) return undefined;
109
- try {
110
- return theme.bg(BAND_BG_TOKEN, text);
111
- } catch {
112
- return undefined;
113
- }
114
- }
115
-
116
91
  /**
117
92
  * Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
118
93
  *
119
94
  * The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
120
95
  * `code` closure (the `mdCode` token, identical to `accent` in both stock themes) for the header ink.
121
- * `bandStyle` picks the header band — `"theme"` (the default) reads the active theme's background token
122
- * through `holder`, `"reverse"` forces the theme-independent fallback, `"none"` leaves the header bare
123
- * — so the band is a construction-time choice whose ink is read per frame.
124
96
  */
125
97
  // shape: closure returning an object literal — trigger #4, eight stateless content roles over the
126
98
  // captured markdown theme, band style and theme holder.
127
- export function createContentPaint(
128
- mdTheme: MarkdownTheme,
129
- bandStyle: BandStyle = "theme",
130
- holder?: ThemeHolder,
131
- ): ContentPaint {
99
+ export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
132
100
  // The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
133
101
  // degrades on its own, so a theme missing one still stamps whatever the other gives.
134
102
  const header = (text: string): string => {
135
103
  const inked = tryStyle(mdTheme.code, text) ?? text;
136
104
  return tryStyle(mdTheme.bold, inked) ?? inked;
137
105
  };
138
- // shape: dispatch object — trigger #1, three band styles on one discriminator, handlers stateless;
139
- // `Record` makes a fourth style a compile error rather than a silently bare header.
140
- const bands: Record<BandStyle, (inked: string) => string> = {
141
- theme: (inked) => themeBand(holder, inked) ?? reverseBand(inked),
142
- reverse: reverseBand,
143
- none: (inked) => inked,
144
- };
145
- const band = bands[bandStyle];
146
106
  return {
147
107
  rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
148
108
  quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
@@ -151,6 +111,6 @@ export function createContentPaint(
151
111
  strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
152
112
  em: (text) => tryStyle(mdTheme.italic, text) ?? text,
153
113
  header,
154
- headerBand: (text) => band(header(text)),
114
+ headerBand: (text) => reverseBand(header(text)),
155
115
  };
156
116
  }
@@ -90,6 +90,12 @@ export interface RenderContext {
90
90
  isError?: boolean;
91
91
  /** True while the result is still streaming; the row must stay cheap. Optional. */
92
92
  isPartial?: boolean;
93
+ /**
94
+ * Horizontal padding the host's own shell would have applied, in character cells. Optional: a row
95
+ * that asks for the "self" shell applies it itself, and the pinned host's render context does not
96
+ * carry it — so an absent value means no padding, never a guessed one.
97
+ */
98
+ outputPad?: number;
93
99
  }
94
100
 
95
101
  /** One result content block; only text blocks are rendered. Structural host shape. */
@@ -89,9 +89,9 @@ function failureHead(lines: readonly string[]): string {
89
89
  function createFallbackSpec(toolName: string, log: Logger): RowSpec<FallbackModel> {
90
90
  return {
91
91
  buildCall(args, ctx): CallModel {
92
- // The runtime's argsComplete gate hands buildCall {} until the host says arguments are
93
- // final, so zero keys covers a gated row and a genuinely argument-less tool alike —
94
- // neither is an unexpected shape, so neither logs.
92
+ // The runtime's args gate hands buildCall {} until a host flag says the arguments are final,
93
+ // so zero keys covers a gated row and a genuinely argument-less tool alike — neither is an
94
+ // unexpected shape, so neither logs.
95
95
  if (Object.keys(args).length === 0) return { title: toolName };
96
96
  const hint = hintOf(args);
97
97
  if (hint === undefined) {
@@ -31,8 +31,13 @@ import { createFallbackRenderers, FALLBACK_MODULE } from "./fallback.ts";
31
31
  import { createRowRenderers } from "./runtime.ts";
32
32
  import type { RowSpec } from "./types.ts";
33
33
 
34
- /** The host's own shell frame: chrome belongs to pi-zentui, so a row never asks for a custom one. */
35
- const SHELL = "default";
34
+ /**
35
+ * The shell every row asks the host for: "self" skips the host's Box(1,1) padding rows around our
36
+ * content, so a row is the lines we compose (#51) — which is also why the horizontal pad is ours to
37
+ * apply (runtime.ts reads it off the ctx). The host still prepends one blank line of its own, and
38
+ * closing that last blank is the upstream grouping ask (ADR-0004), not something a renderer reaches.
39
+ */
40
+ const SHELL = "self";
36
41
  const SCOPE = "tool.resolve";
37
42
 
38
43
  /** The resolver's injected collaborators — the row paint factory, plus the keyed diagnostic sink. */
@@ -64,7 +69,7 @@ function withoutComponent(ctx: RenderContext): RenderContext {
64
69
  }
65
70
 
66
71
  /** A chain answer the host can draw: either slot suffices; a definition with neither is a peer shape the host defaults. */
67
- function hasUsableSlot(base: ToolRenderers | undefined): boolean {
72
+ function hasUsableSlot(base: ToolRenderers | undefined): base is ToolRenderers {
68
73
  return base !== undefined && (base.renderCall !== undefined || base.renderResult !== undefined);
69
74
  }
70
75
 
@@ -107,7 +112,13 @@ export function createMasterResolver(
107
112
  const runtime = entry.renderers;
108
113
 
109
114
  return {
110
- renderShell: SHELL,
115
+ // A row the base draws wears the BASE's shell: our "self" would strip the Box(1,1) whose padding
116
+ // and background the base's own lines were composed for. Read once, at resolution — the host
117
+ // binds the shell's container in the component's constructor and never re-parents it
118
+ // (tool-execution.js:52-54, against :200 which only picks the container to fill), so a shell
119
+ // that tracked the registry per frame would fill one container while the tree still held the
120
+ // other. The slots keep polling; the shell cannot.
121
+ renderShell: registry.isEnabled(key) || !hasUsableSlot(base) ? SHELL : base.renderShell,
111
122
  renderCall(args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent {
112
123
  const baseCall = base?.renderCall;
113
124
  if (registry.isEnabled(key) || baseCall === undefined) return runtime.renderCall(args, theme, ctx);
@@ -149,21 +149,38 @@ function logFailure(log: Logger, ctx: RenderContext, err: unknown): void {
149
149
  log.logOnce(key, LOG_SCOPE, `row degraded: ${text}`);
150
150
  }
151
151
 
152
+ /** The horizontal pad a row applies itself, in cells; a host that hands none means no padding. */
153
+ function padOf(ctx: RenderContext): number {
154
+ const pad = ctx.outputPad;
155
+ return typeof pad === "number" && Number.isFinite(pad) && pad > 0 ? Math.floor(pad) : 0;
156
+ }
157
+
158
+ /**
159
+ * A text component wearing this runtime's pad. The pad arrives on the ctx (once per invocation) while
160
+ * the indent is applied in render(width) (once per frame), so the component carries it across the two —
161
+ * and `setPad` is thereby the marker that says "this one is ours to reuse". The host spells its own
162
+ * padding setter `setPaddingX`, so a component the base renderer left in the slot never answers it.
163
+ */
164
+ interface PaddedText extends UiComponent {
165
+ /** Replaces the displayed body. Required: the reuse protocol the host's lastComponent enables. */
166
+ setText(body: string): void;
167
+ /** Re-reads the row's indent, so a host padding change lands on the next frame. Required. */
168
+ setPad(pad: number): void;
169
+ }
170
+
171
+ /** `in`-narrowed, never cast: a recorded component is the host's until it answers this protocol. */
172
+ function isPaddedText(component: UiComponent): component is PaddedText {
173
+ return "setPad" in component && typeof component.setPad === "function";
174
+ }
175
+
152
176
  /** shape: closure returning an object literal — trigger #4: one stored text behind render/invalidate. */
153
- function textFor(ctx: RenderContext, body: string): UiComponent {
154
- const reuse = ctx.lastComponent;
155
- if (reuse !== undefined) {
156
- const setText = reuse.setText;
157
- if (setText !== undefined) {
158
- setText.call(reuse, body);
159
- return reuse;
160
- }
161
- }
177
+ function textComponent(body: string): UiComponent {
162
178
  let current = body;
163
179
  return {
164
180
  render(_width: number): string[] {
165
181
  // One entry per line; an empty body still yields one line, so a degraded row never vanishes.
166
182
  // Width is the component's own: renderResult receives none, so nothing here is width-keyed.
183
+ // The offer minus the indent is handed down by the pad wrapper around this component.
167
184
  return current.split("\n");
168
185
  },
169
186
  invalidate(): void {
@@ -175,9 +192,64 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
175
192
  };
176
193
  }
177
194
 
195
+ /**
196
+ * The pad wrapper: every line the inner component draws takes the indent, and the inner component gets
197
+ * the offer minus it.
198
+ *
199
+ * Both halves are ours because a row asks the host for the "self" shell (#51), and that shell skips the
200
+ * Box(1,1) which used to indent each line and render its child at `width - paddingX * 2` (pi-tui
201
+ * box.js:83 → :91 → :115). The budget half is what a reused host `Text` needs: it wraps at the width it
202
+ * is given (pi-tui text.js:53-56), so a full-width title would wrap at the whole offer and then be
203
+ * pushed one column past it by the indent.
204
+ *
205
+ * shape: closure returning an object literal — trigger #4: one stored pad behind render/setPad.
206
+ */
207
+ function padComponent(inner: UiComponent, pad: number): PaddedText {
208
+ let indent = pad;
209
+ return {
210
+ render(width: number): string[] {
211
+ const lines = inner.render(Math.max(0, width - indent));
212
+ if (indent === 0) return lines;
213
+ const spaces = " ".repeat(indent);
214
+ return lines.map((line) => spaces + line);
215
+ },
216
+ invalidate(): void {
217
+ inner.invalidate();
218
+ },
219
+ setText(body: string): void {
220
+ inner.setText?.(body);
221
+ },
222
+ setPad(value: number): void {
223
+ indent = value;
224
+ },
225
+ };
226
+ }
227
+
228
+ function textFor(ctx: RenderContext, body: string): UiComponent {
229
+ const pad = padOf(ctx);
230
+ const reuse = ctx.lastComponent;
231
+ // Only a component this runtime wrapped can be told a new pad, so it is the only one it reuses. A
232
+ // foreign one — the base renderer's, after a module toggle handed the row back — is replaced once;
233
+ // the host records what we return, so every later frame takes this branch.
234
+ if (reuse !== undefined && isPaddedText(reuse)) {
235
+ reuse.setText(body);
236
+ reuse.setPad(pad);
237
+ return reuse;
238
+ }
239
+ // A host text component in the slot is still worth mutating in place: it caches its own frame, and
240
+ // it is the one inner whose wrap the reduced budget reaches.
241
+ const setText = reuse?.setText;
242
+ if (reuse !== undefined && setText !== undefined) {
243
+ setText.call(reuse, body);
244
+ return padComponent(reuse, pad);
245
+ }
246
+ return padComponent(textComponent(body), pad);
247
+ }
248
+
178
249
  /**
179
250
  * The collapsed result slot's component: it draws no lines. The host stacks the call slot and the result
180
- * slot inside one Box, and the Box concatenates each child's lines (pi-tui box.js render), so an empty
251
+ * slot inside the self shell's container, and that container concatenates each child's lines at the whole
252
+ * offer (pi-tui tui.js:129-140 — no padding of its own, which is why the pad is ours, #51), so an empty
181
253
  * array adds nothing — the call line above is the whole collapsed row. Stateless, so one shared instance
182
254
  * serves every row and every frame.
183
255
  *
@@ -235,6 +307,35 @@ function scheduleDecorate(
235
307
  );
236
308
  }
237
309
 
310
+ /**
311
+ * The host sets this the moment a call starts executing (host `core/extensions/types.d.ts`). The repo's
312
+ * declared seam omits it, so the undeclared field is `in`-narrowed before it is read — never asserted.
313
+ */
314
+ function isExecutionStarted(ctx: RenderContext): boolean {
315
+ return "executionStarted" in ctx && ctx.executionStarted === true;
316
+ }
317
+
318
+ /**
319
+ * The runtime's one args rule, three arms — each one a host writer that settles when the arguments
320
+ * can be trusted:
321
+ *
322
+ * - `argsComplete === true`: the host flips it on the live streaming path (`setArgsComplete`).
323
+ * - `isPartial === false`: the host starts a component at `isPartial = true` and clears it only in
324
+ * `updateResult` (tool-execution.js:26/:130-132), so a false means a result was delivered and the
325
+ * arguments behind it are final — with one exception: on the abort path the host restores the row
326
+ * through `updateResult({errorMessage}, isError: true)`, whose default `isPartial = false` lands
327
+ * with the arguments still incomplete (interactive-mode.js:2873-2878 live / :3256 restore), so the
328
+ * arm may title from partial args. Strictly `=== false`, so an absent flag opens nothing.
329
+ * - `executionStarted === true`: the host starts executing a call only after its arguments are complete.
330
+ *
331
+ * A call rebuilt from a stored session runs none of those writers: it is constructed from the stored
332
+ * toolCall and `updateResult(message)`d (interactive-mode.js:3238-3273), so `argsComplete` and
333
+ * `executionStarted` stay false for its whole life and only the middle arm titles its row.
334
+ */
335
+ function argsUsable(ctx: RenderContext): boolean {
336
+ return ctx.argsComplete === true || ctx.isPartial === false || isExecutionStarted(ctx);
337
+ }
338
+
238
339
  export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory; log: Logger }): ToolRenderers {
239
340
  const modelFor = (result: ToolResult, ctx: RenderContext): unknown => {
240
341
  const content = Array.isArray(result.content) ? result.content : undefined;
@@ -263,8 +364,11 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
263
364
  const renderCall = (args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent => {
264
365
  try {
265
366
  const paint = deps.paint(theme);
266
- // A partial argument set is not a title: buildCall sees {} until the host says argsComplete.
267
- const model = spec.buildCall(ctx.argsComplete === true ? args : {}, ctx);
367
+ // A partial argument set is not a title. The spec is handed the args and the gate resolved
368
+ // together, so its own argsComplete check cannot shut the gate the runtime just opened; with
369
+ // the args unusable it sees {} behind a shut gate — a half-streamed value never titles.
370
+ const usable = argsUsable(ctx);
371
+ const model = spec.buildCall(usable ? args : {}, usable ? { ...ctx, argsComplete: true } : ctx);
268
372
  // The name carries the call's three-state ink, and the order is the mapping's point: a failed call
269
373
  // is never merely pending, so isError outranks isPartial; with neither flag the call is settled.
270
374
  const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
@@ -1,44 +0,0 @@
1
- /**
2
- * theme-holder.ts — the band's reach into the live theme: fed per event, read per frame.
3
- *
4
- * Boundary: the extension API carries no theme member, so the handle arrives on the per-event UI
5
- * context (`ctx.ui.theme`, a getter over the host's theme proxy) and the composition root feeds it
6
- * here; the content paint reads it per invocation. Nothing here emits an escape and nothing here
7
- * logs — an unfed holder is a normal state whose only consequence is the band's documented fallback.
8
- *
9
- * shape: closure returning an object literal — trigger #4, one mutable handle slot behind set/get.
10
- */
11
-
12
- import type { HostBackgroundTheme } from "./types/host.ts";
13
- import type { ThemeHolder } from "./types/paint.ts";
14
-
15
- /**
16
- * The feed crosses a host boundary, so it is checked rather than annotated. The member is READ, never
17
- * probed with `in`: the host's live theme is `new Proxy({}, { get })` (`theme.js:515-522`), so `in`
18
- * answers false even for a working theme, and any read before `initTheme()` throws. Both observed by
19
- * executed probe against the installed host 1.0.4, 2026-10-08. Neither is an error to report: no
20
- * theme is a normal state, and the band's fallback is its only consequence.
21
- */
22
- function isBackgroundTheme(value: unknown): value is HostBackgroundTheme {
23
- if (typeof value !== "object" || value === null) return false;
24
- try {
25
- return typeof Reflect.get(value, "bg") === "function";
26
- } catch {
27
- return false;
28
- }
29
- }
30
-
31
- /** One handle slot: filled by the session-start feed, read by every band. */
32
- export function createThemeHolder(): ThemeHolder {
33
- // The handle is what is kept, never a resolved colour: caching an escape would freeze the band on
34
- // the theme selected at load, which is the one defect this seam exists to avoid.
35
- let current: HostBackgroundTheme | undefined;
36
- return {
37
- set(theme) {
38
- current = isBackgroundTheme(theme) ? theme : undefined;
39
- },
40
- get() {
41
- return current;
42
- },
43
- };
44
- }