pi-zentui 0.29.1 → 0.29.3

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.
@@ -776,7 +776,7 @@ Rail, Tree, and Streaming share one private `AssistantMessageComponent` wrapper.
776
776
 
777
777
  Rail parses each native contiguous thinking run and shows every label in that run. Tree shows the latest five in each run; neither aggregates across intervening text or tool blocks. Both follow Pi's thinking visibility. Labels come from headings, top-level list items, and blank-line-separated prose. Complete strict 7-bit CSI SGR styling is stripped before parsing; every other terminal control, unsafe or unstructured content, malformed or over-limit input, unterminated fences/math, and unsupported structure leave that complete run native. Fenced code, Mermaid, display math, and indented nested content remain opaque bodies.
778
778
 
779
- Each selected SGR-free label is rendered as Markdown by a fresh Pi `Markdown` with the host child's exact theme, default `thinkingText`/italic style, and transform options. Native emphasis, code, links, HTML, LaTeX, and custom theme/transform callbacks therefore remain authoritative. Host horizontal padding is applied externally. Each label is exactly one terminal row; Pi TUI's ANSI/OSC/grapheme-aware width utilities crop the first rendered row and reserve one cell for `…` only when required. Empty, image, non-text, impossible-width, or throwing output restores the whole native run. Connectors are separate from Markdown and call the current `theme.fg("accent", connector)` every render, so custom themes directly control their appearance. Visible forms are:
779
+ Each selected SGR-free label is rendered as Markdown by a fresh Pi `Markdown` with the host child's exact theme, default `thinkingText`/italic style, and transform options. Native emphasis, code, links, HTML, LaTeX, and custom theme/transform callbacks therefore remain authoritative. Host horizontal padding is applied externally. Each label is exactly one terminal row; Pi TUI's ANSI/OSC/grapheme-aware width utilities crop the first rendered row and reserve one cell for `…` only when required. Empty, image, non-text, impossible-width, or throwing output restores the whole native run. Connectors are separate from Markdown and call the current `theme.fg("accent", connector)` when building a layout, so custom themes directly control their appearance. Successful layouts are reused while width and connector-theme identity are unchanged; a width or theme identity change or invalidation refreshes them. Same-object theme mutations require invalidation. Visible forms are:
780
780
 
781
781
  ```text
782
782
  │ Thinking ┆ Thinking
@@ -336,9 +336,9 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
336
336
  return undefined;
337
337
  }
338
338
  };
339
- // Layered visibility is read-only recognition; installation ownership above stays exclusive.
340
- // Data, demand, and timer consumers read the visible editor through these two helpers; anything
341
- // that installs, restores, or mutates host UI must keep using ownsInstalledEditorFactory().
339
+ // Layered visibility does not grant installation ownership: editor install/restore stays exclusive.
340
+ // Data, demand, and timer consumers use these helpers; Working-line placement additionally
341
+ // requires focused, rendered border capability before its controller hides its own host row.
342
342
  // Ownership counts before Pi constructs its editor; layered visibility requires the recorded
343
343
  // instance to remain reachable through the current factory.
344
344
  const layeredVisibleEditor = () => layeredEditor.editorVisibleThrough(currentEditorFactory());
@@ -402,17 +402,13 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
402
402
  undefined,
403
403
  (ctx) => {
404
404
  if (!activeTuiContext || ctx.ui !== activeTuiContext.ui) return false;
405
- if (!ownsInstalledEditorFactory()) {
406
- activeEditor = undefined;
407
- return false;
408
- }
405
+ // Factory visibility alone is insufficient: the current focused editor must have
406
+ // rendered safe border geometry before the separately owned Working row can hide.
409
407
  return Boolean(
410
- activeEditor &&
411
- sessionLifecycle.isCurrent(activeEditor.generation) &&
412
- activeEditor.factory === installedEditorFactory &&
408
+ sessionLifecycle.isCurrent() &&
413
409
  effectiveEditorEnabled() &&
414
410
  currentConfig.components.editor.style !== "accent-rail" &&
415
- activeEditor.editor.canEmbedWorkingLineBorder(),
411
+ visibleEditor()?.canEmbedWorkingLineBorder(),
416
412
  );
417
413
  },
418
414
  );
@@ -939,10 +935,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
939
935
  return observed;
940
936
  };
941
937
 
942
- // Exclusive: gates mutation of host UI such as the Working row.
943
- const isActiveEditor = (editor: PolishedEditor | WrappedPolishedEditor, generation: number) =>
944
- sessionLifecycle.isCurrent(generation) && activeEditor?.editor === editor;
945
- // Read-only: also true for an editor still visible beneath a foreign wrapper.
938
+ // Also true for an editor still visible beneath a foreign wrapper; installation stays exclusive.
946
939
  const isVisibleEditor = (editor: PolishedEditor | WrappedPolishedEditor, generation: number) =>
947
940
  sessionLifecycle.isCurrent(generation) && visibleEditor() === editor;
948
941
 
@@ -950,7 +943,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
950
943
  editor: PolishedEditor | WrappedPolishedEditor,
951
944
  generation: number,
952
945
  ) => {
953
- if (!isActiveEditor(editor, generation) || !activeTuiContext || !workingLineSessionReady)
946
+ if (!isVisibleEditor(editor, generation) || !activeTuiContext || !workingLineSessionReady)
954
947
  return;
955
948
  if (!host.skinOnly) workingLine.reconcile(activeTuiContext);
956
949
  if (workingLine.isAvailable()) requestEditorRepaint();
@@ -966,10 +959,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
966
959
  const editorWorkingLineFrame = (
967
960
  editor: PolishedEditor | WrappedPolishedEditor,
968
961
  generation: number,
969
- ) =>
970
- !host.skinOnly && isActiveEditor(editor, generation)
971
- ? workingLine.currentWorkingLineFrame()
972
- : undefined;
962
+ ) => {
963
+ if (host.skinOnly || !sessionLifecycle.isCurrent(generation)) return undefined;
964
+ // Even a stale instance render must release a row whose current border disappeared.
965
+ const frame = workingLine.currentWorkingLineFrame();
966
+ return isVisibleEditor(editor, generation) ? frame : undefined;
967
+ };
973
968
 
974
969
  /** Records a freshly constructed editor as owned (exclusive) or visible beneath a foreign wrapper. */
975
970
  const registerConstructedEditor = (
@@ -8,9 +8,9 @@
8
8
  * and redraw requests. The record is valid only while the outer factory observed at construction is
9
9
  * still the current editor factory, so any replacement of the chain invalidates it.
10
10
  *
11
- * Visibility is only evidence that a wrapper constructed the editor, not that its rows reach the
12
- * screen, so surfaces that mutate host UI (for example hiding the native Working row) must keep
13
- * requiring exclusive installation ownership instead of consulting this record.
11
+ * Construction alone cannot authorize hiding the Working row: its controller additionally requires
12
+ * a focused editor that has rendered safe border geometry. A wrapper that renders then discards those
13
+ * rows remains undetectable; supporting such wrappers requires an explicit host visibility contract.
14
14
  */
15
15
  export class LayeredEditorConsumer<Factory, Editor = unknown> {
16
16
  private record?: {
@@ -172,9 +172,11 @@ function renderTopLeft(
172
172
  metadata: MinimalistEditorMetadata,
173
173
  uiTheme: Theme,
174
174
  config: ZentuiConfig,
175
+ renderBorder: (text: string) => string,
175
176
  includeSessionName = true,
176
177
  ): string {
177
178
  const source = config.components.editor.colorSource;
179
+ const separator = safeThemeFg(uiTheme, "muted", " · ");
178
180
  const bashMode = bashModeLabel(inputText);
179
181
  const parts: string[] = [];
180
182
  if (bashMode) {
@@ -206,16 +208,20 @@ function renderTopLeft(
206
208
  ? sanitizeEditorMetadataText(metadata.sessionName ?? "")
207
209
  : "";
208
210
  if (config.components.editor.styles.minimalist.showSessionName && sessionName) {
209
- parts.push(
210
- renderStyleForSource(
211
- uiTheme,
212
- source,
213
- componentColor(config, "editor", "sessionName"),
214
- sessionName,
215
- ),
211
+ return joinStyled(
212
+ [
213
+ joinStyled(parts, separator),
214
+ renderStyleForSource(
215
+ uiTheme,
216
+ source,
217
+ componentColor(config, "editor", "sessionName"),
218
+ sessionName,
219
+ ),
220
+ ],
221
+ metadata.workingLineFrame ? renderBorder(" ─ ") : separator,
216
222
  );
217
223
  }
218
- return joinStyled(parts, safeThemeFg(uiTheme, "muted", " · "));
224
+ return joinStyled(parts, separator);
219
225
  }
220
226
 
221
227
  function renderTopRight(
@@ -448,6 +454,7 @@ function renderTemplateBuiltin(
448
454
  { cwd: "", sessionName: metadata.sessionName },
449
455
  uiTheme,
450
456
  explicitConfig,
457
+ renderBorder,
451
458
  );
452
459
  case "turn_duration":
453
460
  return renderTopLeft(
@@ -455,6 +462,7 @@ function renderTemplateBuiltin(
455
462
  { cwd: "", agentDurationMs: metadata.agentDurationMs, agentActive: metadata.agentActive },
456
463
  uiTheme,
457
464
  explicitConfig,
465
+ renderBorder,
458
466
  );
459
467
  case "model":
460
468
  isolated.modelLabel = metadata.modelLabel;
@@ -693,8 +701,8 @@ export function renderMinimalistFrame({
693
701
  if (!count || !/^[1-9]\d*$/.test(count)) return "";
694
702
  return safeThemeFg(uiTheme, "muted", `${direction === "above" ? "↑" : "↓"} ${count} more`);
695
703
  };
696
- const topMetadata = renderTopLeft(inputText, metadata, uiTheme, config);
697
- const topOperational = renderTopLeft(inputText, metadata, uiTheme, config, false);
704
+ const topMetadata = renderTopLeft(inputText, metadata, uiTheme, config, renderBorder);
705
+ const topOperational = renderTopLeft(inputText, metadata, uiTheme, config, renderBorder, false);
698
706
  const topViewport = viewportLabel("above", viewport?.above);
699
707
  const topLeft = joinStyled([topViewport, topMetadata], separator);
700
708
  const topRightBudget = Math.max(0, width - 8 - visibleWidth(topLeft));
@@ -775,6 +783,7 @@ export function renderMinimalistFrame({
775
783
  { cwd: "", workingLineFrame: metadata.workingLineFrame },
776
784
  uiTheme,
777
785
  config,
786
+ renderBorder,
778
787
  false,
779
788
  )
780
789
  : "";
@@ -784,13 +793,18 @@ export function renderMinimalistFrame({
784
793
  if (visibleWidth(prefix) > budget) prefix = operational;
785
794
  if (visibleWidth(prefix) > budget)
786
795
  prefix = truncateToWidth(prefix, Math.max(0, budget), "…");
796
+ const metadataSeparator =
797
+ operational && metadata.workingLineFrame ? renderBorder(" ─ ") : separator;
787
798
  rendered = renderMinimalistTemplate(
788
799
  format,
789
800
  resolve,
790
801
  customNames,
791
- Math.max(0, budget - visibleWidth(prefix) - (prefix ? visibleWidth(separator) : 0)),
802
+ Math.max(
803
+ 0,
804
+ budget - visibleWidth(prefix) - (prefix ? visibleWidth(metadataSeparator) : 0),
805
+ ),
792
806
  );
793
- return joinStyled([prefix, rendered], separator);
807
+ return joinStyled([prefix, rendered], metadataSeparator);
794
808
  }
795
809
  switch (slot) {
796
810
  case "topLeft": {
@@ -218,6 +218,8 @@ function croppedMarkdownRow(markdown: Markdown, source: string, width: number):
218
218
  /** A width-bounded private replacement for one native contiguous thinking run. */
219
219
  export class ThinkingStepsRows implements Component {
220
220
  private readonly title: Markdown;
221
+ // Content and presentation are constructor-fixed; retain only the last successful layout.
222
+ private renderedCache: { width: number; theme: AccentTheme; rows: string[] } | undefined;
221
223
  private readonly labels: Array<{ source: string; markdown: Markdown }>;
222
224
 
223
225
  constructor(
@@ -246,12 +248,23 @@ export class ThinkingStepsRows implements Component {
246
248
  }
247
249
 
248
250
  private renderNative(width: number): string[] {
251
+ this.renderedCache = undefined;
249
252
  this.onPresentation?.(false);
250
253
  return this.native.render(width);
251
254
  }
252
255
 
253
256
  render(width: number): string[] {
254
257
  try {
258
+ let theme: AccentTheme | undefined;
259
+ const cached = this.renderedCache;
260
+ if (cached?.width === width) {
261
+ // The host theme getter is a read-only lookup, including on cache hits.
262
+ theme = this.getTheme();
263
+ if (cached.theme === theme) {
264
+ this.onPresentation?.(true);
265
+ return [...cached.rows];
266
+ }
267
+ }
255
268
  const outer = this.shape.paddingX;
256
269
  const innerWidth = Math.floor(width) - outer * 2;
257
270
  const titleConnector = this.mode === "rail" ? "│ " : "┆ ";
@@ -276,7 +289,7 @@ export class ThinkingStepsRows implements Component {
276
289
  if (!label) return this.renderNative(width);
277
290
  renderedLabels.push({ connector, label });
278
291
  }
279
- const theme = this.getTheme();
292
+ theme ??= this.getTheme();
280
293
  const left = " ".repeat(outer);
281
294
  const right = " ".repeat(outer);
282
295
  const rows = [
@@ -286,14 +299,17 @@ export class ThinkingStepsRows implements Component {
286
299
  ),
287
300
  ];
288
301
  if (!rows.every((row) => visibleWidth(row) <= width)) return this.renderNative(width);
302
+ this.renderedCache = { width, theme, rows };
289
303
  this.onPresentation?.(true);
290
- return rows;
304
+ // Containers and decorators may mutate their returned array.
305
+ return [...rows];
291
306
  } catch {
292
307
  return this.renderNative(width);
293
308
  }
294
309
  }
295
310
 
296
311
  invalidate(): void {
312
+ this.renderedCache = undefined;
297
313
  this.onInvalidate?.();
298
314
  this.native.invalidate();
299
315
  this.title.invalidate();
@@ -54,7 +54,7 @@ function isNativeMarkdown(renderer: unknown): boolean {
54
54
 
55
55
  const sanitizedMarkdownCache = new WeakMap<
56
56
  object,
57
- { lines: unknown; text: string; options: unknown }
57
+ { lines: unknown; sourceText: string; text: string; options: unknown }
58
58
  >();
59
59
 
60
60
  // Framing eligibility is deliberately narrower than the source trust boundary.
@@ -74,7 +74,14 @@ function withSanitizedMarkdownSources<T>(instance: unknown, render: (adapted: bo
74
74
  if (!isRecord(options.value)) return;
75
75
  const transform = options.value.transform;
76
76
  if (transform !== undefined && typeof transform !== "function") return;
77
- const source = sanitizeUserMessageSourceText(text.value);
77
+ const cached = sanitizedMarkdownCache.get(value);
78
+ // Source sanitization depends only on the immutable raw string, not on
79
+ // width, theme, options or native output invalidation. Reuse just that
80
+ // result; the guarded native render and its cache checks still run.
81
+ const source =
82
+ cached && cached.sourceText === text.value
83
+ ? cached.text
84
+ : sanitizeUserMessageSourceText(text.value);
78
85
  const safeOptions = {
79
86
  ...options.value,
80
87
  ...(transform
@@ -86,7 +93,6 @@ function withSanitizedMarkdownSources<T>(instance: unknown, render: (adapted: bo
86
93
  }
87
94
  : {}),
88
95
  };
89
- const cached = sanitizedMarkdownCache.get(value);
90
96
  restore.push(() => {
91
97
  Object.defineProperty(value, "text", text);
92
98
  Object.defineProperty(value, "options", options);
@@ -95,6 +101,7 @@ function withSanitizedMarkdownSources<T>(instance: unknown, render: (adapted: bo
95
101
  // them normally, and cleanup needs no retained component references.
96
102
  sanitizedMarkdownCache.set(value, {
97
103
  lines: value.cachedText === source ? value.cachedLines : undefined,
104
+ sourceText: text.value,
98
105
  text: source,
99
106
  options: options.value,
100
107
  });
@@ -172,18 +179,20 @@ function nativeMarkdown(instance: PatchableUserMessagePrototype):
172
179
  }
173
180
  const children = direct;
174
181
  if (!Array.isArray(children) || children.length !== 1) return undefined;
175
- const box = children[0];
176
- if (!isRecord(box) || !Array.isArray(box.children) || box.children.length !== 1) return undefined;
177
- const renderer = box.children[0];
182
+ // Pi >=1.0 holds the Markdown directly (it pads/colors itself); older Pi
183
+ // wrapped it in a Box. Accept both shapes.
184
+ let renderer = children[0];
185
+ const direct1 = isNativeMarkdown(renderer);
186
+ if (isRecord(renderer) && !direct1) {
187
+ const box = renderer;
188
+ if (!Array.isArray(box.children) || box.children.length !== 1) return undefined;
189
+ renderer = box.children[0];
190
+ }
178
191
  if (!isNativeMarkdown(renderer)) return undefined;
179
192
  const child = renderer as unknown as Record<string, unknown>;
180
- if (
181
- typeof child.text !== "string" ||
182
- child.paddingX !== 0 ||
183
- child.paddingY !== 0 ||
184
- !isRecord(child.theme)
185
- )
186
- return undefined;
193
+ if (typeof child.text !== "string" || !isRecord(child.theme)) return undefined;
194
+ // Box-wrapped (older Pi) Markdown must be unpadded; Pi >=1.0 pads it itself.
195
+ if (!direct1 && (child.paddingX !== 0 || child.paddingY !== 0)) return undefined;
187
196
  for (const key of [
188
197
  "heading",
189
198
  "link",
@@ -210,6 +219,8 @@ function nativeMarkdown(instance: PatchableUserMessagePrototype):
210
219
  )
211
220
  return undefined;
212
221
  if (child.defaultTextStyle !== undefined && !isRecord(child.defaultTextStyle)) return undefined;
222
+ // Pi >=1.0 puts the message background in defaultTextStyle; Zentui frames draw their own.
223
+ const { bgColor: _bg, ...textStyle } = (child.defaultTextStyle ?? {}) as Record<string, unknown>;
213
224
  return {
214
225
  renderer: renderer as unknown as Markdown,
215
226
  text: child.text,
@@ -217,9 +228,9 @@ function nativeMarkdown(instance: PatchableUserMessagePrototype):
217
228
  theme: child.theme as unknown as NonNullable<
218
229
  UserMessageStyleRenderInput["markdown"]
219
230
  >["theme"],
220
- defaultTextStyle: child.defaultTextStyle as NonNullable<
221
- UserMessageStyleRenderInput["markdown"]
222
- >["defaultTextStyle"],
231
+ defaultTextStyle: (child.defaultTextStyle === undefined
232
+ ? undefined
233
+ : textStyle) as NonNullable<UserMessageStyleRenderInput["markdown"]>["defaultTextStyle"],
223
234
  options: child.options as NonNullable<UserMessageStyleRenderInput["markdown"]>["options"],
224
235
  },
225
236
  };
@@ -434,20 +434,26 @@ export function normalizeWorkingLineStyleSpec(value: ColorSpec | undefined): Col
434
434
  return tokens.join(" ");
435
435
  }
436
436
 
437
- function renderTier(
437
+ function workingLineTierRenderer(
438
438
  theme: ThemeLike,
439
439
  config: WorkingLineComponentConfig,
440
440
  colors: PolishedTuiColors,
441
- tier: Tier,
442
- text: string,
443
- ): string {
444
- return renderStyleForSourceOrFallback(
445
- theme,
446
- config.colorSource,
447
- normalizeWorkingLineStyleSpec(workingLineColor(config, colors, tier)),
448
- WORKING_LINE_FALLBACKS[tier],
449
- text,
450
- );
441
+ ): (tier: Tier, text: string) => string {
442
+ // Palette validation is invariant within this build, but must not survive a
443
+ // metric/config/theme rebuild. Resolve lazily so unused tiers remain untouched.
444
+ const tierStyles = new Map<Tier, ColorSpec | undefined>();
445
+ return (tier, text) => {
446
+ if (!tierStyles.has(tier)) {
447
+ tierStyles.set(tier, normalizeWorkingLineStyleSpec(workingLineColor(config, colors, tier)));
448
+ }
449
+ return renderStyleForSourceOrFallback(
450
+ theme,
451
+ config.colorSource,
452
+ tierStyles.get(tier),
453
+ WORKING_LINE_FALLBACKS[tier],
454
+ text,
455
+ );
456
+ };
451
457
  }
452
458
 
453
459
  /** Resolve the fixed, nonanimated high-tier style used by persisted Turn summaries. */
@@ -484,9 +490,7 @@ export function snapshotWorkingLineHighStyle(
484
490
  }
485
491
 
486
492
  function renderAnimatedText(
487
- theme: ThemeLike,
488
- config: WorkingLineComponentConfig,
489
- colors: PolishedTuiColors,
493
+ render: (tier: Tier, text: string) => string,
490
494
  cells: GraphemeCell[],
491
495
  width: number,
492
496
  tick: number,
@@ -504,7 +508,7 @@ function renderAnimatedText(
504
508
  if (previous?.tier === tier) previous.text += cell.text;
505
509
  else runs.push({ tier, text: cell.text });
506
510
  }
507
- return runs.map((run) => renderTier(theme, config, colors, run.tier, run.text)).join("");
511
+ return runs.map((run) => render(run.tier, run.text)).join("");
508
512
  }
509
513
 
510
514
  export type WorkingLineRuntimeSegments = {
@@ -813,8 +817,7 @@ function spinnerRowCells(
813
817
  function renderWorkingLineSchedule(
814
818
  definition: ScheduleDefinition,
815
819
  config: WorkingLineComponentConfig,
816
- colors: PolishedTuiColors,
817
- theme: ThemeLike,
820
+ render: (tier: Tier, text: string) => string,
818
821
  rowCells: GraphemeCell[],
819
822
  spinnerCells: Map<string, GraphemeCell[]> | undefined,
820
823
  width: number,
@@ -834,15 +837,7 @@ function renderWorkingLineSchedule(
834
837
  const renderTextForTick = (tick: number): string => {
835
838
  let cachedText = textRenderCache.get(tick);
836
839
  if (cachedText === undefined) {
837
- cachedText = renderAnimatedText(
838
- theme,
839
- config,
840
- colors,
841
- rowCells,
842
- width,
843
- tick,
844
- config.textAnimation,
845
- );
840
+ cachedText = renderAnimatedText(render, rowCells, width, tick, config.textAnimation);
846
841
  textRenderCache.set(tick, cachedText);
847
842
  }
848
843
  return cachedText;
@@ -856,7 +851,7 @@ function renderWorkingLineSchedule(
856
851
  let cachedSpinner = spinnerRenderCache.get(index);
857
852
  if (cachedSpinner === undefined) {
858
853
  const glyph = spinner.frames[index] ?? spinner.frames[0];
859
- cachedSpinner = renderTier(theme, config, colors, "high", glyph);
854
+ cachedSpinner = render("high", glyph);
860
855
  spinnerRenderCache.set(index, cachedSpinner);
861
856
  }
862
857
  return cachedSpinner;
@@ -874,9 +869,7 @@ function renderWorkingLineSchedule(
874
869
  spinner.frames[state.spinnerTick % spinner.frames.length] ?? spinner.frames[0];
875
870
  const frame = config.animateSpinnerColor
876
871
  ? `${renderAnimatedText(
877
- theme,
878
- config,
879
- colors,
872
+ render,
880
873
  spinnerCells?.get(spinnerGlyph) ?? rowCells,
881
874
  width,
882
875
  state.textTick,
@@ -934,6 +927,7 @@ function buildPreparedWorkingLineFrames(
934
927
  const textOrigin = config.animateSpinnerColor ? 0 : spinnerWidth + 1;
935
928
  const textCycle = textPeriod(config.textAnimation, animatedTextWidth);
936
929
  const textPhase = normalizedPhaseTick(textStartTick, textCycle);
930
+ const render = workingLineTierRenderer(theme, config, colors);
937
931
 
938
932
  if (config.textAnimation === "disabled") {
939
933
  const frameStates = Array.from({ length: spinner.frames.length }, (_, index) => ({
@@ -943,7 +937,7 @@ function buildPreparedWorkingLineFrames(
943
937
  let codeUnits = 0;
944
938
  const frames = frameStates.map((state) => {
945
939
  const glyph = spinner.frames[state.spinnerTick % spinner.frames.length] ?? spinner.frames[0];
946
- const frame = `${renderTier(theme, config, colors, "mid", `${glyph} ${composed.row}`)}${SGR_RESET}`;
940
+ const frame = `${render("mid", `${glyph} ${composed.row}`)}${SGR_RESET}`;
947
941
  codeUnits += frame.length;
948
942
  if (codeUnits > MAX_WORKING_LINE_FRAME_CODE_UNITS)
949
943
  throw new Error("Working-line animation exceeds its memory cap");
@@ -985,8 +979,7 @@ function buildPreparedWorkingLineFrames(
985
979
  const rendered = renderWorkingLineSchedule(
986
980
  exact,
987
981
  config,
988
- colors,
989
- theme,
982
+ render,
990
983
  rowCells,
991
984
  spinnerCells,
992
985
  animatedTextWidth,
@@ -1029,8 +1022,7 @@ function buildPreparedWorkingLineFrames(
1029
1022
  const rendered = renderWorkingLineSchedule(
1030
1023
  fallback,
1031
1024
  config,
1032
- colors,
1033
- theme,
1025
+ render,
1034
1026
  rowCells,
1035
1027
  spinnerCells,
1036
1028
  animatedTextWidth,
@@ -1064,6 +1056,7 @@ export function buildWorkingLineSpinnerFrames(
1064
1056
  ): { frames: string[]; frameStates: WorkingLineFrameState[]; intervalMs: number } {
1065
1057
  const spinner = WORKING_LINE_SPINNERS[config.spinner];
1066
1058
  workingLineSpinnerWidth(config.spinner);
1059
+ const render = workingLineTierRenderer(theme, config, colors);
1067
1060
  const phase = Number.isFinite(startTick) ? Math.max(0, Math.floor(startTick)) : 0;
1068
1061
  const frameStates = Array.from({ length: spinner.frames.length }, (_, index) => ({
1069
1062
  spinnerTick: phase + index,
@@ -1071,7 +1064,7 @@ export function buildWorkingLineSpinnerFrames(
1071
1064
  }));
1072
1065
  const frames = frameStates.map((state) => {
1073
1066
  const glyph = spinner.frames[state.spinnerTick % spinner.frames.length] ?? spinner.frames[0];
1074
- return `${renderTier(theme, config, colors, "high", glyph)}${SGR_RESET}`;
1067
+ return `${render("high", glyph)}${SGR_RESET}`;
1075
1068
  });
1076
1069
  return { frames, frameStates, intervalMs: config.spinnerIntervalMs };
1077
1070
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.29.1",
3
+ "version": "0.29.3",
4
4
  "description": "Modular UI components for Pi and Oh My Pi — customizable editors, messages, progress displays, and statuslines.",
5
5
  "type": "module",
6
6
  "license": "MIT",