pi-zentui 0.29.1 → 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
@@ -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.1",
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",