@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 +1 -1
- package/src/types.ts +3 -1
- package/src/utils.test.ts +86 -0
- package/src/utils.ts +58 -0
package/package.json
CHANGED
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
|
}
|