@xynogen/pix-pretty 1.13.0 → 1.14.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.13.0",
3
+ "version": "1.14.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",
package/src/utils.test.ts CHANGED
@@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test";
3
3
  import { MAX_PREVIEW_LINES } from "./config.js";
4
4
  import type { FgTheme } from "./types.js";
5
5
  import {
6
+ dotJoin,
6
7
  formatCollapsedToolRow,
7
8
  formatJson,
8
9
  hideCollapsedToolCall,
@@ -35,6 +36,28 @@ describe("ruleFrame", () => {
35
36
  expect(plain(out[0]!)).toBe("────");
36
37
  expect(plain(out.at(-1)!)).toBe("────");
37
38
  });
39
+
40
+ it("paints both rules via the supplied paint fn, neutral by default", () => {
41
+ const green = (s: string) => `<G>${s}</G>`;
42
+ const ok = ruleFrame(["x"], [], 4, green);
43
+ expect(ok[0]).toBe("<G>────</G>");
44
+ expect(ok.at(-1)).toBe("<G>────</G>"); // both top and bottom rule painted
45
+ const neutral = ruleFrame(["x"], [], 4);
46
+ expect(neutral[0]).toContain("50;50;50"); // FG_RULE default tint
47
+ });
48
+ });
49
+
50
+ describe("dotJoin", () => {
51
+ it("joins non-empty parts with a middot, dropping falsy pieces", () => {
52
+ expect(dotJoin(["a", "b", "c"])).toBe("a · b · c");
53
+ expect(dotJoin(["a", "", null, undefined, false, "b"])).toBe("a · b");
54
+ expect(dotJoin(["only"])).toBe("only");
55
+ expect(dotJoin([])).toBe("");
56
+ });
57
+
58
+ it("paints the separator when a paint fn is supplied", () => {
59
+ expect(dotJoin(["a", "b"], (s) => `<${s}>`)).toBe("a< · >b");
60
+ });
38
61
  });
39
62
 
40
63
  // Minimal theme: fg() passes text through untouched.
@@ -202,15 +225,24 @@ describe("renderDimPreview", () => {
202
225
  expect(out).toContain("body");
203
226
  });
204
227
 
205
- it("frames the body with a rule top and bottom, header above the top rule", () => {
228
+ it("frames the body with a rule top and bottom, dropping the redundant header", () => {
206
229
  const out = plain(renderDimPreview("a\nb", theme, { frame: true, header: "2 files" }));
207
230
  const lines = out.split("\n");
208
- // header, top rule, a, b, bottom rule
209
- expect(lines[0]).toContain("2 files");
210
- expect(lines[1]).toMatch(/^─+$/); // top rule
231
+ // No floating header in framed mode the collapsed row carries the count.
232
+ expect(out).not.toContain("2 files");
233
+ expect(lines[0]).toMatch(/^─+$/); // top rule is the first line
211
234
  expect(lines.at(-1)).toMatch(/^─+$/); // bottom rule closes the block
212
235
  });
213
236
 
237
+ it("paints the frame rules when a paint fn is given", () => {
238
+ const tag: FgTheme = { fg: (k, v) => `<${k}>${v}` };
239
+ const out = renderDimPreview("a\nb", tag, {
240
+ frame: true,
241
+ paint: (s: string) => tag.fg("success", s),
242
+ });
243
+ expect(out).toContain("<success>─");
244
+ });
245
+
214
246
  it("frames overflow footer below the bottom rule", () => {
215
247
  const body = Array.from({ length: MAX_PREVIEW_LINES + 2 }, (_, i) => `L${i}`);
216
248
  const out = plain(renderDimPreview(body.join("\n"), theme, { frame: true }));
package/src/utils.ts CHANGED
@@ -156,6 +156,19 @@ export function padIcon(glyph: string, width = 2): string {
156
156
  return pad > 0 ? glyph + " ".repeat(pad) : glyph;
157
157
  }
158
158
 
159
+ /**
160
+ * Join non-empty parts with a middot separator: `dotJoin(["a", "", "b"])` → `"a · b"`.
161
+ * Empty/nullish parts are dropped so callers can pass conditional pieces inline.
162
+ * Pass `paint` (e.g. `(s) => theme.fg("dim", s)`) to tint the separator to theme.
163
+ */
164
+ export function dotJoin(
165
+ parts: Array<string | false | null | undefined>,
166
+ paint?: RulePaint,
167
+ ): string {
168
+ const sep = paint ? paint(" · ") : " · ";
169
+ return parts.filter((p): p is string => Boolean(p)).join(sep);
170
+ }
171
+
159
172
  type CollapsedToolTheme = {
160
173
  fg: (
161
174
  key: "success" | "error" | "warning" | "toolTitle" | "muted" | "dim",
@@ -205,16 +218,16 @@ export function hideCollapsedToolCall(
205
218
 
206
219
  export type DimPreviewOptions = {
207
220
  maxLines?: number;
208
- /** Header line shown above the body (or above the top rule when framed).
209
- * Pass the tool's SEMANTIC count here (e.g. `pluralize(matchCount, "match")`)
210
- * the same value the collapsed summary row uses so top and collapsed
211
- * never disagree. Do not recount the body: notices/blank lines make a body
212
- * line-count diverge from the semantic count. */
221
+ /** Header line shown above the body. NON-FRAMED mode only in framed mode
222
+ * the header is intentionally dropped (the collapsed row already carries the
223
+ * count, so a floating header above the frame is redundant). */
213
224
  header?: string;
214
225
  /** Pattern whose matches are highlighted (green bold) inside dim lines. */
215
226
  highlight?: string;
216
- /** Wrap the body in a top/bottom rule frame (header above, overflow below), like bash/read/mcp. */
227
+ /** Wrap the body in a top/bottom rule frame (overflow below), like bash/ls/mcp. */
217
228
  frame?: boolean;
229
+ /** Tint the frame rules (green ok, red error) — same status color as bash/ls. */
230
+ paint?: RulePaint;
218
231
  };
219
232
 
220
233
  function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string): string {
@@ -255,9 +268,9 @@ export function renderDimPreview(
255
268
  : undefined;
256
269
 
257
270
  if (opts.frame) {
258
- // Same layout as bash/read/mcp: header above the top rule, body between the
259
- // rules, overflow footer below the bottom rule.
260
- const out = [...(header ? [header] : []), ...ruleFrame(body, overflow ? [overflow] : [])];
271
+ // One shape regardless of line count: a single framed box, no floating
272
+ // header (the collapsed row carries the count). Rules follow status color.
273
+ const out = ruleFrame(body, overflow ? [overflow] : [], undefined, opts.paint);
261
274
  return fillToolBackground(out.join("\n"));
262
275
  }
263
276
 
@@ -336,8 +349,13 @@ export function shortPath(cwd: string, home: string, p: string): string {
336
349
  return p.replace(home, "~");
337
350
  }
338
351
 
339
- export function rule(w: number): string {
340
- return `${FG_RULE}${"─".repeat(w)}${RST}`;
352
+ /** Paints a rule line. Callers pass `(s) => theme.fg(status, s)` so the frame
353
+ * tint follows the active theme (success/error/warning). Default = neutral FG_RULE. */
354
+ export type RulePaint = (glyphs: string) => string;
355
+
356
+ export function rule(w: number, paint?: RulePaint): string {
357
+ const glyphs = "─".repeat(w);
358
+ return paint ? paint(glyphs) : `${FG_RULE}${glyphs}${RST}`;
341
359
  }
342
360
 
343
361
  /**
@@ -350,8 +368,9 @@ export function ruleFrame(
350
368
  bodyLines: string[],
351
369
  footerLines: string[] = [],
352
370
  width?: number,
371
+ paint?: RulePaint,
353
372
  ): string[] {
354
- const r = rule(width ?? termW());
373
+ const r = rule(width ?? termW(), paint);
355
374
  return [r, ...bodyLines, r, ...footerLines];
356
375
  }
357
376