pi-zentui 0.29.0 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -81,6 +81,7 @@ import {
81
81
  renderTurnSummaryEntry,
82
82
  TURN_SUMMARY_ENTRY_TYPE,
83
83
  } from "./interaction-summary";
84
+ import { LayeredEditorConsumer } from "./layered-editor";
84
85
  import { LiveContextController } from "./live-context";
85
86
  import { readPackageVersionResult } from "./package-version";
86
87
  import { getComponentPreset } from "./presets";
@@ -277,7 +278,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
277
278
  let stopSessionTimer: () => void = () => {};
278
279
  let stopMinimalistDurationUpdates: () => void = () => {};
279
280
  let minimalistDurationUpdatesActive = false;
280
- let minimalistDecorationActive = false;
281
+ let minimalistDecorationEditor: PolishedEditor | WrappedPolishedEditor | undefined;
281
282
  let customVariableSessionReady = false;
282
283
  let sessionTimerRequirements = "";
283
284
  let lastDurationLabel = "";
@@ -324,14 +325,41 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
324
325
  }
325
326
  };
326
327
 
328
+ const layeredEditor = new LayeredEditorConsumer<
329
+ EditorFactory,
330
+ PolishedEditor | WrappedPolishedEditor
331
+ >((generation) => sessionLifecycle.isCurrent(generation));
332
+ const currentEditorFactory = (): EditorFactory | undefined => {
333
+ try {
334
+ return activeTuiContext?.ui.getEditorComponent();
335
+ } catch {
336
+ return undefined;
337
+ }
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().
342
+ // Ownership counts before Pi constructs its editor; layered visibility requires the recorded
343
+ // instance to remain reachable through the current factory.
344
+ const layeredVisibleEditor = () => layeredEditor.editorVisibleThrough(currentEditorFactory());
345
+ const editorConsumerActive = () =>
346
+ ownsInstalledEditorFactory() || layeredVisibleEditor() !== undefined;
347
+ const visibleEditor = () =>
348
+ ownsInstalledEditorFactory() ? activeEditor?.editor : layeredVisibleEditor();
349
+ const requestEditorRepaint = () => {
350
+ requestEditorRender?.();
351
+ // The layered record intentionally outlives ownership clearing, so disabling the editor is
352
+ // gated here; a live wrapper keeps repainting while the editor remains enabled.
353
+ if (effectiveEditorEnabled()) layeredEditor.requestRender(currentEditorFactory());
354
+ };
355
+
327
356
  const customVariables = new CustomVariables(
328
357
  pi.events,
329
358
  (key) => {
330
359
  if (!customVariableSessionReady || !sessionLifecycle.isCurrent()) return false;
331
360
  return (
332
361
  (effectiveEditorEnabled() &&
333
- ownsInstalledEditorFactory() &&
334
- activeEditor?.editor.isMetadataDecorated() === true &&
362
+ visibleEditor()?.isMetadataDecorated() === true &&
335
363
  editorDemandsCustomVariable(currentConfig, key)) ||
336
364
  (installedFooterKind === "starship" &&
337
365
  ownsInstalledFooter() &&
@@ -340,7 +368,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
340
368
  },
341
369
  () => {
342
370
  if (!customVariableSessionReady || !sessionLifecycle.isCurrent()) return;
343
- requestEditorRender?.();
371
+ requestEditorRepaint();
344
372
  requestFooterRender?.();
345
373
  },
346
374
  );
@@ -349,7 +377,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
349
377
  customVariables.reconcile();
350
378
  codexQuota.reconcile();
351
379
  requestFooterRender?.();
352
- requestEditorRender?.();
380
+ requestEditorRepaint();
353
381
  };
354
382
  const thinkingExperimental = new ThinkingExperimentalController(
355
383
  () => currentConfig.components.thinkingSteps,
@@ -391,7 +419,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
391
419
  if (!host.skinOnly)
392
420
  workingLine.setRequestRender(() => {
393
421
  if (sessionLifecycle.isCurrent()) {
394
- requestEditorRender?.();
422
+ requestEditorRepaint();
395
423
  }
396
424
  });
397
425
  let workingLineSessionReady = false;
@@ -468,7 +496,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
468
496
  return usageTotals.resolve(
469
497
  ctx,
470
498
  (effectiveEditorEnabled() &&
471
- ownsInstalledEditorFactory() &&
499
+ editorConsumerActive() &&
472
500
  ["tokens", "input_tokens", "output_tokens", "cost", "cache_hit"].some((name) =>
473
501
  editorMetadataReferences(currentConfig).has(name),
474
502
  )) ||
@@ -504,9 +532,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
504
532
  )
505
533
  return undefined;
506
534
  const editorDemand =
507
- effectiveEditorEnabled() &&
508
- ownsInstalledEditorFactory() &&
509
- editorWantsCodexQuota(currentConfig);
535
+ effectiveEditorEnabled() && editorConsumerActive() && editorWantsCodexQuota(currentConfig);
510
536
  const footerDemand =
511
537
  effectiveFooterStyle() === "starship" &&
512
538
  currentConfig.components.footer.codexQuota &&
@@ -516,7 +542,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
516
542
  () => {
517
543
  if (!sessionLifecycle.isCurrent()) return;
518
544
  requestFooterRender?.();
519
- requestEditorRender?.();
545
+ requestEditorRepaint();
520
546
  },
521
547
  );
522
548
  const getEditorQuota = () =>
@@ -598,7 +624,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
598
624
  currentConfig,
599
625
  installedFooterReferences(),
600
626
  effectiveEditorEnabled() &&
601
- ownsInstalledEditorFactory() &&
627
+ editorConsumerActive() &&
602
628
  currentConfig.components.editor.style === "minimalist",
603
629
  );
604
630
 
@@ -647,6 +673,8 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
647
673
  if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
648
674
  if (activeTuiContext?.ui === ctx.ui) activeTuiContext = ctx;
649
675
  if (editorInstalled && !ownsInstalledEditorFactory()) reconcileObservedEditorOwnership(ctx);
676
+ // A layered editor can stop being visible without any ownership transition to observe.
677
+ if (projectRefreshActive && !needsProjectRefresh()) stopProjectRefresh();
650
678
  syncFooterState(ctx);
651
679
  if (project && needsProjectRefresh()) scheduleProjectRefresh(ctx);
652
680
  refresh();
@@ -698,9 +726,8 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
698
726
  sessionLifecycle.isCurrent() &&
699
727
  agentRunActive &&
700
728
  agentDurationClock.isActive() &&
701
- minimalistDecorationActive &&
729
+ minimalistDecorationActive() &&
702
730
  effectiveEditorEnabled() &&
703
- ownsInstalledEditorFactory() &&
704
731
  currentConfig.components.editor.style === "minimalist" &&
705
732
  editorMetadataReferences(currentConfig).has("turn_duration");
706
733
  if (!needed) {
@@ -721,13 +748,25 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
721
748
  });
722
749
  };
723
750
 
724
- const setMinimalistDecorationActive = (active: boolean) => {
725
- const next = sessionLifecycle.isCurrent() && active && ownsInstalledEditorFactory();
726
- if (minimalistDecorationActive === next) return;
727
- minimalistDecorationActive = next;
751
+ // Decoration is bound to the editor instance that reported it, so it stays valid while that
752
+ // editor is visible (owned or layered) and cannot leak to a replacement editor.
753
+ const minimalistDecorationActive = () =>
754
+ sessionLifecycle.isCurrent() &&
755
+ minimalistDecorationEditor !== undefined &&
756
+ visibleEditor() === minimalistDecorationEditor;
757
+ const reconcileMinimalistDecoration = () => {
728
758
  customVariables.reconcile();
729
759
  reconcileAgentTimer();
730
760
  };
761
+ const setMinimalistDecorationActive = (
762
+ active: boolean,
763
+ editor?: PolishedEditor | WrappedPolishedEditor,
764
+ ) => {
765
+ const next = active ? editor : undefined;
766
+ if (minimalistDecorationEditor === next) return;
767
+ minimalistDecorationEditor = next;
768
+ reconcileMinimalistDecoration();
769
+ };
731
770
 
732
771
  const startAgentTurn = (interactionStarted: boolean) => {
733
772
  agentRunActive = true;
@@ -852,12 +891,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
852
891
  if (activeTuiContext && workingLineSessionReady) {
853
892
  if (!host.skinOnly) workingLine.reconcile(activeTuiContext);
854
893
  }
855
- setMinimalistDecorationActive(false);
856
894
  requestEditorRender = undefined;
857
895
  wrappedEditorFactory = undefined;
858
896
  installedEditorFactory = undefined;
859
897
  editorInstallMode = "none";
860
898
  editorInstalled = false;
899
+ reconcileMinimalistDecoration();
861
900
  codexQuota.reconcile();
862
901
  };
863
902
 
@@ -900,8 +939,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
900
939
  return observed;
901
940
  };
902
941
 
942
+ // Exclusive: gates mutation of host UI such as the Working row.
903
943
  const isActiveEditor = (editor: PolishedEditor | WrappedPolishedEditor, generation: number) =>
904
944
  sessionLifecycle.isCurrent(generation) && activeEditor?.editor === editor;
945
+ // Read-only: also true for an editor still visible beneath a foreign wrapper.
946
+ const isVisibleEditor = (editor: PolishedEditor | WrappedPolishedEditor, generation: number) =>
947
+ sessionLifecycle.isCurrent(generation) && visibleEditor() === editor;
905
948
 
906
949
  const editorBorderCapabilityChanged = (
907
950
  editor: PolishedEditor | WrappedPolishedEditor,
@@ -910,14 +953,14 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
910
953
  if (!isActiveEditor(editor, generation) || !activeTuiContext || !workingLineSessionReady)
911
954
  return;
912
955
  if (!host.skinOnly) workingLine.reconcile(activeTuiContext);
913
- if (workingLine.isAvailable()) requestEditorRender?.();
956
+ if (workingLine.isAvailable()) requestEditorRepaint();
914
957
  };
915
958
 
916
959
  const editorMetadataDecorationChanged = (
917
960
  editor: PolishedEditor | WrappedPolishedEditor,
918
961
  generation: number,
919
962
  ) => {
920
- if (isActiveEditor(editor, generation)) customVariables.reconcile();
963
+ if (isVisibleEditor(editor, generation)) customVariables.reconcile();
921
964
  };
922
965
 
923
966
  const editorWorkingLineFrame = (
@@ -928,6 +971,31 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
928
971
  ? workingLine.currentWorkingLineFrame()
929
972
  : undefined;
930
973
 
974
+ /** Records a freshly constructed editor as owned (exclusive) or visible beneath a foreign wrapper. */
975
+ const registerConstructedEditor = (
976
+ ctx: ExtensionContext,
977
+ factory: ZentuiEditorFactory,
978
+ generation: number,
979
+ editor: PolishedEditor | WrappedPolishedEditor,
980
+ tui: TUI,
981
+ ) => {
982
+ const observed = observeEditorFactory(ctx);
983
+ if (
984
+ !sessionLifecycle.isCurrent(generation) ||
985
+ activeTuiContext?.ui !== ctx.ui ||
986
+ !observed.known
987
+ ) {
988
+ return;
989
+ }
990
+ if (observed.factory === factory) {
991
+ activeEditor = { editor, factory, generation };
992
+ requestEditorRender = () => tui.requestRender();
993
+ } else if (observed.factory && !isZentuiEditorFactory(observed.factory)) {
994
+ // A foreign wrapper built this editor beneath its own outer factory.
995
+ layeredEditor.attach(observed.factory, generation, editor, () => tui.requestRender());
996
+ }
997
+ };
998
+
931
999
  const makeEditorFactory = (
932
1000
  ctx: ExtensionContext,
933
1001
  baseFactory: EditorFactory | undefined,
@@ -977,21 +1045,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
977
1045
  };
978
1046
  },
979
1047
  (active) => {
980
- if (isActiveEditor(editor, generation)) setMinimalistDecorationActive(active);
1048
+ if (isVisibleEditor(editor, generation)) setMinimalistDecorationActive(active, editor);
981
1049
  },
982
1050
  () => editorBorderCapabilityChanged(editor, generation),
983
1051
  () => editorMetadataDecorationChanged(editor, generation),
984
1052
  );
985
- const observed = observeEditorFactory(ctx);
986
- if (
987
- sessionLifecycle.isCurrent(generation) &&
988
- activeTuiContext?.ui === ctx.ui &&
989
- observed.known &&
990
- observed.factory === factory
991
- ) {
992
- activeEditor = { editor, factory, generation };
993
- requestEditorRender = () => tui.requestRender();
994
- }
1053
+ registerConstructedEditor(ctx, factory, generation, editor, tui);
995
1054
  return editor;
996
1055
  }) as ZentuiEditorFactory;
997
1056
  factory[ZENTUI_EDITOR_FACTORY] = true;
@@ -1048,21 +1107,12 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
1048
1107
  };
1049
1108
  },
1050
1109
  (active) => {
1051
- if (isActiveEditor(editor, generation)) setMinimalistDecorationActive(active);
1110
+ if (isVisibleEditor(editor, generation)) setMinimalistDecorationActive(active, editor);
1052
1111
  },
1053
1112
  () => editorBorderCapabilityChanged(editor, generation),
1054
1113
  () => editorMetadataDecorationChanged(editor, generation),
1055
1114
  );
1056
- const observed = observeEditorFactory(ctx);
1057
- if (
1058
- sessionLifecycle.isCurrent(generation) &&
1059
- activeTuiContext?.ui === ctx.ui &&
1060
- observed.known &&
1061
- observed.factory === factory
1062
- ) {
1063
- activeEditor = { editor, factory, generation };
1064
- requestEditorRender = () => tui.requestRender();
1065
- }
1115
+ registerConstructedEditor(ctx, factory, generation, editor, tui);
1066
1116
  return editor;
1067
1117
  }) as ZentuiEditorFactory;
1068
1118
  factory[ZENTUI_EDITOR_FACTORY] = true;
@@ -1415,6 +1465,8 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
1415
1465
  if (!ctx || !sessionLifecycle.isCurrent()) return;
1416
1466
  sessionLifecycle.shutdown();
1417
1467
  activeEditor = undefined;
1468
+ minimalistDecorationEditor = undefined;
1469
+ layeredEditor.detach();
1418
1470
  extensionStatuses.dispose();
1419
1471
  codexQuota.stop();
1420
1472
  stopSessionTimer();
@@ -1474,6 +1526,7 @@ export default function (pi: ExtensionAPI, host: ZentuiHost = {}) {
1474
1526
  customVariables.clear();
1475
1527
  const lifecycleGeneration = sessionLifecycle.start();
1476
1528
  activeEditor = undefined;
1529
+ minimalistDecorationEditor = undefined;
1477
1530
  // A new generation must not expose or route extension segments through the previous
1478
1531
  // session while TUI startup is in progress.
1479
1532
  workingLineSessionReady = false;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Records that a Zentui editor instance is still visible beneath a foreign predecessor-preserving
3
+ * wrapper (`setEditorComponent((tui, theme, kb) => predecessor(tui, theme, kb))`).
4
+ *
5
+ * This is deliberately NOT installation ownership: Zentui does not own the outer factory, must not
6
+ * replace or restore it, and keeps this record across ownership clearing. It only answers
7
+ * "may a surface Zentui rendered still be on screen?" for read-only consumers such as usage demand
8
+ * and redraw requests. The record is valid only while the outer factory observed at construction is
9
+ * still the current editor factory, so any replacement of the chain invalidates it.
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.
14
+ */
15
+ export class LayeredEditorConsumer<Factory, Editor = unknown> {
16
+ private record?: {
17
+ outerFactory: Factory;
18
+ generation: number;
19
+ editor: Editor;
20
+ requestRender: () => void;
21
+ };
22
+
23
+ constructor(private readonly isCurrentGeneration: (generation: number) => boolean) {}
24
+
25
+ attach(
26
+ outerFactory: Factory,
27
+ generation: number,
28
+ editor: Editor,
29
+ requestRender: () => void,
30
+ ): void {
31
+ this.record = { outerFactory, generation, editor, requestRender };
32
+ }
33
+
34
+ detach(): void {
35
+ this.record = undefined;
36
+ }
37
+
38
+ editorVisibleThrough(currentFactory: Factory | undefined): Editor | undefined {
39
+ const record = this.record;
40
+ if (!record) return undefined;
41
+ if (!this.isCurrentGeneration(record.generation) || record.outerFactory !== currentFactory) {
42
+ this.record = undefined;
43
+ return undefined;
44
+ }
45
+ return record.editor;
46
+ }
47
+
48
+ isVisibleThrough(currentFactory: Factory | undefined): boolean {
49
+ return this.editorVisibleThrough(currentFactory) !== undefined;
50
+ }
51
+
52
+ requestRender(currentFactory: Factory | undefined): void {
53
+ if (this.isVisibleThrough(currentFactory)) this.record?.requestRender();
54
+ }
55
+ }
@@ -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
  });
@@ -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.0",
3
+ "version": "0.29.2",
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",