@xynogen/pix-pretty 1.15.0 → 1.16.1

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.15.0",
3
+ "version": "1.16.1",
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/types.ts CHANGED
@@ -42,9 +42,11 @@ export type ToolContent = TextContent | ImageContent;
42
42
 
43
43
  export type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
44
44
 
45
- type TextComponentLike = {
45
+ export type TextComponentLike = {
46
46
  setText(value: string): void;
47
47
  getText?: () => string;
48
+ render?: (width: number) => string[];
49
+ invalidate?: () => void;
48
50
  };
49
51
 
50
52
  export type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
package/src/utils.test.ts CHANGED
@@ -4,6 +4,7 @@ import { MAX_PREVIEW_LINES } from "./config.js";
4
4
  import type { FgTheme } from "./types.js";
5
5
  import {
6
6
  dotJoin,
7
+ fillToolBackground,
7
8
  formatCollapsedToolRow,
8
9
  formatJson,
9
10
  hideCollapsedToolCall,
@@ -13,8 +14,93 @@ import {
13
14
  renderDimPreview,
14
15
  ruleFrame,
15
16
  setResultDetails,
17
+ viewportText,
16
18
  } from "./utils.js";
17
19
 
20
+ class MockTextComponent {
21
+ private text = "";
22
+
23
+ setText(value: string): void {
24
+ this.text = value;
25
+ }
26
+
27
+ render(): string[] {
28
+ return this.text.split("\n");
29
+ }
30
+
31
+ invalidate(): void {}
32
+ }
33
+
34
+ // Counting variant: records how often the inner component is re-fitted, so a
35
+ // regression that recomputes every frame (the pre-memo CPU bug) fails loudly.
36
+ class CountingTextComponent {
37
+ static setCalls = 0;
38
+ private text = "";
39
+ setText(value: string): void {
40
+ CountingTextComponent.setCalls++;
41
+ this.text = value;
42
+ }
43
+ render(): string[] {
44
+ return this.text.split("\n");
45
+ }
46
+ invalidate(): void {}
47
+ }
48
+
49
+ describe("viewportText", () => {
50
+ it("trims a pre-filled row to Pi's narrower fullscreen viewport", () => {
51
+ const text = viewportText(MockTextComponent);
52
+ text.setText(fillToolBackground("tool", "", 10));
53
+
54
+ expect(plain(text.render(9)[0]!)).toBe("tool".padEnd(9));
55
+ expect(plain(text.render(10)[0]!)).toBe("tool".padEnd(10));
56
+ });
57
+
58
+ it("memoizes: repeat render at same width does not re-fit the inner component", () => {
59
+ CountingTextComponent.setCalls = 0;
60
+ const text = viewportText(CountingTextComponent);
61
+ text.setText("line one\nline two");
62
+
63
+ text.render(20);
64
+ text.render(20);
65
+ text.render(20);
66
+ // One fit for the width; repeats hit the cache. Pre-memo this was 3.
67
+ expect(CountingTextComponent.setCalls).toBe(1);
68
+ });
69
+
70
+ it("re-fits when width changes, and again when it returns", () => {
71
+ CountingTextComponent.setCalls = 0;
72
+ const text = viewportText(CountingTextComponent);
73
+ text.setText("abcdefghij");
74
+
75
+ text.render(20);
76
+ text.render(5); // width changed → re-fit
77
+ text.render(5); // same → cached
78
+ text.render(20); // changed back → re-fit
79
+ expect(CountingTextComponent.setCalls).toBe(3);
80
+ });
81
+
82
+ it("re-fits after setText changes the content", () => {
83
+ CountingTextComponent.setCalls = 0;
84
+ const text = viewportText(CountingTextComponent);
85
+ text.setText("first");
86
+ text.render(20);
87
+ text.render(20); // cached
88
+ text.setText("second"); // invalidates memo
89
+ text.render(20); // re-fit
90
+ expect(CountingTextComponent.setCalls).toBe(2);
91
+ });
92
+
93
+ it("setText with identical value is a no-op (no memo invalidation)", () => {
94
+ CountingTextComponent.setCalls = 0;
95
+ const text = viewportText(CountingTextComponent);
96
+ text.setText("same");
97
+ text.render(20);
98
+ text.setText("same"); // identical → must not invalidate
99
+ text.render(20); // still cached
100
+ expect(CountingTextComponent.setCalls).toBe(1);
101
+ });
102
+ });
103
+
18
104
  // Strip ANSI escapes so assertions test content, not color codes.
19
105
  const ANSI = /\x1b\[[0-9;]*m/g;
20
106
  function plain(text: string): string {
package/src/utils.ts CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  import { MAX_PREVIEW_LINES } from "./config.js";
15
15
  import type {
16
16
  FgTheme,
17
+ TextComponentCtor,
18
+ TextComponentLike,
17
19
  ToolContent,
18
20
  ToolImageContent,
19
21
  ToolResultLike,
@@ -48,6 +50,62 @@ export function fillToolBackground(text: string, bg = BG_BASE, width?: number):
48
50
  .join("\n");
49
51
  }
50
52
 
53
+ export function viewportTextConstructor(TextComponent: TextComponentCtor): TextComponentCtor {
54
+ return function ViewportTextComponent(text = "") {
55
+ const component = viewportText(TextComponent);
56
+ component.setText(text);
57
+ return component;
58
+ } as unknown as TextComponentCtor;
59
+ }
60
+
61
+ export function viewportText(
62
+ TextComponent: TextComponentCtor,
63
+ ): TextComponentLike & ViewportComponent {
64
+ return new ViewportText(new TextComponent("", 0, 0));
65
+ }
66
+
67
+ type ViewportComponent = {
68
+ render(width: number): string[];
69
+ invalidate(): void;
70
+ };
71
+
72
+ class ViewportText implements TextComponentLike, ViewportComponent {
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.
77
+ private fittedWidth = -1;
78
+ private fittedText = "";
79
+
80
+ constructor(private readonly component: TextComponentLike) {}
81
+
82
+ setText(value: string): void {
83
+ if (value === this.text) return;
84
+ this.text = value;
85
+ this.fittedWidth = -1; // invalidate memo
86
+ }
87
+
88
+ getText(): string {
89
+ return this.text;
90
+ }
91
+
92
+ render(width: number): string[] {
93
+ if (width !== this.fittedWidth) {
94
+ this.fittedText = this.text
95
+ .split("\n")
96
+ .map((line) => truncateToWidth(line, width, ""))
97
+ .join("\n");
98
+ this.fittedWidth = width;
99
+ this.component.setText(this.fittedText);
100
+ }
101
+ return this.component.render?.(width) ?? this.fittedText.split("\n");
102
+ }
103
+
104
+ invalidate(): void {
105
+ this.component.invalidate?.();
106
+ }
107
+ }
108
+
51
109
  export function pluralize(count: number, noun: string, plural?: string): string {
52
110
  return `${count} ${count === 1 ? noun : (plural ?? `${noun}s`)}`;
53
111
  }