@xynogen/pix-pretty 1.18.4 → 1.19.0
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/utils.test.ts +60 -1
- package/src/utils.ts +70 -0
package/package.json
CHANGED
package/src/utils.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
fillToolBackground,
|
|
8
8
|
formatCollapsedToolRow,
|
|
9
9
|
formatJson,
|
|
10
|
+
frameToolResult,
|
|
10
11
|
hideCollapsedToolCall,
|
|
11
12
|
padIcon,
|
|
12
13
|
pluralize,
|
|
@@ -16,11 +17,13 @@ import {
|
|
|
16
17
|
sectionRule,
|
|
17
18
|
setResultDetails,
|
|
18
19
|
termW,
|
|
20
|
+
unframeToolResult,
|
|
19
21
|
viewportText,
|
|
20
22
|
} from "./utils.js";
|
|
21
23
|
|
|
22
24
|
class MockTextComponent {
|
|
23
25
|
private text = "";
|
|
26
|
+
invalidations = 0;
|
|
24
27
|
|
|
25
28
|
setText(value: string): void {
|
|
26
29
|
this.text = value;
|
|
@@ -30,7 +33,9 @@ class MockTextComponent {
|
|
|
30
33
|
return this.text.split("\n");
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
invalidate(): void {
|
|
36
|
+
invalidate(): void {
|
|
37
|
+
this.invalidations++;
|
|
38
|
+
}
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
// Counting variant: records how often the inner component is re-fitted, so a
|
|
@@ -190,6 +195,60 @@ describe("termW", () => {
|
|
|
190
195
|
});
|
|
191
196
|
});
|
|
192
197
|
|
|
198
|
+
describe("frameToolResult", () => {
|
|
199
|
+
it("wraps a component at render width and forwards invalidation", () => {
|
|
200
|
+
const child = new MockTextComponent();
|
|
201
|
+
child.setText("result");
|
|
202
|
+
const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
|
|
203
|
+
const framed = frameToolResult(child, theme, false);
|
|
204
|
+
|
|
205
|
+
expect(framed.render(8)).toEqual([
|
|
206
|
+
"[success]────────[/success]",
|
|
207
|
+
"result",
|
|
208
|
+
"[success]────────[/success]",
|
|
209
|
+
]);
|
|
210
|
+
framed.setText("updated");
|
|
211
|
+
expect(framed.render(8)[1]).toBe("updated");
|
|
212
|
+
framed.invalidate();
|
|
213
|
+
expect(child.invalidations).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("unwraps a framed component when a result collapses", () => {
|
|
217
|
+
const child = new MockTextComponent();
|
|
218
|
+
child.setText("expanded");
|
|
219
|
+
const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
|
|
220
|
+
const framed = frameToolResult(child, theme, false);
|
|
221
|
+
framed.setText("collapsed");
|
|
222
|
+
|
|
223
|
+
expect(unframeToolResult(framed)).toBe(child);
|
|
224
|
+
expect(unframeToolResult(framed).render(20)).toEqual(["collapsed"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("uses error rules for failed results", () => {
|
|
228
|
+
const child = new MockTextComponent();
|
|
229
|
+
child.setText("failed");
|
|
230
|
+
const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
|
|
231
|
+
|
|
232
|
+
expect(frameToolResult(child, theme, true).render(4)).toEqual([
|
|
233
|
+
"[error]────[/error]",
|
|
234
|
+
"failed",
|
|
235
|
+
"[error]────[/error]",
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("reuses an existing frame instead of nesting rules on rerender", () => {
|
|
240
|
+
const child = new MockTextComponent();
|
|
241
|
+
child.setText("result");
|
|
242
|
+
const theme: FgTheme = { fg: (key, text) => `[${key}]${text}[/${key}]` };
|
|
243
|
+
const first = frameToolResult(child, theme, false);
|
|
244
|
+
const nextTheme: FgTheme = { fg: (key, text) => `<${key}>${text}</${key}>` };
|
|
245
|
+
const rerendered = frameToolResult(first, nextTheme, true);
|
|
246
|
+
|
|
247
|
+
expect(rerendered).toBe(first);
|
|
248
|
+
expect(rerendered.render(4)).toEqual(["<error>────</error>", "result", "<error>────</error>"]);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
193
252
|
describe("ruleFrame", () => {
|
|
194
253
|
it("wraps body with a rule top and bottom, then footer below the close", () => {
|
|
195
254
|
const out = ruleFrame(["a", "b"], ["… +3 more"], 10);
|
package/src/utils.ts
CHANGED
|
@@ -73,6 +73,24 @@ type ViewportComponent = {
|
|
|
73
73
|
invalidate(): void;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
type ResultComponent = ViewportComponent & {
|
|
77
|
+
handleInput?(data: string): void;
|
|
78
|
+
wantsKeyRelease?: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type FramableComponent = Partial<ResultComponent> & Partial<TextComponentLike>;
|
|
82
|
+
type ResultFrameTheme = {
|
|
83
|
+
fg: (key: "success" | "error", text: string) => string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const RESULT_FRAME = Symbol("pix.resultFrame");
|
|
87
|
+
type FramedResultComponent = ResultComponent & {
|
|
88
|
+
[RESULT_FRAME]: {
|
|
89
|
+
component: FramableComponent;
|
|
90
|
+
update(theme: ResultFrameTheme, isError: boolean): void;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
76
94
|
class ViewportText implements TextComponentLike, ViewportComponent {
|
|
77
95
|
private text = "";
|
|
78
96
|
// Pi re-renders every frame (spinner/streaming). Two-level cache:
|
|
@@ -526,6 +544,58 @@ export function sectionRule(line: string, theme: FgTheme, width: number): string
|
|
|
526
544
|
return theme.fg("muted", `${"─".repeat(lead)}${label}${"─".repeat(tail)}`);
|
|
527
545
|
}
|
|
528
546
|
|
|
547
|
+
/** Decorate any tool result component with status-colored top/bottom rules. */
|
|
548
|
+
export function frameToolResult<T extends FramableComponent & { setText(value: string): void }>(
|
|
549
|
+
component: T,
|
|
550
|
+
theme: ResultFrameTheme,
|
|
551
|
+
isError: boolean,
|
|
552
|
+
): ResultComponent & { setText(value: string): void; getText?: () => string };
|
|
553
|
+
export function frameToolResult(
|
|
554
|
+
component: FramableComponent,
|
|
555
|
+
theme: ResultFrameTheme,
|
|
556
|
+
isError: boolean,
|
|
557
|
+
): ResultComponent;
|
|
558
|
+
export function frameToolResult(
|
|
559
|
+
component: FramableComponent,
|
|
560
|
+
theme: ResultFrameTheme,
|
|
561
|
+
isError: boolean,
|
|
562
|
+
): ResultComponent {
|
|
563
|
+
const existing = component as Partial<FramedResultComponent>;
|
|
564
|
+
if (existing[RESULT_FRAME]) {
|
|
565
|
+
existing[RESULT_FRAME].update(theme, isError);
|
|
566
|
+
return component as ResultComponent;
|
|
567
|
+
}
|
|
568
|
+
let frameTheme = theme;
|
|
569
|
+
let failed = isError;
|
|
570
|
+
const framed: FramedResultComponent & Partial<TextComponentLike> = {
|
|
571
|
+
[RESULT_FRAME]: {
|
|
572
|
+
component,
|
|
573
|
+
update(nextTheme, nextError) {
|
|
574
|
+
frameTheme = nextTheme;
|
|
575
|
+
failed = nextError;
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
wantsKeyRelease: component.wantsKeyRelease,
|
|
579
|
+
render(width) {
|
|
580
|
+
const paint = (line: string) => frameTheme.fg(failed ? "error" : "success", line);
|
|
581
|
+
return ruleFrame(component.render?.(width) ?? [], [], width, paint);
|
|
582
|
+
},
|
|
583
|
+
invalidate() {
|
|
584
|
+
component.invalidate?.();
|
|
585
|
+
},
|
|
586
|
+
handleInput: component.handleInput ? (data) => component.handleInput?.(data) : undefined,
|
|
587
|
+
};
|
|
588
|
+
if (component.setText) framed.setText = (value) => component.setText?.(value);
|
|
589
|
+
if (component.getText) framed.getText = () => component.getText?.() ?? "";
|
|
590
|
+
return framed;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Remove the result frame while preserving its underlying renderer component. */
|
|
594
|
+
export function unframeToolResult<T extends FramableComponent>(component: T): T {
|
|
595
|
+
const framed = component as Partial<FramedResultComponent>;
|
|
596
|
+
return (framed[RESULT_FRAME]?.component ?? component) as T;
|
|
597
|
+
}
|
|
598
|
+
|
|
529
599
|
/**
|
|
530
600
|
* Frame tool output the way bash/read/sudo do: a top rule, the body lines, a
|
|
531
601
|
* bottom rule, then any footer lines (e.g. `… +N more`) below the close. The
|