@xynogen/pix-pretty 1.17.2 → 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 +1 -1
- package/src/ansi.ts +9 -0
- package/src/icons.test.ts +18 -1
- package/src/icons.ts +13 -0
- package/src/renderers.ts +10 -3
- package/src/types.ts +4 -0
- package/src/utils.test.ts +73 -0
- package/src/utils.ts +82 -15
package/package.json
CHANGED
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/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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
327
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
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
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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
|