@xynogen/pix-pretty 1.16.1 → 1.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.16.1",
3
+ "version": "1.16.2",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/utils.test.ts CHANGED
@@ -35,12 +35,14 @@ class MockTextComponent {
35
35
  // regression that recomputes every frame (the pre-memo CPU bug) fails loudly.
36
36
  class CountingTextComponent {
37
37
  static setCalls = 0;
38
+ static renderCalls = 0;
38
39
  private text = "";
39
40
  setText(value: string): void {
40
41
  CountingTextComponent.setCalls++;
41
42
  this.text = value;
42
43
  }
43
44
  render(): string[] {
45
+ CountingTextComponent.renderCalls++;
44
46
  return this.text.split("\n");
45
47
  }
46
48
  invalidate(): void {}
@@ -99,6 +101,56 @@ describe("viewportText", () => {
99
101
  text.render(20); // still cached
100
102
  expect(CountingTextComponent.setCalls).toBe(1);
101
103
  });
104
+
105
+ it("idle frames (same text+width) do not re-call the inner render", () => {
106
+ CountingTextComponent.renderCalls = 0;
107
+ const text = viewportText(CountingTextComponent);
108
+ text.setText("line one\nline two");
109
+ text.render(20);
110
+ text.render(20);
111
+ text.render(20);
112
+ // One inner render for the width; spinner ticks reuse the memoized output.
113
+ expect(CountingTextComponent.renderCalls).toBe(1);
114
+ });
115
+
116
+ it("re-calls inner render after content changes", () => {
117
+ CountingTextComponent.renderCalls = 0;
118
+ const text = viewportText(CountingTextComponent);
119
+ text.setText("first");
120
+ text.render(20);
121
+ text.setText("second"); // content changed → render output stale
122
+ text.render(20);
123
+ expect(CountingTextComponent.renderCalls).toBe(2);
124
+ });
125
+
126
+ it("invalidate() drops the render memo so a same-width frame recomputes (theme change)", () => {
127
+ CountingTextComponent.renderCalls = 0;
128
+ const text = viewportText(CountingTextComponent);
129
+ text.setText("body");
130
+ text.render(20);
131
+ text.render(20); // memo hit
132
+ expect(CountingTextComponent.renderCalls).toBe(1);
133
+ text.invalidate(); // theme/style changed → output stale even at same width
134
+ text.render(20); // must recompute, not serve pre-invalidate output
135
+ expect(CountingTextComponent.renderCalls).toBe(2);
136
+ });
137
+
138
+ it("streaming append reuses already-fitted lines and fits only the new tail", () => {
139
+ const text = viewportText(MockTextComponent);
140
+ text.setText("aaaaaaaaaa\nbbbbbbbbbb");
141
+ expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb"]);
142
+ // Append (streaming). Old lines fit identically; new line appears.
143
+ text.setText("aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc");
144
+ expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb", "ccccc"]);
145
+ });
146
+
147
+ it("width change re-fits all lines (line cache is width-scoped, no stale serve)", () => {
148
+ const text = viewportText(MockTextComponent);
149
+ text.setText("abcdefghij");
150
+ expect(text.render(5).map(plain)).toEqual(["abcde"]);
151
+ // Must NOT serve the stale 5-wide fit for the same raw line at a new width.
152
+ expect(text.render(8).map(plain)).toEqual(["abcdefgh"]);
153
+ });
102
154
  });
103
155
 
104
156
  // Strip ANSI escapes so assertions test content, not color codes.
package/src/utils.ts CHANGED
@@ -71,18 +71,33 @@ type ViewportComponent = {
71
71
 
72
72
  class ViewportText implements TextComponentLike, ViewportComponent {
73
73
  private text = "";
74
- // Pi re-renders every frame (spinner/streaming). Without this cache each
75
- // frame re-split + re-truncated the full text AND re-setText'd the inner
76
- // component (re-dirtying it), burning CPU on unchanged content.
74
+ // Pi re-renders every frame (spinner/streaming). Two-level cache:
75
+ // 1. per-line: `truncateToWidth` result keyed by raw line + width. Streaming
76
+ // only appends to the tail, so already-fitted lines hit the cache and
77
+ // only the new/changed lines pay the pi-tui width cost (was O(total
78
+ // lines) per chunk, now O(new lines)).
79
+ // 2. per-blob: skip re-joining + re-setText'ing the inner component when
80
+ // neither text nor width changed (idle frames, spinner ticks).
77
81
  private fittedWidth = -1;
78
82
  private fittedText = "";
83
+ private lineCache = new Map<string, string>();
84
+ private lineCacheWidth = -1;
85
+ // Render-output memo. The inner component's render is a pure function of
86
+ // (fittedText, width), so on an unchanged frame (spinner tick, idle) we skip
87
+ // calling into pi-tui AND skip the fallback split — otherwise both fired
88
+ // every single frame even when nothing changed. The returned array is shared
89
+ // by reference; pi-tui's render contract is read-only (the host joins/prints
90
+ // the lines, never mutates), so we don't defensively copy per frame.
91
+ private rendered: string[] = [];
92
+ private renderedWidth = -1;
79
93
 
80
94
  constructor(private readonly component: TextComponentLike) {}
81
95
 
82
96
  setText(value: string): void {
83
97
  if (value === this.text) return;
84
98
  this.text = value;
85
- this.fittedWidth = -1; // invalidate memo
99
+ this.fittedWidth = -1; // invalidate blob memo (line cache stays valid)
100
+ this.renderedWidth = -1; // invalidate render-output memo
86
101
  }
87
102
 
88
103
  getText(): string {
@@ -91,17 +106,45 @@ class ViewportText implements TextComponentLike, ViewportComponent {
91
106
 
92
107
  render(width: number): string[] {
93
108
  if (width !== this.fittedWidth) {
94
- this.fittedText = this.text
95
- .split("\n")
96
- .map((line) => truncateToWidth(line, width, ""))
109
+ if (width !== this.lineCacheWidth) {
110
+ this.lineCache.clear(); // width changed → every line must re-fit
111
+ this.lineCacheWidth = width;
112
+ }
113
+ const cache = this.lineCache;
114
+ const lines = this.text.split("\n");
115
+ // Bound the cache so a long streaming log can't grow it without limit.
116
+ // The working set is the visible line count; a generous multiple keeps
117
+ // steady-state hits while capping worst-case memory. Reset when exceeded
118
+ // rather than LRU-evicting — simpler, and a width-stable frame refills it.
119
+ if (cache.size > 4 * lines.length + 256) cache.clear();
120
+ this.fittedText = lines
121
+ .map((line) => {
122
+ let fitted = cache.get(line);
123
+ if (fitted === undefined) {
124
+ fitted = truncateToWidth(line, width, "");
125
+ cache.set(line, fitted);
126
+ }
127
+ return fitted;
128
+ })
97
129
  .join("\n");
98
130
  this.fittedWidth = width;
131
+ this.renderedWidth = -1; // fitted text rebuilt → render output is stale
99
132
  this.component.setText(this.fittedText);
100
133
  }
101
- return this.component.render?.(width) ?? this.fittedText.split("\n");
134
+ if (width !== this.renderedWidth) {
135
+ this.rendered = this.component.render?.(width) ?? this.fittedText.split("\n");
136
+ this.renderedWidth = width;
137
+ }
138
+ return this.rendered;
102
139
  }
103
140
 
104
141
  invalidate(): void {
142
+ // invalidate = "output stale for reasons other than (text,width)" — theme /
143
+ // style change. Fitting is pure on (text,width) so fittedText/lineCache stay
144
+ // valid, but the RENDER output (colors) may differ, so drop the render memo;
145
+ // otherwise a same-width frame after invalidate would serve pre-invalidate
146
+ // output forever.
147
+ this.renderedWidth = -1;
105
148
  this.component.invalidate?.();
106
149
  }
107
150
  }