@xynogen/pix-pretty 1.17.0 → 1.18.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.17.0",
3
+ "version": "1.18.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",
@@ -59,8 +59,7 @@
59
59
  "access": "public"
60
60
  },
61
61
  "dependencies": {
62
- "@xynogen/pix-data": "^0.4.3",
63
- "@xynogen/pix-runtime": "^0.7.0",
62
+ "@xynogen/pix-runtime": "^0.8.0",
64
63
  "chalk": "^4.1.2",
65
64
  "cli-highlight": "^2.1.11",
66
65
  "@ff-labs/fff-node": "^0.5.2",
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
  // ---------------------------------------------------------------------------
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/index.ts CHANGED
@@ -8,13 +8,15 @@
8
8
  * UI features (paste chips, thinking blocks) live in pix-display.
9
9
  */
10
10
 
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
  import { registerFffCommands } from "./commands/fff.js";
12
13
  import { fffState } from "./fff.js";
13
14
  import { clearHighlightCache } from "./highlight.js";
14
15
  import { initIconMode } from "./icon-persist.js";
15
16
  import type { PiPrettyApi } from "./types.js";
16
17
 
17
- export default function piPrettyExtension(pi: PiPrettyApi): void {
18
+ export default function piPrettyExtension(pi: ExtensionAPI): void {
19
+ const prettyPi = pi as unknown as PiPrettyApi;
18
20
  clearHighlightCache();
19
21
 
20
22
  // ── Icon mode ───────────────────────────────────────────
@@ -25,5 +27,5 @@ export default function piPrettyExtension(pi: PiPrettyApi): void {
25
27
  // ── FFF slash commands ──────────────────────────────────────────────
26
28
  // fffState is a module-level singleton shared with pix-grep/pix-find.
27
29
  // Commands become available once pix-grep initialises the finder.
28
- registerFffCommands(pi, fffState);
30
+ registerFffCommands(prettyPi, fffState);
29
31
  }
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,6 +13,7 @@ import {
13
13
  renderCollapsedToolRow,
14
14
  renderDimPreview,
15
15
  ruleFrame,
16
+ sectionRule,
16
17
  setResultDetails,
17
18
  viewportText,
18
19
  } from "./utils.js";
@@ -402,4 +403,76 @@ describe("renderDimPreview", () => {
402
403
  expect(plain(raw)).toContain("call(foo)");
403
404
  expect(raw).toContain("\x1b[");
404
405
  });
406
+
407
+ it("highlights every regex match, not just a literal substring", () => {
408
+ // /te.t/ must light up both 'test' and 'text' — a literal indexOf can't.
409
+ const raw = renderDimPreview("test text", theme, { highlight: /te.t/g });
410
+ // Two bold-open codes = two highlighted hits.
411
+ expect(raw.split("\x1b[1m").length - 1).toBe(2);
412
+ expect(plain(raw)).toContain("test text");
413
+ });
414
+
415
+ it("does not loop on a zero-width regex match", () => {
416
+ // /x*/ matches empty everywhere — must terminate and keep content intact.
417
+ const raw = renderDimPreview("abc", theme, { highlight: /x*/g });
418
+ expect(plain(raw)).toContain("abc");
419
+ });
420
+
421
+ it("skips highlighting a line that already carries ANSI (no escape corruption)", () => {
422
+ // Pre-colored input: a match inside an escape would corrupt it, so the
423
+ // whole line is dimmed instead — no BOLD hit is injected.
424
+ const preColored = "\x1b[31mtest\x1b[0m done";
425
+ const raw = renderDimPreview(preColored, theme, { highlight: "test" });
426
+ expect(raw).not.toContain("\x1b[1m"); // no bold hit
427
+ expect(raw).toContain("\x1b[31m"); // original ANSI preserved
428
+ });
429
+
430
+ it("renders an === label === separator as a section divider", () => {
431
+ const raw = renderDimPreview("=== branch ===\nmain", theme, {});
432
+ // Label survives; the raw === markers are gone (replaced by a rule).
433
+ expect(plain(raw)).toContain("branch");
434
+ expect(plain(raw)).not.toContain("===");
435
+ expect(plain(raw)).toContain("─"); // divider glyph
436
+ expect(plain(raw)).toContain("main"); // ordinary lines untouched
437
+ });
438
+ });
439
+
440
+ describe("sectionRule", () => {
441
+ // Tagging theme so we can assert which role each fragment uses.
442
+ const tag: FgTheme = { fg: (key, text) => `<${key}>${text}</${key}>` };
443
+
444
+ it("left-aligns the label after a short lead rule, all muted", () => {
445
+ const out = sectionRule("=== versions ===", tag, 40) ?? "";
446
+ expect(out).not.toBeNull();
447
+ // One muted span wrapping the whole divider; label starts after 4 dashes.
448
+ expect(out).toBe("<muted>──── versions ──────────────────────────</muted>");
449
+ });
450
+
451
+ it("wraps an over-long label snugly with 2 dashes each side", () => {
452
+ const out = sectionRule("=== a very long section label here ===", tag, 20) ?? "";
453
+ expect(out).toBe("<muted>── a very long section label here ──</muted>");
454
+ });
455
+
456
+ it("accepts extra whitespace and 2+ equals signs", () => {
457
+ expect(sectionRule("== dirty? ==", tag, 40)).toContain("──── dirty? ");
458
+ expect(sectionRule("===== a b c =====", tag, 40)).toContain("──── a b c ");
459
+ });
460
+
461
+ it("returns null for a non-separator line", () => {
462
+ expect(sectionRule("just a normal line", tag, 20)).toBeNull();
463
+ expect(sectionRule("=== no closing", tag, 20)).toBeNull();
464
+ // Must span the whole line — leading text before the === disqualifies it.
465
+ expect(sectionRule("plain === middle === text", tag, 20)).toBeNull();
466
+ });
467
+
468
+ it("returns null when the line already carries ANSI", () => {
469
+ expect(sectionRule("\x1b[31m=== x ===\x1b[0m", tag, 20)).toBeNull();
470
+ });
471
+
472
+ it("caps the divider width on an ultra-wide terminal", () => {
473
+ // Ask for 400 columns — the visible width must stay capped (<= 72).
474
+ const out = sectionRule("=== x ===", tag, 400) ?? "";
475
+ const visible = out.replace(/<\/?[a-z]+>/g, "");
476
+ expect([...visible].length).toBeLessThanOrEqual(72);
477
+ });
405
478
  });
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";
@@ -323,28 +324,62 @@ export type DimPreviewOptions = {
323
324
  * the header is intentionally dropped (the collapsed row already carries the
324
325
  * count, so a floating header above the frame is redundant). */
325
326
  header?: string;
326
- /** Pattern whose matches are highlighted (green bold) inside dim lines. */
327
- highlight?: string;
327
+ /** Pattern whose matches are highlighted (bold, themed) inside dim lines.
328
+ * A string is matched literally (case-insensitive); a RegExp is used as-is
329
+ * (callers pass the compiled search pattern so regex greps highlight too). */
330
+ highlight?: string | RegExp;
328
331
  /** Wrap the body in a top/bottom rule frame (overflow below), like bash/ls/mcp. */
329
332
  frame?: boolean;
330
333
  /** Tint the frame rules (green ok, red error) — same status color as bash/ls. */
331
334
  paint?: RulePaint;
332
335
  };
333
336
 
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);
337
+ /** Compile a highlight pattern into a global RegExp, or null when it can't
338
+ * match anything. Strings match literally (case-insensitive); a RegExp is
339
+ * re-flagged global so every occurrence on a line lights up. */
340
+ function toHighlightRegex(pattern: string | RegExp): RegExp | null {
341
+ if (typeof pattern !== "string") {
342
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
343
+ try {
344
+ return new RegExp(pattern.source, flags);
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+ if (!pattern) return null;
350
+ // Literal string → escape regex metacharacters, case-insensitive.
351
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
352
+ try {
353
+ return new RegExp(escaped, "gi");
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
339
358
 
359
+ function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string | RegExp): string {
360
+ if (!pattern) return theme.fg("dim", line);
361
+ // A line that already carries ANSI (e.g. a source that emitted its own color)
362
+ // can't be safely index-sliced — a match could land inside an escape and
363
+ // corrupt it. Skip highlighting and dim the whole line instead.
364
+ if (hasAnsi(line)) return theme.fg("dim", line);
365
+ const re = pattern ? toHighlightRegex(pattern) : null;
366
+ if (!re) return theme.fg("dim", line);
367
+
368
+ // Themed bold hit: theme.fg carries its own reset, so re-open BOLD after it
369
+ // and close with the bold-off code (\x1b[22m) to avoid leaking bold onward.
370
+ const hit = (s: string) => `${BOLD}${theme.fg("success", s)}${BOLD_OFF}`;
340
371
  const parts: string[] = [];
341
372
  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;
373
+ for (let m = re.exec(line); m !== null; m = re.exec(line)) {
374
+ const idx = m.index;
375
+ const text = m[0];
376
+ if (text.length === 0) {
377
+ re.lastIndex++; // zero-width match (e.g. /a*/) advance to avoid a loop
378
+ continue;
379
+ }
380
+ if (idx > start) parts.push(theme.fg("dim", line.slice(start, idx)));
381
+ parts.push(hit(text));
382
+ start = idx + text.length;
348
383
  }
349
384
  if (start < line.length) parts.push(theme.fg("dim", line.slice(start)));
350
385
  return parts.length > 0 ? parts.join("") : theme.fg("dim", line);
@@ -359,9 +394,12 @@ export function renderDimPreview(
359
394
  const highlight = opts.highlight;
360
395
  const output = normalizeLineEndings(text).trim() || "done";
361
396
  const lines = output.split("\n");
397
+ const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
362
398
  const body = lines
363
399
  .slice(0, maxLines)
364
- .map((line) => ` ${dimLineWithHighlight(line, theme, highlight)}`);
400
+ .map(
401
+ (line) => ` ${sectionRule(line, theme, sw) ?? dimLineWithHighlight(line, theme, highlight)}`,
402
+ );
365
403
  const header = opts.header ? ` ${theme.fg("dim", opts.header)}` : undefined;
366
404
  const overflow =
367
405
  lines.length > maxLines
@@ -459,6 +497,35 @@ export function rule(w: number, paint?: RulePaint): string {
459
497
  return paint ? paint(glyphs) : `${FG_RULE}${glyphs}${RST}`;
460
498
  }
461
499
 
500
+ /** Matches an `=== label ===` separator line (a common shell/echo idiom). */
501
+ const SECTION_RE = /^\s*={2,}\s*(.+?)\s*={2,}\s*$/;
502
+
503
+ /** Max width of a section divider — a full-width rule on an ultra-wide
504
+ * terminal reads as noise, so cap it regardless of available columns. */
505
+ const SECTION_RULE_MAX = 72;
506
+
507
+ /** Fixed dash count before a left-aligned section label. */
508
+ const SECTION_RULE_LEAD = 4;
509
+
510
+ /**
511
+ * If `line` is an `=== label ===` separator, render it as a left-aligned
512
+ * section divider (`──── label ───────────`); otherwise return null. Callers
513
+ * fall back to their normal per-line rendering on null. Sized to `width` but
514
+ * capped at SECTION_RULE_MAX so it stays readable on wide terminals. Label and
515
+ * rule both use the muted role — theme-driven, ANSI-safe.
516
+ */
517
+ export function sectionRule(line: string, theme: FgTheme, width: number): string | null {
518
+ const m = SECTION_RE.exec(line);
519
+ if (!m || hasAnsi(line)) return null;
520
+ const label = ` ${m[1]} `;
521
+ const w = Math.min(width, SECTION_RULE_MAX);
522
+ const trail = w - 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