@xynogen/pix-pretty 1.17.2 → 1.18.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.17.2",
3
+ "version": "1.18.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/ansi.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export let RST = "\x1b[0m";
2
2
  export const BOLD = "\x1b[1m";
3
+ export const BOLD_OFF = "\x1b[22m";
3
4
 
4
5
  export const FG_LNUM = "\x1b[38;2;100;100;100m";
5
6
  export const FG_DIM = "\x1b[38;2;80;80;80m";
@@ -23,6 +24,14 @@ export function resolveBaseBackground(_theme: unknown): void {
23
24
 
24
25
  export const ANSI_CAPTURE_RE = /\x1b\[([0-9;]*)m/g;
25
26
 
27
+ /** The ESC byte that opens every ANSI escape sequence. */
28
+ export const ESC = "\x1b";
29
+
30
+ /** True when a string already contains an ANSI escape sequence. */
31
+ export function hasAnsi(s: string): boolean {
32
+ return s.includes(ESC);
33
+ }
34
+
26
35
  // ---------------------------------------------------------------------------
27
36
  // Low-contrast fix (same as pi-diff)
28
37
  // ---------------------------------------------------------------------------
@@ -49,8 +49,6 @@ const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
49
49
  // Terminal bounds + thresholds
50
50
  // ---------------------------------------------------------------------------
51
51
 
52
- const MAX_TERM_WIDTH = 210;
53
-
54
52
  const MAX_PREVIEW_LINES = envInt("PRETTY_MAX_PREVIEW_LINES", 80);
55
53
 
56
54
  const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", dc.splitMinWidth || 150);
@@ -158,8 +156,8 @@ function tabs(s: string): string {
158
156
  function termW(): number {
159
157
  // Single source of truth: utils.termW caches, falls back to tty ioctl, and
160
158
  // invalidates on resize. Diff layout needs a hard floor of 80 cols for the
161
- // split-view column math, so clamp the shared value here.
162
- return Math.max(80, Math.min(utilsTermW(), MAX_TERM_WIDTH));
159
+ // split-view column math (no ceiling — fill the true width on ultrawide).
160
+ return Math.max(80, utilsTermW());
163
161
  }
164
162
 
165
163
  /** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
@@ -557,8 +555,7 @@ export async function renderUnified(
557
555
  if (l.type === "sep") {
558
556
  const gap = l.newNum;
559
557
  const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
560
- const totalW = Math.min(tw, 72);
561
- const pad = Math.max(0, totalW - label.length - 2);
558
+ const pad = Math.max(0, tw - label.length - 2);
562
559
  const half1 = Math.floor(pad / 2);
563
560
  const half2 = pad - half1;
564
561
  out.push(`${BG_BASE}${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
package/src/icons.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { dirIcon, fileIcon } from "./icons.ts";
2
+ import { dirIcon, fileColor, fileIcon } from "./icons.ts";
3
3
 
4
4
  const theme = {
5
5
  fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
@@ -16,3 +16,20 @@ describe("theme-derived file icons", () => {
16
16
  expect(dirIcon(theme)).toContain("<accent>");
17
17
  });
18
18
  });
19
+
20
+ describe("theme-derived file name color", () => {
21
+ test("colors a filename with the same role as its icon", () => {
22
+ expect(fileColor("example.ts", "example.ts", theme)).toBe(
23
+ "<syntaxType>example.ts</syntaxType>",
24
+ );
25
+ expect(fileColor("package.json", "package.json", theme)).toContain("<syntaxString>");
26
+ });
27
+
28
+ test("falls back to the text role for an unknown extension", () => {
29
+ expect(fileColor("notes.zzz", "notes.zzz", theme)).toBe("<text>notes.zzz</text>");
30
+ });
31
+
32
+ test("passes the name through unchanged when no theme is supplied", () => {
33
+ expect(fileColor("example.ts", "example.ts")).toBe("example.ts");
34
+ });
35
+ });
package/src/icons.ts CHANGED
@@ -100,3 +100,16 @@ export function fileIcon(fp: string, theme?: FgTheme): string {
100
100
  export function dirIcon(theme?: FgTheme): string {
101
101
  return USE_ICONS ? `${paint({ glyph: "\ue5ff", color: "accent" }, theme)} ` : "";
102
102
  }
103
+
104
+ /**
105
+ * Color a filename by its type, reusing the same per-extension palette the
106
+ * icons use (so name and icon share a hue). Unknown types fall back to the
107
+ * theme's default text color. Pure passthrough when no theme is supplied.
108
+ */
109
+ export function fileColor(fp: string, name: string, theme?: FgTheme): string {
110
+ if (!theme) return name;
111
+ const base = basename(fp).toLowerCase();
112
+ const ext = extname(fp).slice(1).toLowerCase();
113
+ const spec = NAME_ICON[base] ?? EXT_ICON[ext];
114
+ return theme.fg(spec?.color ?? "text", name);
115
+ }
package/src/renderers.ts CHANGED
@@ -5,7 +5,7 @@ import { prettySection } from "@xynogen/pix-runtime/sections";
5
5
  import { FG_DIM, FG_RULE, RST } from "./ansi.js";
6
6
  import { MAX_PREVIEW_LINES } from "./config.js";
7
7
  import { hlBlock } from "./highlight.js";
8
- import { dirIcon, fileIcon } from "./icons.js";
8
+ import { dirIcon, fileColor, fileIcon } from "./icons.js";
9
9
  import { lang } from "./lang.js";
10
10
  import type { FgTheme } from "./types.js";
11
11
  import { lnum, normalizeLineEndings, pluralize, rule, termW } from "./utils.js";
@@ -82,6 +82,13 @@ export function renderTree(text: string, basePath: string, theme?: FgTheme): str
82
82
  }
83
83
 
84
84
  /** Vertical tree view with connectors and icons. */
85
+ /** Color a listing entry: dirs use the theme accent, files use their
86
+ * per-extension hue (fileColor). Pure passthrough without a theme. */
87
+ function entryColor(isDir: boolean, name: string, theme?: FgTheme): string {
88
+ if (!theme) return name;
89
+ return isDir ? theme.fg("accent", name) : fileColor(name, name, theme);
90
+ }
91
+
85
92
  function renderLsTree(text: string, _basePath: string, theme?: FgTheme): string {
86
93
  const lines = text.trim().split("\n").filter(Boolean);
87
94
  if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
@@ -99,7 +106,7 @@ function renderLsTree(text: string, _basePath: string, theme?: FgTheme): string
99
106
  const isDir = entry.endsWith("/");
100
107
  const name = isDir ? entry.slice(0, -1) : entry;
101
108
  const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
102
- const displayName = isDir && theme ? theme.fg("accent", name) : name;
109
+ const displayName = entryColor(isDir, name, theme);
103
110
 
104
111
  out.push(`${connector}${icon}${displayName}`);
105
112
  }
@@ -130,7 +137,7 @@ function renderLsGrid(text: string, _basePath: string, theme?: FgTheme): string
130
137
  const isDir = entry.endsWith("/");
131
138
  const name = isDir ? entry.slice(0, -1) : entry;
132
139
  const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
133
- const displayName = isDir && theme ? theme.fg("accent", name) : name;
140
+ const displayName = entryColor(isDir, name, theme);
134
141
  const cell = `${icon}${displayName}`;
135
142
  cells.push(cell);
136
143
  cellWidths.push(visibleWidth(cell));
package/src/types.ts CHANGED
@@ -196,6 +196,10 @@ export type GrepResultDetails = {
196
196
  pattern: string;
197
197
  path?: string;
198
198
  matchCount: number;
199
+ /** Search flags, so the renderer can rebuild the matcher to highlight hits:
200
+ * literal → escape metacharacters; ignoreCase → case-insensitive. */
201
+ literal?: boolean;
202
+ ignoreCase?: boolean;
199
203
  };
200
204
 
201
205
  export type RenderDetails =
package/src/utils.test.ts CHANGED
@@ -13,7 +13,9 @@ import {
13
13
  renderCollapsedToolRow,
14
14
  renderDimPreview,
15
15
  ruleFrame,
16
+ sectionRule,
16
17
  setResultDetails,
18
+ termW,
17
19
  viewportText,
18
20
  } from "./utils.js";
19
21
 
@@ -159,6 +161,35 @@ function plain(text: string): string {
159
161
  return text.replace(ANSI, "");
160
162
  }
161
163
 
164
+ describe("termW", () => {
165
+ // termW() caches and invalidates on a stdout 'resize' event. Set columns then
166
+ // emit resize so the next call re-reads.
167
+ function setCols(cols: number): void {
168
+ (process.stdout as { columns?: number }).columns = cols;
169
+ process.stdout.emit("resize");
170
+ }
171
+
172
+ it("returns the true terminal width with no upper clamp (ultrawide)", () => {
173
+ const orig = process.stdout.columns;
174
+ try {
175
+ setCols(384); // wider than the old 210 cap
176
+ expect(termW()).toBe(384);
177
+ } finally {
178
+ setCols(orig ?? 80);
179
+ }
180
+ });
181
+
182
+ it("floors width at 1 for a degenerate column count", () => {
183
+ const orig = process.stdout.columns;
184
+ try {
185
+ setCols(0); // falsy — falls through resolution chain, never < 1
186
+ expect(termW()).toBeGreaterThanOrEqual(1);
187
+ } finally {
188
+ setCols(orig ?? 80);
189
+ }
190
+ });
191
+ });
192
+
162
193
  describe("ruleFrame", () => {
163
194
  it("wraps body with a rule top and bottom, then footer below the close", () => {
164
195
  const out = ruleFrame(["a", "b"], ["… +3 more"], 10);
@@ -402,4 +433,75 @@ describe("renderDimPreview", () => {
402
433
  expect(plain(raw)).toContain("call(foo)");
403
434
  expect(raw).toContain("\x1b[");
404
435
  });
436
+
437
+ it("highlights every regex match, not just a literal substring", () => {
438
+ // /te.t/ must light up both 'test' and 'text' — a literal indexOf can't.
439
+ const raw = renderDimPreview("test text", theme, { highlight: /te.t/g });
440
+ // Two bold-open codes = two highlighted hits.
441
+ expect(raw.split("\x1b[1m").length - 1).toBe(2);
442
+ expect(plain(raw)).toContain("test text");
443
+ });
444
+
445
+ it("does not loop on a zero-width regex match", () => {
446
+ // /x*/ matches empty everywhere — must terminate and keep content intact.
447
+ const raw = renderDimPreview("abc", theme, { highlight: /x*/g });
448
+ expect(plain(raw)).toContain("abc");
449
+ });
450
+
451
+ it("skips highlighting a line that already carries ANSI (no escape corruption)", () => {
452
+ // Pre-colored input: a match inside an escape would corrupt it, so the
453
+ // whole line is dimmed instead — no BOLD hit is injected.
454
+ const preColored = "\x1b[31mtest\x1b[0m done";
455
+ const raw = renderDimPreview(preColored, theme, { highlight: "test" });
456
+ expect(raw).not.toContain("\x1b[1m"); // no bold hit
457
+ expect(raw).toContain("\x1b[31m"); // original ANSI preserved
458
+ });
459
+
460
+ it("renders an === label === separator as a section divider", () => {
461
+ const raw = renderDimPreview("=== branch ===\nmain", theme, {});
462
+ // Label survives; the raw === markers are gone (replaced by a rule).
463
+ expect(plain(raw)).toContain("branch");
464
+ expect(plain(raw)).not.toContain("===");
465
+ expect(plain(raw)).toContain("─"); // divider glyph
466
+ expect(plain(raw)).toContain("main"); // ordinary lines untouched
467
+ });
468
+ });
469
+
470
+ describe("sectionRule", () => {
471
+ // Tagging theme so we can assert which role each fragment uses.
472
+ const tag: FgTheme = { fg: (key, text) => `<${key}>${text}</${key}>` };
473
+
474
+ it("left-aligns the label after a short lead rule, all muted", () => {
475
+ const out = sectionRule("=== versions ===", tag, 40) ?? "";
476
+ expect(out).not.toBeNull();
477
+ // One muted span wrapping the whole divider; label starts after 4 dashes.
478
+ expect(out).toBe("<muted>──── versions ──────────────────────────</muted>");
479
+ });
480
+
481
+ it("wraps an over-long label snugly with 2 dashes each side", () => {
482
+ const out = sectionRule("=== a very long section label here ===", tag, 20) ?? "";
483
+ expect(out).toBe("<muted>── a very long section label here ──</muted>");
484
+ });
485
+
486
+ it("accepts extra whitespace and 2+ equals signs", () => {
487
+ expect(sectionRule("== dirty? ==", tag, 40)).toContain("──── dirty? ");
488
+ expect(sectionRule("===== a b c =====", tag, 40)).toContain("──── a b c ");
489
+ });
490
+
491
+ it("returns null for a non-separator line", () => {
492
+ expect(sectionRule("just a normal line", tag, 20)).toBeNull();
493
+ expect(sectionRule("=== no closing", tag, 20)).toBeNull();
494
+ // Must span the whole line — leading text before the === disqualifies it.
495
+ expect(sectionRule("plain === middle === text", tag, 20)).toBeNull();
496
+ });
497
+
498
+ it("returns null when the line already carries ANSI", () => {
499
+ expect(sectionRule("\x1b[31m=== x ===\x1b[0m", tag, 20)).toBeNull();
500
+ });
501
+
502
+ it("fills the full requested width so it aligns with the tool frame", () => {
503
+ const out = sectionRule("=== x ===", tag, 400) ?? "";
504
+ const visible = out.replace(/<\/?[a-z]+>/g, "");
505
+ expect([...visible].length).toBe(400);
506
+ });
405
507
  });
package/src/utils.ts CHANGED
@@ -6,9 +6,10 @@ import {
6
6
  BG_BASE,
7
7
  BG_ERROR,
8
8
  BOLD,
9
- FG_GREEN,
9
+ BOLD_OFF,
10
10
  FG_LNUM,
11
11
  FG_RULE,
12
+ hasAnsi,
12
13
  RST,
13
14
  } from "./ansi.js";
14
15
  import { MAX_PREVIEW_LINES } from "./config.js";
@@ -51,6 +52,9 @@ export function fillToolBackground(text: string, bg = BG_BASE, width?: number):
51
52
  }
52
53
 
53
54
  export function viewportTextConstructor(TextComponent: TextComponentCtor): TextComponentCtor {
55
+ // SAFETY: a `new`-able ctor and a plain factory returning the same instance
56
+ // shape are interchangeable to callers here; TS can't see the returned
57
+ // ViewportText satisfies TextComponentCtor's construct signature.
54
58
  return function ViewportTextComponent(text = "") {
55
59
  const component = viewportText(TextComponent);
56
60
  component.setText(text);
@@ -323,28 +327,62 @@ export type DimPreviewOptions = {
323
327
  * the header is intentionally dropped (the collapsed row already carries the
324
328
  * count, so a floating header above the frame is redundant). */
325
329
  header?: string;
326
- /** Pattern whose matches are highlighted (green bold) inside dim lines. */
327
- highlight?: string;
330
+ /** Pattern whose matches are highlighted (bold, themed) inside dim lines.
331
+ * A string is matched literally (case-insensitive); a RegExp is used as-is
332
+ * (callers pass the compiled search pattern so regex greps highlight too). */
333
+ highlight?: string | RegExp;
328
334
  /** Wrap the body in a top/bottom rule frame (overflow below), like bash/ls/mcp. */
329
335
  frame?: boolean;
330
336
  /** Tint the frame rules (green ok, red error) — same status color as bash/ls. */
331
337
  paint?: RulePaint;
332
338
  };
333
339
 
334
- function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string): string {
335
- if (!pattern) return theme.fg("dim", line);
336
- const foldedLine = line.toLocaleLowerCase();
337
- const foldedPattern = pattern.toLocaleLowerCase();
338
- if (!foldedPattern) return theme.fg("dim", line);
340
+ /** Compile a highlight pattern into a global RegExp, or null when it can't
341
+ * match anything. Strings match literally (case-insensitive); a RegExp is
342
+ * re-flagged global so every occurrence on a line lights up. */
343
+ function toHighlightRegex(pattern: string | RegExp): RegExp | null {
344
+ if (typeof pattern !== "string") {
345
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
346
+ try {
347
+ return new RegExp(pattern.source, flags);
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+ if (!pattern) return null;
353
+ // Literal string → escape regex metacharacters, case-insensitive.
354
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
+ try {
356
+ return new RegExp(escaped, "gi");
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
339
361
 
362
+ function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string | RegExp): string {
363
+ if (!pattern) return theme.fg("dim", line);
364
+ // A line that already carries ANSI (e.g. a source that emitted its own color)
365
+ // can't be safely index-sliced — a match could land inside an escape and
366
+ // corrupt it. Skip highlighting and dim the whole line instead.
367
+ if (hasAnsi(line)) return theme.fg("dim", line);
368
+ const re = pattern ? toHighlightRegex(pattern) : null;
369
+ if (!re) return theme.fg("dim", line);
370
+
371
+ // Themed bold hit: theme.fg carries its own reset, so re-open BOLD after it
372
+ // and close with the bold-off code (\x1b[22m) to avoid leaking bold onward.
373
+ const hit = (s: string) => `${BOLD}${theme.fg("success", s)}${BOLD_OFF}`;
340
374
  const parts: string[] = [];
341
375
  let start = 0;
342
- for (;;) {
343
- const match = foldedLine.indexOf(foldedPattern, start);
344
- if (match < 0) break;
345
- if (match > start) parts.push(theme.fg("dim", line.slice(start, match)));
346
- parts.push(`${FG_GREEN}${BOLD}${line.slice(match, match + pattern.length)}${RST}`);
347
- start = match + pattern.length;
376
+ for (let m = re.exec(line); m !== null; m = re.exec(line)) {
377
+ const idx = m.index;
378
+ const text = m[0];
379
+ if (text.length === 0) {
380
+ re.lastIndex++; // zero-width match (e.g. /a*/) advance to avoid a loop
381
+ continue;
382
+ }
383
+ if (idx > start) parts.push(theme.fg("dim", line.slice(start, idx)));
384
+ parts.push(hit(text));
385
+ start = idx + text.length;
348
386
  }
349
387
  if (start < line.length) parts.push(theme.fg("dim", line.slice(start)));
350
388
  return parts.length > 0 ? parts.join("") : theme.fg("dim", line);
@@ -359,9 +397,12 @@ export function renderDimPreview(
359
397
  const highlight = opts.highlight;
360
398
  const output = normalizeLineEndings(text).trim() || "done";
361
399
  const lines = output.split("\n");
400
+ const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
362
401
  const body = lines
363
402
  .slice(0, maxLines)
364
- .map((line) => ` ${dimLineWithHighlight(line, theme, highlight)}`);
403
+ .map(
404
+ (line) => ` ${sectionRule(line, theme, sw) ?? dimLineWithHighlight(line, theme, highlight)}`,
405
+ );
365
406
  const header = opts.header ? ` ${theme.fg("dim", opts.header)}` : undefined;
366
407
  const overflow =
367
408
  lines.length > maxLines
@@ -413,7 +454,9 @@ export function termW(): number {
413
454
  Number.parseInt(process.env.COLUMNS ?? "", 10) ||
414
455
  _readTtyColumns() ||
415
456
  120;
416
- _cachedTermW = Math.max(1, Math.min(raw, 210));
457
+ // No upper clamp: frames must fill the true terminal width so our rules align
458
+ // with Pi's native full-width UI on ultrawide displays.
459
+ _cachedTermW = Math.max(1, raw);
417
460
 
418
461
  return _cachedTermW;
419
462
  }
@@ -459,6 +502,30 @@ export function rule(w: number, paint?: RulePaint): string {
459
502
  return paint ? paint(glyphs) : `${FG_RULE}${glyphs}${RST}`;
460
503
  }
461
504
 
505
+ /** Matches an `=== label ===` separator line (a common shell/echo idiom). */
506
+ const SECTION_RE = /^\s*={2,}\s*(.+?)\s*={2,}\s*$/;
507
+
508
+ /** Fixed dash count before a left-aligned section label. */
509
+ const SECTION_RULE_LEAD = 4;
510
+
511
+ /**
512
+ * If `line` is an `=== label ===` separator, render it as a left-aligned
513
+ * section divider (`──── label ───────────`); otherwise return null. Callers
514
+ * fall back to their normal per-line rendering on null. Fills the full `width`
515
+ * so the divider aligns with the surrounding tool frame. Label and rule both
516
+ * use the muted role — theme-driven, ANSI-safe.
517
+ */
518
+ export function sectionRule(line: string, theme: FgTheme, width: number): string | null {
519
+ const m = SECTION_RE.exec(line);
520
+ if (!m || hasAnsi(line)) return null;
521
+ const label = ` ${m[1]} `;
522
+ const trail = width - SECTION_RULE_LEAD - visibleWidth(label);
523
+ // Label fits → lead rule + trailing fill. Too long to fill → don't force a
524
+ // full-width rule; wrap it snugly with 2 dashes each side (`── long text ──`).
525
+ const [lead, tail] = trail >= 2 ? [SECTION_RULE_LEAD, trail] : [2, 2];
526
+ return theme.fg("muted", `${"─".repeat(lead)}${label}${"─".repeat(tail)}`);
527
+ }
528
+
462
529
  /**
463
530
  * Frame tool output the way bash/read/sudo do: a top rule, the body lines, a
464
531
  * bottom rule, then any footer lines (e.g. `… +N more`) below the close. The