@xynogen/pix-pretty 1.18.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.18.3",
3
+ "version": "1.19.0",
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",
@@ -158,6 +158,7 @@ describe("showOverlay — confirm mode", () => {
158
158
  title: "SSH FILE TRANSFER",
159
159
  body: [
160
160
  "Intent: Copy a release artifact",
161
+ "Command: scp app.tar.gz host:/tmp",
161
162
  "Host: deploy@example.com",
162
163
  "Direction: Download",
163
164
  "From: /srv/releases/app.tar.gz",
@@ -170,6 +171,8 @@ describe("showOverlay — confirm mode", () => {
170
171
  },
171
172
  );
172
173
  const joined = captured.join("\n");
174
+ expect(joined).toContain("<dim>Intent:</dim> <text>Copy a release artifact</text>");
175
+ expect(joined).toContain("<dim>Command:</dim> <dim>scp app.tar.gz host:/tmp</dim>");
173
176
  expect(joined).toContain("<dim>Host:</dim> <accent>deploy@example.com</accent>");
174
177
  expect(joined).toContain("<dim>Direction:</dim> <warning>Download</warning>");
175
178
  expect(joined).toContain("<dim>From:</dim> <text>/srv/releases/app.tar.gz</text>");
@@ -178,6 +181,44 @@ describe("showOverlay — confirm mode", () => {
178
181
  expect(joined).toContain("<dim>Auth:</dim> <success>SSH key (no password)</success>");
179
182
  });
180
183
 
184
+ // Regression guard (readability fix, pix-pretty 1.18.4): Intent:/Command:
185
+ // lines must go through the label/value colour map — a <dim> label plus a
186
+ // semantic value — never a bare bright value with the label stripped off.
187
+ // Pre-1.18.4 they were sliced and dumped as bright <text> with no label,
188
+ // which was hard to read on the modal background. Command value must be <dim>.
189
+ test("Intent/Command body lines keep a dim label and never drop it", async () => {
190
+ const coloredTheme = {
191
+ fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
192
+ bg: (_color: string, text: string) => text,
193
+ bold: (text: string) => text,
194
+ };
195
+ let captured: string[] = [];
196
+ await showOverlay(
197
+ makeUI(
198
+ (comp) => {
199
+ captured = comp.render(100);
200
+ comp.handleInput(ENTER);
201
+ },
202
+ undefined,
203
+ coloredTheme,
204
+ ),
205
+ {
206
+ mode: "sudo",
207
+ title: "ROOT COMMAND REQUEST",
208
+ accent: "error",
209
+ body: ["Intent: install a package", "Command: apt install foo"],
210
+ timeoutMs: 0,
211
+ },
212
+ );
213
+ const joined = captured.join("\n");
214
+ // Command label + value both dim; intent value stays readable text but is
215
+ // always prefixed by a dim label (never a bare label-less value).
216
+ expect(joined).toContain("<dim>Command:</dim> <dim>apt install foo</dim>");
217
+ expect(joined).toContain("<dim>Intent:</dim> <text>install a package</text>");
218
+ // The pre-1.18.4 bug: label stripped, value dumped bright with no dim label.
219
+ expect(joined).not.toContain("<text>apt install foo</text>"); // command value is dim, not text
220
+ });
221
+
181
222
  test("wraps a long body command instead of truncating it", async () => {
182
223
  // A command far wider than any modal width — must survive in full, wrapped.
183
224
  const longCmd = `echo ${"pix-gate-installed-or-linked ".repeat(8)}done`;
@@ -176,8 +176,6 @@ function buildSections(opts: {
176
176
  const inner = width - 4; // CHROME = 2 border + 2 padding
177
177
  const header = [theme.fg(accent, theme.bold(config.title))];
178
178
  const body = (config.body ?? []).map((line) => {
179
- if (line.startsWith("Intent:")) return theme.fg("text", line.slice(7).trimStart());
180
- if (line.startsWith("Command:")) return theme.fg("text", line.slice(8).trimStart());
181
179
  if (line.startsWith("Warning:")) return theme.fg("warning", line);
182
180
  if (line.startsWith("(") && line.endsWith(")")) return theme.fg("muted", line);
183
181
 
@@ -186,6 +184,8 @@ function buildSections(opts: {
186
184
  const label = line.slice(0, separator + 1);
187
185
  const value = line.slice(separator + 1).trimStart();
188
186
  const valueColors: Record<string, string> = {
187
+ "Intent:": "text",
188
+ "Command:": "dim",
189
189
  "Host:": "accent",
190
190
  "Direction:": "warning",
191
191
  "From:": "text",
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