@xynogen/pix-pretty 1.7.21 → 1.7.22
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/README.md +10 -4
- package/package.json +3 -3
- package/src/ansi.test.ts +27 -0
- package/src/ansi.ts +12 -2
- package/src/dependency-security.test.ts +32 -0
- package/src/diff-render.ts +116 -90
- package/src/diff.test.ts +36 -1
- package/src/gate-overlay.test.ts +33 -21
- package/src/highlight.test.ts +26 -0
- package/src/highlight.ts +62 -3
- package/src/icons.test.ts +18 -0
- package/src/icons.ts +86 -104
- package/src/renderers.ts +20 -19
- package/src/tools/context.ts +2 -0
- package/src/types.ts +2 -2
- package/src/utils.ts +4 -4
package/README.md
CHANGED
|
@@ -71,17 +71,23 @@ Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file h
|
|
|
71
71
|
```jsonc
|
|
72
72
|
{
|
|
73
73
|
"pretty": {
|
|
74
|
-
"syntaxTheme": "monokai", // syntax-highlight theme
|
|
75
74
|
"icons": "nerd", // nerd | unicode | ascii
|
|
76
|
-
"maxPreviewLines":
|
|
77
|
-
"
|
|
75
|
+
"maxPreviewLines": 80,
|
|
76
|
+
"maxRenderLines": 150,
|
|
77
|
+
"maxHighlightChars": 80000,
|
|
78
|
+
"cacheLimit": 128,
|
|
79
|
+
"diff": {
|
|
80
|
+
"splitMinWidth": 150,
|
|
81
|
+
"splitMinCodeWidth": 60
|
|
82
|
+
}
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
```
|
|
81
86
|
|
|
87
|
+
Syntax highlighting, diffs, and tool surfaces use the active Pi theme. Color overrides do not live in `pix.json`.
|
|
88
|
+
|
|
82
89
|
### Environment Variables (override `pix.json`)
|
|
83
90
|
|
|
84
|
-
- `PRETTY_THEME` — color theme for syntax highlighting
|
|
85
91
|
- `PRETTY_MAX_HL_CHARS` — max characters to highlight (default: 80000)
|
|
86
92
|
- `PRETTY_MAX_PREVIEW_LINES` — max lines in preview output
|
|
87
93
|
- `PRETTY_CACHE_LIMIT` — FFF cache size limit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-pretty",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.22",
|
|
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",
|
|
@@ -58,10 +58,10 @@
|
|
|
58
58
|
"access": "public"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@xynogen/pix-data": "^0.3.
|
|
61
|
+
"@xynogen/pix-data": "^0.3.4",
|
|
62
62
|
"cli-highlight": "^2.1.11",
|
|
63
63
|
"@ff-labs/fff-node": "^0.5.2",
|
|
64
|
-
"diff": "^
|
|
64
|
+
"diff": "^8.0.3"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/ansi.test.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as ansi from "./ansi.ts";
|
|
3
|
+
import { resolveBaseBackground } from "./ansi.ts";
|
|
4
|
+
|
|
5
|
+
const bg = (r: number, g: number, b: number) => `\x1b[48;2;${r};${g};${b}m`;
|
|
6
|
+
|
|
7
|
+
describe("themed tool surfaces", () => {
|
|
8
|
+
test("uses semantic success and error backgrounds from the active theme", () => {
|
|
9
|
+
resolveBaseBackground({
|
|
10
|
+
getBgAnsi: (key) => {
|
|
11
|
+
if (key === "toolSuccessBg") return bg(10, 20, 30);
|
|
12
|
+
if (key === "toolErrorBg") return bg(40, 50, 60);
|
|
13
|
+
return "";
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
expect(ansi.BG_BASE).toBe(bg(10, 20, 30));
|
|
17
|
+
expect(ansi.BG_ERROR).toBe(bg(40, 50, 60));
|
|
18
|
+
expect(ansi.RST).toContain(bg(10, 20, 30));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("resets stale theme backgrounds when no raw theme accessor exists", () => {
|
|
22
|
+
resolveBaseBackground(undefined);
|
|
23
|
+
expect(ansi.BG_BASE).toBe("\x1b[49m");
|
|
24
|
+
expect(ansi.BG_ERROR).toBe("\x1b[49m");
|
|
25
|
+
expect(ansi.RST).toBe("\x1b[0m");
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/ansi.ts
CHANGED
|
@@ -34,9 +34,19 @@ function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
|
|
|
34
34
|
/** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
|
|
35
35
|
* Recompute on each render so runtime theme changes are respected. */
|
|
36
36
|
export function resolveBaseBackground(theme: BgTheme | null | undefined): void {
|
|
37
|
-
if (!theme?.getBgAnsi)
|
|
37
|
+
if (!theme?.getBgAnsi) {
|
|
38
|
+
BG_BASE = BG_DEFAULT;
|
|
39
|
+
BG_ERROR = BG_DEFAULT;
|
|
40
|
+
RST = "\x1b[0m";
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
38
43
|
|
|
39
|
-
BG_BASE =
|
|
44
|
+
BG_BASE =
|
|
45
|
+
getThemeBgAnsi(theme, "toolSuccessBg") ??
|
|
46
|
+
getThemeBgAnsi(theme, "toolPendingBg") ??
|
|
47
|
+
getThemeBgAnsi(theme, "toolBg") ??
|
|
48
|
+
getThemeBgAnsi(theme, "background") ??
|
|
49
|
+
BG_DEFAULT;
|
|
40
50
|
BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
|
|
41
51
|
RST = `\x1b[0m${BG_BASE}`;
|
|
42
52
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
|
+
const repoRoot = dirname(dirname(packageRoot));
|
|
8
|
+
|
|
9
|
+
interface PackageManifest {
|
|
10
|
+
dependencies?: Record<string, string>;
|
|
11
|
+
overrides?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readManifest(path: string): PackageManifest {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
throw new Error(`Unable to read package manifest: ${path}`, { cause });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("dependency security floors", () => {
|
|
23
|
+
it("uses a jsdiff release without GHSA-73rr-hh4g-fpgx", () => {
|
|
24
|
+
const manifest = readManifest(join(packageRoot, "package.json"));
|
|
25
|
+
expect(manifest.dependencies?.diff).toBe("^8.0.3");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("pins protobufjs above GHSA-j3f2-48v5-ccww", () => {
|
|
29
|
+
const manifest = readManifest(join(repoRoot, "package.json"));
|
|
30
|
+
expect(manifest.overrides?.protobufjs).toBe("7.6.5");
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/diff-render.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { BG_BASE, BOLD, FG_DIM, FG_LNUM, FG_RULE, RST } from "./ansi.js";
|
|
|
14
14
|
import { MAX_HL_CHARS, MAX_RENDER_LINES, WORD_DIFF_MIN_SIM } from "./config.js";
|
|
15
15
|
import type { DiffLine, ParsedDiff } from "./diff.js";
|
|
16
16
|
import { hlBlock } from "./highlight.js";
|
|
17
|
-
import type { BundledLanguage } from "./types.js";
|
|
17
|
+
import type { BundledLanguage, FgTheme } from "./types.js";
|
|
18
18
|
import { termW as utilsTermW } from "./utils.js";
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
@@ -26,59 +26,22 @@ function envInt(name: string, fallback: number): number {
|
|
|
26
26
|
return Number.isFinite(v) ? v : fallback;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function envFg(name: string, fallback: string): string {
|
|
30
|
-
const hex = process.env[name];
|
|
31
|
-
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
|
|
32
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
33
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
34
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
35
|
-
return `\x1b[38;2;${r};${g};${b}m`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function envBg(name: string, fallback: string): string {
|
|
39
|
-
const hex = process.env[name];
|
|
40
|
-
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
|
|
41
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
42
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
43
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
44
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
29
|
// ---------------------------------------------------------------------------
|
|
48
|
-
// Diff-specific ANSI
|
|
30
|
+
// Diff-specific ANSI. Theme-backed colors are resolved per render; these
|
|
31
|
+
// constants are readable fallbacks for hosts that do not expose raw theme ANSI.
|
|
49
32
|
// ---------------------------------------------------------------------------
|
|
50
33
|
|
|
51
34
|
const DIM = "\x1b[2m";
|
|
52
|
-
|
|
53
|
-
function hexToBg(hex: string): string {
|
|
54
|
-
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
|
|
55
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
56
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
57
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
58
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function hexToFg(hex: string): string {
|
|
62
|
-
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
|
|
63
|
-
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
64
|
-
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
65
|
-
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
66
|
-
return `\x1b[38;2;${r};${g};${b}m`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
35
|
const dc = pixConfig().pretty.diff;
|
|
70
36
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
const FG_ADD = envFg("DIFF_FG_ADD", hexToFg(dc.fgAdd) || "\x1b[38;2;100;180;120m");
|
|
81
|
-
const FG_DEL = envFg("DIFF_FG_DEL", hexToFg(dc.fgDel) || "\x1b[38;2;200;100;100m");
|
|
37
|
+
const FALLBACK_BG_ADD = "\x1b[48;2;22;38;32m";
|
|
38
|
+
const FALLBACK_BG_DEL = "\x1b[48;2;45;25;25m";
|
|
39
|
+
const FALLBACK_BG_ADD_W = "\x1b[48;2;35;75;50m";
|
|
40
|
+
const FALLBACK_BG_DEL_W = "\x1b[48;2;80;35;35m";
|
|
41
|
+
const FALLBACK_BG_GUTTER_ADD = "\x1b[48;2;18;32;26m";
|
|
42
|
+
const FALLBACK_BG_GUTTER_DEL = "\x1b[48;2;38;22;22m";
|
|
43
|
+
const FG_ADD = "\x1b[38;2;100;180;120m";
|
|
44
|
+
const FG_DEL = "\x1b[38;2;200;100;100m";
|
|
82
45
|
const FG_STRIPE = "\x1b[38;2;40;40;40m"; // diagonal stripes on filler cells
|
|
83
46
|
|
|
84
47
|
const BORDER_BAR = "▌";
|
|
@@ -113,12 +76,26 @@ export interface DiffColors {
|
|
|
113
76
|
fgAdd: string;
|
|
114
77
|
fgDel: string;
|
|
115
78
|
fgCtx: string;
|
|
79
|
+
/** Active Pi theme, forwarded to syntax highlighting. */
|
|
80
|
+
theme?: FgTheme;
|
|
81
|
+
bgAdd: string;
|
|
82
|
+
bgDel: string;
|
|
83
|
+
bgAddHighlight: string;
|
|
84
|
+
bgDelHighlight: string;
|
|
85
|
+
bgGutterAdd: string;
|
|
86
|
+
bgGutterDel: string;
|
|
116
87
|
}
|
|
117
88
|
|
|
118
89
|
export const DEFAULT_DIFF_COLORS: DiffColors = {
|
|
119
90
|
fgAdd: FG_ADD,
|
|
120
91
|
fgDel: FG_DEL,
|
|
121
92
|
fgCtx: FG_DIM,
|
|
93
|
+
bgAdd: FALLBACK_BG_ADD,
|
|
94
|
+
bgDel: FALLBACK_BG_DEL,
|
|
95
|
+
bgAddHighlight: FALLBACK_BG_ADD_W,
|
|
96
|
+
bgDelHighlight: FALLBACK_BG_DEL_W,
|
|
97
|
+
bgGutterAdd: FALLBACK_BG_GUTTER_ADD,
|
|
98
|
+
bgGutterDel: FALLBACK_BG_GUTTER_DEL,
|
|
122
99
|
};
|
|
123
100
|
|
|
124
101
|
// --- contrast helpers -------------------------------------------------------
|
|
@@ -171,23 +148,53 @@ function ensureContrast(fg: string, bgSeq: string, min = 3): string {
|
|
|
171
148
|
*
|
|
172
149
|
* Theme hue is preserved, but each add/del fg is contrast-checked against the
|
|
173
150
|
* gutter bg it is painted on and lifted if it would render too dark to read. */
|
|
174
|
-
|
|
175
|
-
|
|
151
|
+
type DiffTheme = {
|
|
152
|
+
fg?: FgTheme["fg"];
|
|
153
|
+
getFgAnsi?: (key: string) => string;
|
|
154
|
+
getBgAnsi?: (key: string) => string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function themeFg(theme: DiffTheme, key: string, fallback: string): string {
|
|
176
158
|
try {
|
|
177
|
-
return
|
|
178
|
-
fgAdd: ensureContrast(theme.getFgAnsi("toolDiffAdded") || FG_ADD, BG_GUTTER_ADD),
|
|
179
|
-
fgDel: ensureContrast(theme.getFgAnsi("toolDiffRemoved") || FG_DEL, BG_GUTTER_DEL),
|
|
180
|
-
fgCtx: theme.getFgAnsi("toolDiffContext") || FG_DIM,
|
|
181
|
-
};
|
|
159
|
+
return theme.getFgAnsi?.(key) || fallback;
|
|
182
160
|
} catch {
|
|
183
|
-
return
|
|
161
|
+
return fallback;
|
|
184
162
|
}
|
|
185
163
|
}
|
|
186
164
|
|
|
165
|
+
function tintedBackground(fg: string, strength: number, fallback: string): string {
|
|
166
|
+
const rgb = parseAnsiRgb(fg, "38");
|
|
167
|
+
if (!rgb) return fallback;
|
|
168
|
+
const [r, g, b] = rgb.map((channel) => Math.round(channel * strength)) as Rgb;
|
|
169
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function resolveDiffColors(theme?: DiffTheme): DiffColors {
|
|
173
|
+
if (!theme) return DEFAULT_DIFF_COLORS;
|
|
174
|
+
const rawFgAdd = themeFg(theme, "toolDiffAdded", FG_ADD);
|
|
175
|
+
const rawFgDel = themeFg(theme, "toolDiffRemoved", FG_DEL);
|
|
176
|
+
const bgAdd = tintedBackground(rawFgAdd, 0.2, FALLBACK_BG_ADD);
|
|
177
|
+
const bgDel = tintedBackground(rawFgDel, 0.2, FALLBACK_BG_DEL);
|
|
178
|
+
const bgGutterAdd = tintedBackground(rawFgAdd, 0.15, FALLBACK_BG_GUTTER_ADD);
|
|
179
|
+
const bgGutterDel = tintedBackground(rawFgDel, 0.15, FALLBACK_BG_GUTTER_DEL);
|
|
180
|
+
return {
|
|
181
|
+
fgAdd: ensureContrast(rawFgAdd, bgGutterAdd),
|
|
182
|
+
fgDel: ensureContrast(rawFgDel, bgGutterDel),
|
|
183
|
+
fgCtx: themeFg(theme, "toolDiffContext", FG_DIM),
|
|
184
|
+
theme: typeof theme.fg === "function" ? (theme as FgTheme) : undefined,
|
|
185
|
+
bgAdd,
|
|
186
|
+
bgDel,
|
|
187
|
+
bgAddHighlight: tintedBackground(rawFgAdd, 0.35, FALLBACK_BG_ADD_W),
|
|
188
|
+
bgDelHighlight: tintedBackground(rawFgDel, 0.35, FALLBACK_BG_DEL_W),
|
|
189
|
+
bgGutterAdd,
|
|
190
|
+
bgGutterDel,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
187
194
|
/** Stable cache key for the resolved diff theme colors. */
|
|
188
|
-
export function diffThemeCacheKey(theme?:
|
|
189
|
-
const
|
|
190
|
-
return `${
|
|
195
|
+
export function diffThemeCacheKey(theme?: DiffTheme): string {
|
|
196
|
+
const { theme: _theme, ...colors } = resolveDiffColors(theme);
|
|
197
|
+
return `${Object.values(colors).join("|")}|${BG_BASE}`;
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
// ---------------------------------------------------------------------------
|
|
@@ -371,12 +378,27 @@ function rule(w: number): string {
|
|
|
371
378
|
return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`;
|
|
372
379
|
}
|
|
373
380
|
|
|
374
|
-
/** Compact "+a -d" summary
|
|
381
|
+
/** Compact plain "+a -d" summary for persisted renderer details. */
|
|
375
382
|
export function summarize(a: number, d: number): string {
|
|
376
|
-
const
|
|
377
|
-
if (a > 0)
|
|
378
|
-
if (d > 0)
|
|
379
|
-
return
|
|
383
|
+
const parts: string[] = [];
|
|
384
|
+
if (a > 0) parts.push(`+${a}`);
|
|
385
|
+
if (d > 0) parts.push(`-${d}`);
|
|
386
|
+
return parts.length ? parts.join(" ") : "no changes";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Apply active Pi theme colors to a plain diff summary at render time. */
|
|
390
|
+
export function renderDiffSummary(summary: string, theme: FgTheme): string {
|
|
391
|
+
if (summary === "no changes") return theme.fg("toolDiffContext", summary);
|
|
392
|
+
return summary
|
|
393
|
+
.split(" ")
|
|
394
|
+
.map((part) =>
|
|
395
|
+
part.startsWith("+")
|
|
396
|
+
? theme.fg("toolDiffAdded", part)
|
|
397
|
+
: part.startsWith("-")
|
|
398
|
+
? theme.fg("toolDiffRemoved", part)
|
|
399
|
+
: part,
|
|
400
|
+
)
|
|
401
|
+
.join(" ");
|
|
380
402
|
}
|
|
381
403
|
|
|
382
404
|
// ---------------------------------------------------------------------------
|
|
@@ -467,13 +489,17 @@ function injectBg(
|
|
|
467
489
|
}
|
|
468
490
|
|
|
469
491
|
/** Simple word diff (no syntax hl) — fallback when highlighting is unavailable. */
|
|
470
|
-
function plainWordDiff(
|
|
492
|
+
function plainWordDiff(
|
|
493
|
+
oldText: string,
|
|
494
|
+
newText: string,
|
|
495
|
+
dc: DiffColors,
|
|
496
|
+
): { old: string; new: string } {
|
|
471
497
|
const parts = Diff.diffWords(oldText, newText);
|
|
472
498
|
let o = "";
|
|
473
499
|
let n = "";
|
|
474
500
|
for (const p of parts) {
|
|
475
|
-
if (p.removed) o += `${
|
|
476
|
-
else if (p.added) n += `${
|
|
501
|
+
if (p.removed) o += `${dc.bgDelHighlight}${p.value}${RST}${dc.bgDel}`;
|
|
502
|
+
else if (p.added) n += `${dc.bgAddHighlight}${p.value}${RST}${dc.bgAdd}`;
|
|
477
503
|
else {
|
|
478
504
|
o += p.value;
|
|
479
505
|
n += p.value;
|
|
@@ -548,8 +574,8 @@ export async function renderUnified(
|
|
|
548
574
|
}
|
|
549
575
|
const [oldHL, newHL] = canHL
|
|
550
576
|
? await Promise.all([
|
|
551
|
-
hlBlock(oldSrc.join("\n"), language),
|
|
552
|
-
hlBlock(newSrc.join("\n"), language),
|
|
577
|
+
hlBlock(oldSrc.join("\n"), language, dc.theme),
|
|
578
|
+
hlBlock(newSrc.join("\n"), language, dc.theme),
|
|
553
579
|
])
|
|
554
580
|
: [oldSrc, newSrc];
|
|
555
581
|
|
|
@@ -634,42 +660,42 @@ export async function renderUnified(
|
|
|
634
660
|
if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
|
|
635
661
|
const del0 = at(dels, 0);
|
|
636
662
|
const add0 = at(adds, 0);
|
|
637
|
-
const delBody = injectBg(del0.hl, wd.oldRanges,
|
|
638
|
-
const addBody = injectBg(add0.hl, wd.newRanges,
|
|
639
|
-
emitRow(del0.l.oldNum, "-",
|
|
640
|
-
emitRow(add0.l.newNum, "+",
|
|
663
|
+
const delBody = injectBg(del0.hl, wd.oldRanges, dc.bgDel, dc.bgDelHighlight);
|
|
664
|
+
const addBody = injectBg(add0.hl, wd.newRanges, dc.bgAdd, dc.bgAddHighlight);
|
|
665
|
+
emitRow(del0.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, delBody, dc.bgDel);
|
|
666
|
+
emitRow(add0.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, addBody, dc.bgAdd);
|
|
641
667
|
continue;
|
|
642
668
|
}
|
|
643
669
|
if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
644
670
|
const del0 = at(dels, 0);
|
|
645
671
|
const add0 = at(adds, 0);
|
|
646
|
-
const pwd = plainWordDiff(del0.l.content, add0.l.content);
|
|
672
|
+
const pwd = plainWordDiff(del0.l.content, add0.l.content, dc);
|
|
647
673
|
emitRow(
|
|
648
674
|
del0.l.oldNum,
|
|
649
675
|
"-",
|
|
650
|
-
|
|
676
|
+
dc.bgGutterDel,
|
|
651
677
|
`${dc.fgDel}${BOLD}`,
|
|
652
|
-
`${
|
|
653
|
-
|
|
678
|
+
`${dc.bgDel}${pwd.old}`,
|
|
679
|
+
dc.bgDel,
|
|
654
680
|
);
|
|
655
681
|
emitRow(
|
|
656
682
|
add0.l.newNum,
|
|
657
683
|
"+",
|
|
658
|
-
|
|
684
|
+
dc.bgGutterAdd,
|
|
659
685
|
`${dc.fgAdd}${BOLD}`,
|
|
660
|
-
`${
|
|
661
|
-
|
|
686
|
+
`${dc.bgAdd}${pwd.new}`,
|
|
687
|
+
dc.bgAdd,
|
|
662
688
|
);
|
|
663
689
|
continue;
|
|
664
690
|
}
|
|
665
691
|
|
|
666
692
|
for (const d of dels) {
|
|
667
|
-
const body = canHL ? `${
|
|
668
|
-
emitRow(d.l.oldNum, "-",
|
|
693
|
+
const body = canHL ? `${dc.bgDel}${d.hl}` : `${dc.bgDel}${d.l.content}`;
|
|
694
|
+
emitRow(d.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, body, dc.bgDel);
|
|
669
695
|
}
|
|
670
696
|
for (const a of adds) {
|
|
671
|
-
const body = canHL ? `${
|
|
672
|
-
emitRow(a.l.newNum, "+",
|
|
697
|
+
const body = canHL ? `${dc.bgAdd}${a.hl}` : `${dc.bgAdd}${a.l.content}`;
|
|
698
|
+
emitRow(a.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, body, dc.bgAdd);
|
|
673
699
|
}
|
|
674
700
|
}
|
|
675
701
|
|
|
@@ -740,8 +766,8 @@ export async function renderSplit(
|
|
|
740
766
|
}
|
|
741
767
|
const [leftHL, rightHL] = canHL
|
|
742
768
|
? await Promise.all([
|
|
743
|
-
hlBlock(leftSrc.join("\n"), language),
|
|
744
|
-
hlBlock(rightSrc.join("\n"), language),
|
|
769
|
+
hlBlock(leftSrc.join("\n"), language, dc.theme),
|
|
770
|
+
hlBlock(rightSrc.join("\n"), language, dc.theme),
|
|
745
771
|
])
|
|
746
772
|
: [leftSrc, rightSrc];
|
|
747
773
|
|
|
@@ -776,8 +802,8 @@ export async function renderSplit(
|
|
|
776
802
|
|
|
777
803
|
const isDel = line.type === "del";
|
|
778
804
|
const isAdd = line.type === "add";
|
|
779
|
-
const gBg = isDel ?
|
|
780
|
-
const cBg = isDel ?
|
|
805
|
+
const gBg = isDel ? dc.bgGutterDel : isAdd ? dc.bgGutterAdd : BG_BASE;
|
|
806
|
+
const cBg = isDel ? dc.bgDel : isAdd ? dc.bgAdd : BG_BASE;
|
|
781
807
|
const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
|
|
782
808
|
const sign = isDel ? "-" : isAdd ? "+" : " ";
|
|
783
809
|
const num = isDel
|
|
@@ -793,7 +819,7 @@ export async function renderSplit(
|
|
|
793
819
|
|
|
794
820
|
let body: string;
|
|
795
821
|
if (ranges && ranges.length > 0) {
|
|
796
|
-
body = injectBg(hl, ranges, cBg, isDel ?
|
|
822
|
+
body = injectBg(hl, ranges, cBg, isDel ? dc.bgDelHighlight : dc.bgAddHighlight);
|
|
797
823
|
} else if (isDel || isAdd) {
|
|
798
824
|
body = `${cBg}${hl}`;
|
|
799
825
|
} else {
|
|
@@ -828,7 +854,7 @@ export async function renderSplit(
|
|
|
828
854
|
lResult = halfBuild(leftLine, lhl, wd.oldRanges, "left");
|
|
829
855
|
rResult = halfBuild(rightLine, rhl, wd.newRanges, "right");
|
|
830
856
|
} else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
831
|
-
const pwd = plainWordDiff(leftLine.content, rightLine.content);
|
|
857
|
+
const pwd = plainWordDiff(leftLine.content, rightLine.content, dc);
|
|
832
858
|
lI++;
|
|
833
859
|
rI++;
|
|
834
860
|
lResult = halfBuild(leftLine, pwd.old, null, "left");
|
package/src/diff.test.ts
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
|
|
3
2
|
import { parseDiff } from "./diff.js";
|
|
3
|
+
import { diffThemeCacheKey, renderDiffSummary, resolveDiffColors } from "./diff-render.js";
|
|
4
4
|
|
|
5
5
|
const OLD = "line1\nline2\nline3";
|
|
6
6
|
const NEW = "line1\nCHANGED\nline3";
|
|
7
7
|
|
|
8
|
+
describe("theme-derived diff rendering", () => {
|
|
9
|
+
const theme = {
|
|
10
|
+
fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
|
|
11
|
+
getFgAnsi: (key: string) => {
|
|
12
|
+
if (key === "toolDiffAdded") return "\x1b[38;2;120;210;150m";
|
|
13
|
+
if (key === "toolDiffRemoved") return "\x1b[38;2;230;120;130m";
|
|
14
|
+
if (key === "toolDiffContext") return "\x1b[38;2;130;140;150m";
|
|
15
|
+
return "";
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
it("derives foregrounds and tint backgrounds from semantic theme tokens", () => {
|
|
20
|
+
const colors = resolveDiffColors(theme);
|
|
21
|
+
expect(colors.fgAdd).toBe("\x1b[38;2;120;210;150m");
|
|
22
|
+
expect(colors.fgDel).toBe("\x1b[38;2;230;120;130m");
|
|
23
|
+
expect(colors.fgCtx).toBe("\x1b[38;2;130;140;150m");
|
|
24
|
+
expect(colors.bgAdd).toBe("\x1b[48;2;24;42;30m");
|
|
25
|
+
expect(colors.bgDel).toBe("\x1b[48;2;46;24;26m");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("includes semantic theme colors in cache identity", () => {
|
|
29
|
+
const changed = { ...theme, getFgAnsi: () => "\x1b[38;2;1;2;3m" };
|
|
30
|
+
expect(diffThemeCacheKey(theme)).not.toBe(diffThemeCacheKey(changed));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("colors persisted plain summaries only at render time", () => {
|
|
34
|
+
expect(renderDiffSummary("+3 -2", theme)).toBe(
|
|
35
|
+
"<toolDiffAdded>+3</toolDiffAdded> <toolDiffRemoved>-2</toolDiffRemoved>",
|
|
36
|
+
);
|
|
37
|
+
expect(renderDiffSummary("no changes", theme)).toBe(
|
|
38
|
+
"<toolDiffContext>no changes</toolDiffContext>",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
8
43
|
describe("parseDiff baseLine", () => {
|
|
9
44
|
it("is snippet-relative when baseLine omitted (default 0)", () => {
|
|
10
45
|
const { lines } = parseDiff(OLD, NEW);
|
package/src/gate-overlay.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, jest, test } from "bun:test";
|
|
2
2
|
import { type OverlayUI, showOverlay } from "./gate-overlay.ts";
|
|
3
3
|
|
|
4
4
|
// ── Mock host ─────────────────────────────────────────────────────────────────
|
|
@@ -240,28 +240,40 @@ function makeTimerUI(onReady?: (comp: Wired) => void): OverlayUI {
|
|
|
240
240
|
|
|
241
241
|
describe("showOverlay — auto-deny timer", () => {
|
|
242
242
|
test("expires to timeout when left untouched", async () => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
243
|
+
jest.useFakeTimers();
|
|
244
|
+
try {
|
|
245
|
+
const pending = showOverlay(makeTimerUI(), {
|
|
246
|
+
mode: "confirm",
|
|
247
|
+
title: "T",
|
|
248
|
+
timeoutMs: 1000, // ceil → 1s, fires on first tick
|
|
249
|
+
});
|
|
250
|
+
jest.advanceTimersByTime(1000); // fire the auto-deny tick without a real wait
|
|
251
|
+
const result = await pending;
|
|
252
|
+
expect(result.action).toBe("timeout");
|
|
253
|
+
} finally {
|
|
254
|
+
jest.useRealTimers();
|
|
255
|
+
}
|
|
249
256
|
});
|
|
250
257
|
|
|
251
258
|
test("first keypress cancels the timer (no auto-deny)", async () => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
comp
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
259
|
+
jest.useFakeTimers();
|
|
260
|
+
try {
|
|
261
|
+
let live: Wired | undefined;
|
|
262
|
+
const pending = showOverlay(
|
|
263
|
+
makeTimerUI((comp) => {
|
|
264
|
+
live = comp;
|
|
265
|
+
comp.handleInput(DOWN); // any key — cancels the dead-man's switch
|
|
266
|
+
}),
|
|
267
|
+
{ mode: "confirm", title: "T", timeoutMs: 1000 },
|
|
268
|
+
);
|
|
269
|
+
// Advance well past the 1s window. A live timer would have resolved
|
|
270
|
+
// "timeout"; the keypress cancelled it, so the promise stays pending.
|
|
271
|
+
jest.advanceTimersByTime(1300);
|
|
272
|
+
live?.handleInput(ENTER); // now deny explicitly
|
|
273
|
+
const result = await pending;
|
|
274
|
+
expect(result.action).toBe("denied");
|
|
275
|
+
} finally {
|
|
276
|
+
jest.useRealTimers();
|
|
277
|
+
}
|
|
266
278
|
});
|
|
267
279
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { _cache, clearHighlightCache, hlBlock } from "./highlight.ts";
|
|
3
|
+
|
|
4
|
+
function theme(color: string) {
|
|
5
|
+
return {
|
|
6
|
+
fg: (key: string, text: string) => `\x1b[38;2;${color}m${key}:${text}\x1b[0m`,
|
|
7
|
+
getFgAnsi: (key: string) => `\x1b[38;2;${color}m:${key}`,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("active-theme syntax highlighting", () => {
|
|
12
|
+
beforeEach(() => clearHighlightCache());
|
|
13
|
+
|
|
14
|
+
test("maps JSON scopes to semantic Pi syntax roles", async () => {
|
|
15
|
+
const out = (await hlBlock('{"name":"pix","count":2}', "json", theme("10;20;30"))).join("\n");
|
|
16
|
+
expect(out).toContain("syntaxVariable");
|
|
17
|
+
expect(out).toContain("syntaxString");
|
|
18
|
+
expect(out).toContain("syntaxNumber");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("separates cached output by active theme colors", async () => {
|
|
22
|
+
await hlBlock("const value = 1", "typescript", theme("10;20;30"));
|
|
23
|
+
await hlBlock("const value = 1", "typescript", theme("30;40;50"));
|
|
24
|
+
expect(_cache.size).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/highlight.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeShikiContrast } from "./ansi.js";
|
|
2
2
|
import { CACHE_LIMIT, MAX_HL_CHARS } from "./config.js";
|
|
3
|
-
import type { BundledLanguage } from "./types.js";
|
|
3
|
+
import type { BundledLanguage, FgTheme } from "./types.js";
|
|
4
4
|
|
|
5
5
|
// Engine: cli-highlight (highlight.js-backed, synchronous ANSI output).
|
|
6
6
|
//
|
|
@@ -63,6 +63,60 @@ function toHljsLang(language: BundledLanguage): string | undefined {
|
|
|
63
63
|
return hl.supportsLanguage(mapped) ? mapped : undefined;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
type HighlightTheme = Record<string, (text: string) => string>;
|
|
67
|
+
|
|
68
|
+
const SYNTAX_THEME_KEYS = [
|
|
69
|
+
"syntaxComment",
|
|
70
|
+
"syntaxKeyword",
|
|
71
|
+
"syntaxFunction",
|
|
72
|
+
"syntaxVariable",
|
|
73
|
+
"syntaxString",
|
|
74
|
+
"syntaxNumber",
|
|
75
|
+
"syntaxType",
|
|
76
|
+
"syntaxOperator",
|
|
77
|
+
"syntaxPunctuation",
|
|
78
|
+
] as const;
|
|
79
|
+
|
|
80
|
+
function highlightThemeKey(theme?: FgTheme): string {
|
|
81
|
+
if (!theme?.getFgAnsi) return "default";
|
|
82
|
+
try {
|
|
83
|
+
return SYNTAX_THEME_KEYS.map((key) => theme.getFgAnsi?.(key) ?? "").join("|");
|
|
84
|
+
} catch {
|
|
85
|
+
return "default";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Map highlight.js scopes onto the active Pi theme's semantic syntax colors. */
|
|
90
|
+
function buildHighlightTheme(theme?: FgTheme): HighlightTheme | undefined {
|
|
91
|
+
if (!theme) return undefined;
|
|
92
|
+
const fg = (key: string) => (text: string) => theme.fg(key, text);
|
|
93
|
+
return {
|
|
94
|
+
keyword: fg("syntaxKeyword"),
|
|
95
|
+
built_in: fg("syntaxType"),
|
|
96
|
+
literal: fg("syntaxKeyword"),
|
|
97
|
+
number: fg("syntaxNumber"),
|
|
98
|
+
regexp: fg("syntaxString"),
|
|
99
|
+
string: fg("syntaxString"),
|
|
100
|
+
comment: fg("syntaxComment"),
|
|
101
|
+
doctag: fg("syntaxComment"),
|
|
102
|
+
meta: fg("syntaxComment"),
|
|
103
|
+
function: fg("syntaxFunction"),
|
|
104
|
+
title: fg("syntaxFunction"),
|
|
105
|
+
class: fg("syntaxType"),
|
|
106
|
+
type: fg("syntaxType"),
|
|
107
|
+
tag: fg("syntaxPunctuation"),
|
|
108
|
+
name: fg("syntaxKeyword"),
|
|
109
|
+
attr: fg("syntaxVariable"),
|
|
110
|
+
attribute: fg("syntaxVariable"),
|
|
111
|
+
variable: fg("syntaxVariable"),
|
|
112
|
+
params: fg("syntaxVariable"),
|
|
113
|
+
operator: fg("syntaxOperator"),
|
|
114
|
+
punctuation: fg("syntaxPunctuation"),
|
|
115
|
+
addition: fg("toolDiffAdded"),
|
|
116
|
+
deletion: fg("toolDiffRemoved"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
66
120
|
export const _cache = new Map<string, string[]>();
|
|
67
121
|
|
|
68
122
|
function _touch(k: string, v: string[]): string[] {
|
|
@@ -81,6 +135,7 @@ function _touch(k: string, v: string[]): string[] {
|
|
|
81
135
|
export async function hlBlock(
|
|
82
136
|
code: string,
|
|
83
137
|
language: BundledLanguage | undefined,
|
|
138
|
+
theme?: FgTheme,
|
|
84
139
|
): Promise<string[]> {
|
|
85
140
|
if (!code) return [""];
|
|
86
141
|
if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
|
|
@@ -88,7 +143,7 @@ export async function hlBlock(
|
|
|
88
143
|
const hljsLang = toHljsLang(language);
|
|
89
144
|
if (!hljsLang) return code.split("\n");
|
|
90
145
|
|
|
91
|
-
const k = `${hljsLang}\0${code}`;
|
|
146
|
+
const k = `${hljsLang}\0${highlightThemeKey(theme)}\0${code}`;
|
|
92
147
|
const hit = _cache.get(k);
|
|
93
148
|
if (hit) return _touch(k, hit);
|
|
94
149
|
|
|
@@ -97,7 +152,11 @@ export async function hlBlock(
|
|
|
97
152
|
|
|
98
153
|
try {
|
|
99
154
|
const ansi = normalizeShikiContrast(
|
|
100
|
-
hl.highlight(code, {
|
|
155
|
+
hl.highlight(code, {
|
|
156
|
+
language: hljsLang,
|
|
157
|
+
ignoreIllegals: true,
|
|
158
|
+
theme: buildHighlightTheme(theme),
|
|
159
|
+
}),
|
|
101
160
|
);
|
|
102
161
|
const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
|
|
103
162
|
return _touch(k, out);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { dirIcon, fileIcon } from "./icons.ts";
|
|
3
|
+
|
|
4
|
+
const theme = {
|
|
5
|
+
fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
describe("theme-derived file icons", () => {
|
|
9
|
+
test("uses semantic theme roles instead of embedded ANSI colors", () => {
|
|
10
|
+
expect(fileIcon("example.ts", theme)).toContain("<syntaxType>");
|
|
11
|
+
expect(fileIcon("data.json", theme)).toContain("<syntaxNumber>");
|
|
12
|
+
expect(fileIcon("unknown.zzz", theme)).toContain("<muted>");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("themes directory icons with the active accent", () => {
|
|
16
|
+
expect(dirIcon(theme)).toContain("<accent>");
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/icons.ts
CHANGED
|
@@ -1,120 +1,102 @@
|
|
|
1
1
|
import { basename, extname } from "node:path";
|
|
2
|
-
|
|
3
|
-
import { FG_BLUE, FG_DIM, RST } from "./ansi.js";
|
|
2
|
+
import type { FgTheme } from "./types.js";
|
|
4
3
|
|
|
5
4
|
const ICONS_MODE = (process.env.PRETTY_ICONS ?? "nerd").toLowerCase();
|
|
6
|
-
|
|
7
5
|
const USE_ICONS = ICONS_MODE !== "none" && ICONS_MODE !== "off";
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const EXT_ICON: Record<string, string> = {
|
|
15
|
-
// TypeScript / JavaScript
|
|
16
|
-
ts: `\x1b[38;2;49;120;198m\ue628${RST}`, // blue
|
|
17
|
-
tsx: `\x1b[38;2;49;120;198m\ue7ba${RST}`, // react blue
|
|
18
|
-
js: `\x1b[38;2;241;224;90m\ue74e${RST}`, // yellow
|
|
19
|
-
jsx: `\x1b[38;2;97;218;251m\ue7ba${RST}`, // react cyan
|
|
20
|
-
mjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
|
|
21
|
-
cjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
|
|
22
|
-
|
|
23
|
-
// Systems / Backend
|
|
24
|
-
py: `\x1b[38;2;55;118;171m\ue73c${RST}`, // python blue
|
|
25
|
-
rs: `\x1b[38;2;222;165;132m\ue7a8${RST}`, // rust orange
|
|
26
|
-
go: `\x1b[38;2;0;173;216m\ue724${RST}`, // go cyan
|
|
27
|
-
java: `\x1b[38;2;204;62;68m\ue738${RST}`, // java red
|
|
28
|
-
swift: `\x1b[38;2;255;172;77m\ue755${RST}`, // swift orange
|
|
29
|
-
rb: `\x1b[38;2;204;52;45m\ue739${RST}`, // ruby red
|
|
30
|
-
kt: `\x1b[38;2;126;103;200m\ue634${RST}`, // kotlin purple
|
|
31
|
-
c: `\x1b[38;2;85;154;211m\ue61e${RST}`, // c blue
|
|
32
|
-
cpp: `\x1b[38;2;85;154;211m\ue61d${RST}`, // cpp blue
|
|
33
|
-
h: `\x1b[38;2;140;160;185m\ue61e${RST}`, // header muted
|
|
34
|
-
hpp: `\x1b[38;2;140;160;185m\ue61d${RST}`,
|
|
35
|
-
cs: `\x1b[38;2;104;33;122m\ue648${RST}`, // c# purple
|
|
36
|
-
|
|
37
|
-
// Web
|
|
38
|
-
html: `\x1b[38;2;228;77;38m\ue736${RST}`, // html orange
|
|
39
|
-
css: `\x1b[38;2;66;165;245m\ue749${RST}`, // css blue
|
|
40
|
-
scss: `\x1b[38;2;207;100;154m\ue749${RST}`, // scss pink
|
|
41
|
-
less: `\x1b[38;2;66;165;245m\ue749${RST}`,
|
|
42
|
-
vue: `\x1b[38;2;65;184;131m\ue6a0${RST}`, // vue green
|
|
43
|
-
svelte: `\x1b[38;2;255;62;0m\ue697${RST}`, // svelte red-orange
|
|
44
|
-
|
|
45
|
-
// Config / Data
|
|
46
|
-
json: `\x1b[38;2;241;224;90m\ue60b${RST}`, // json yellow
|
|
47
|
-
jsonc: `\x1b[38;2;241;224;90m\ue60b${RST}`,
|
|
48
|
-
yaml: `\x1b[38;2;160;116;196m\ue6a8${RST}`, // yaml purple
|
|
49
|
-
yml: `\x1b[38;2;160;116;196m\ue6a8${RST}`,
|
|
50
|
-
toml: `\x1b[38;2;160;116;196m\ue6b2${RST}`, // toml purple
|
|
51
|
-
xml: `\x1b[38;2;228;77;38m\ue619${RST}`, // xml orange
|
|
52
|
-
sql: `\x1b[38;2;218;218;218m\ue706${RST}`, // sql gray
|
|
53
|
-
|
|
54
|
-
// Markdown / Docs
|
|
55
|
-
md: `\x1b[38;2;66;165;245m\ue73e${RST}`, // markdown blue
|
|
56
|
-
mdx: `\x1b[38;2;66;165;245m\ue73e${RST}`,
|
|
57
|
-
|
|
58
|
-
// Shell / Scripts
|
|
59
|
-
sh: `\x1b[38;2;137;180;130m\ue795${RST}`, // shell green
|
|
60
|
-
bash: `\x1b[38;2;137;180;130m\ue795${RST}`,
|
|
61
|
-
zsh: `\x1b[38;2;137;180;130m\ue795${RST}`,
|
|
62
|
-
fish: `\x1b[38;2;137;180;130m\ue795${RST}`,
|
|
63
|
-
lua: `\x1b[38;2;81;160;207m\ue620${RST}`, // lua blue
|
|
64
|
-
php: `\x1b[38;2;137;147;186m\ue73d${RST}`, // php purple
|
|
65
|
-
dart: `\x1b[38;2;87;182;240m\ue798${RST}`, // dart blue
|
|
66
|
-
|
|
67
|
-
// Images
|
|
68
|
-
png: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
69
|
-
jpg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
70
|
-
jpeg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
71
|
-
gif: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
72
|
-
svg: `\x1b[38;2;255;180;50m\uf1c5${RST}`,
|
|
73
|
-
webp: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
74
|
-
ico: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
7
|
+
interface IconSpec {
|
|
8
|
+
glyph: string;
|
|
9
|
+
color: string;
|
|
10
|
+
}
|
|
75
11
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
12
|
+
const FILE = "\uf15b";
|
|
13
|
+
const EXT_ICON: Record<string, IconSpec> = {
|
|
14
|
+
ts: { glyph: "\ue628", color: "syntaxType" },
|
|
15
|
+
tsx: { glyph: "\ue7ba", color: "syntaxType" },
|
|
16
|
+
js: { glyph: "\ue74e", color: "syntaxNumber" },
|
|
17
|
+
jsx: { glyph: "\ue7ba", color: "syntaxVariable" },
|
|
18
|
+
mjs: { glyph: "\ue74e", color: "syntaxNumber" },
|
|
19
|
+
cjs: { glyph: "\ue74e", color: "syntaxNumber" },
|
|
20
|
+
py: { glyph: "\ue73c", color: "syntaxFunction" },
|
|
21
|
+
rs: { glyph: "\ue7a8", color: "syntaxType" },
|
|
22
|
+
go: { glyph: "\ue724", color: "syntaxVariable" },
|
|
23
|
+
java: { glyph: "\ue738", color: "syntaxKeyword" },
|
|
24
|
+
swift: { glyph: "\ue755", color: "syntaxNumber" },
|
|
25
|
+
rb: { glyph: "\ue739", color: "syntaxKeyword" },
|
|
26
|
+
kt: { glyph: "\ue634", color: "syntaxType" },
|
|
27
|
+
c: { glyph: "\ue61e", color: "syntaxFunction" },
|
|
28
|
+
cpp: { glyph: "\ue61d", color: "syntaxFunction" },
|
|
29
|
+
h: { glyph: "\ue61e", color: "muted" },
|
|
30
|
+
hpp: { glyph: "\ue61d", color: "muted" },
|
|
31
|
+
cs: { glyph: "\ue648", color: "syntaxType" },
|
|
32
|
+
html: { glyph: "\ue736", color: "syntaxKeyword" },
|
|
33
|
+
css: { glyph: "\ue749", color: "syntaxFunction" },
|
|
34
|
+
scss: { glyph: "\ue749", color: "syntaxType" },
|
|
35
|
+
less: { glyph: "\ue749", color: "syntaxFunction" },
|
|
36
|
+
vue: { glyph: "\ue6a0", color: "syntaxString" },
|
|
37
|
+
svelte: { glyph: "\ue697", color: "syntaxKeyword" },
|
|
38
|
+
json: { glyph: "\ue60b", color: "syntaxNumber" },
|
|
39
|
+
jsonc: { glyph: "\ue60b", color: "syntaxNumber" },
|
|
40
|
+
yaml: { glyph: "\ue6a8", color: "syntaxType" },
|
|
41
|
+
yml: { glyph: "\ue6a8", color: "syntaxType" },
|
|
42
|
+
toml: { glyph: "\ue6b2", color: "syntaxType" },
|
|
43
|
+
xml: { glyph: "\ue619", color: "syntaxKeyword" },
|
|
44
|
+
sql: { glyph: "\ue706", color: "text" },
|
|
45
|
+
md: { glyph: "\ue73e", color: "accent" },
|
|
46
|
+
mdx: { glyph: "\ue73e", color: "accent" },
|
|
47
|
+
sh: { glyph: "\ue795", color: "syntaxString" },
|
|
48
|
+
bash: { glyph: "\ue795", color: "syntaxString" },
|
|
49
|
+
zsh: { glyph: "\ue795", color: "syntaxString" },
|
|
50
|
+
fish: { glyph: "\ue795", color: "syntaxString" },
|
|
51
|
+
lua: { glyph: "\ue620", color: "syntaxFunction" },
|
|
52
|
+
php: { glyph: "\ue73d", color: "syntaxType" },
|
|
53
|
+
dart: { glyph: "\ue798", color: "syntaxFunction" },
|
|
54
|
+
png: { glyph: "\uf1c5", color: "syntaxType" },
|
|
55
|
+
jpg: { glyph: "\uf1c5", color: "syntaxType" },
|
|
56
|
+
jpeg: { glyph: "\uf1c5", color: "syntaxType" },
|
|
57
|
+
gif: { glyph: "\uf1c5", color: "syntaxType" },
|
|
58
|
+
svg: { glyph: "\uf1c5", color: "syntaxNumber" },
|
|
59
|
+
webp: { glyph: "\uf1c5", color: "syntaxType" },
|
|
60
|
+
ico: { glyph: "\uf1c5", color: "syntaxType" },
|
|
61
|
+
lock: { glyph: "\uf023", color: "muted" },
|
|
62
|
+
env: { glyph: "\ue615", color: "syntaxNumber" },
|
|
63
|
+
graphql: { glyph: "\ue662", color: "syntaxType" },
|
|
64
|
+
dockerfile: { glyph: "\ue7b0", color: "syntaxFunction" },
|
|
81
65
|
};
|
|
82
66
|
|
|
83
|
-
const NAME_ICON: Record<string,
|
|
84
|
-
"package.json":
|
|
85
|
-
"package-lock.json":
|
|
86
|
-
"tsconfig.json":
|
|
87
|
-
"biome.json":
|
|
88
|
-
".gitignore":
|
|
89
|
-
".git":
|
|
90
|
-
".env":
|
|
91
|
-
".envrc":
|
|
92
|
-
dockerfile:
|
|
93
|
-
makefile:
|
|
94
|
-
gnumakefile:
|
|
95
|
-
"readme.md":
|
|
96
|
-
license:
|
|
97
|
-
"cargo.toml":
|
|
98
|
-
"go.mod":
|
|
99
|
-
"pyproject.toml":
|
|
67
|
+
const NAME_ICON: Record<string, IconSpec> = {
|
|
68
|
+
"package.json": { glyph: "\ue71e", color: "syntaxString" },
|
|
69
|
+
"package-lock.json": { glyph: "\ue71e", color: "muted" },
|
|
70
|
+
"tsconfig.json": { glyph: "\ue628", color: "syntaxType" },
|
|
71
|
+
"biome.json": { glyph: "\ue615", color: "syntaxFunction" },
|
|
72
|
+
".gitignore": { glyph: "\ue702", color: "syntaxType" },
|
|
73
|
+
".git": { glyph: "\ue702", color: "syntaxType" },
|
|
74
|
+
".env": { glyph: "\ue615", color: "syntaxNumber" },
|
|
75
|
+
".envrc": { glyph: "\ue615", color: "syntaxNumber" },
|
|
76
|
+
dockerfile: { glyph: "\ue7b0", color: "syntaxFunction" },
|
|
77
|
+
makefile: { glyph: "\ue615", color: "muted" },
|
|
78
|
+
gnumakefile: { glyph: "\ue615", color: "muted" },
|
|
79
|
+
"readme.md": { glyph: "\ue73e", color: "accent" },
|
|
80
|
+
license: { glyph: "\ue60a", color: "text" },
|
|
81
|
+
"cargo.toml": { glyph: "\ue7a8", color: "syntaxType" },
|
|
82
|
+
"go.mod": { glyph: "\ue724", color: "syntaxVariable" },
|
|
83
|
+
"pyproject.toml": { glyph: "\ue73c", color: "syntaxFunction" },
|
|
100
84
|
};
|
|
101
85
|
|
|
102
|
-
|
|
86
|
+
function paint(spec: IconSpec, theme?: FgTheme): string {
|
|
87
|
+
return theme ? theme.fg(spec.color, spec.glyph) : spec.glyph;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** File icon whose color is derived from the active Pi theme when available. */
|
|
91
|
+
export function fileIcon(fp: string, theme?: FgTheme): string {
|
|
103
92
|
if (!USE_ICONS) return "";
|
|
104
93
|
const base = basename(fp).toLowerCase();
|
|
105
|
-
if (NAME_ICON[base]) return `${NAME_ICON[base]} `;
|
|
106
94
|
const ext = extname(fp).slice(1).toLowerCase();
|
|
107
|
-
|
|
95
|
+
const spec = NAME_ICON[base] ?? EXT_ICON[ext] ?? { glyph: FILE, color: "muted" };
|
|
96
|
+
return `${paint(spec, theme)} `;
|
|
108
97
|
}
|
|
109
98
|
|
|
110
|
-
|
|
111
|
-
|
|
99
|
+
/** Directory icon whose color is derived from the active Pi theme. */
|
|
100
|
+
export function dirIcon(theme?: FgTheme): string {
|
|
101
|
+
return USE_ICONS ? `${paint({ glyph: "\ue5ff", color: "accent" }, theme)} ` : "";
|
|
112
102
|
}
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// cli-highlight ANSI cache
|
|
116
|
-
//
|
|
117
|
-
// highlight.js uses different language ids than shiki for a few entries
|
|
118
|
-
// (no tsx/jsx grammar, jsonc, mdx, make, etc.). Map the shiki-style ids the
|
|
119
|
-
// EXT_LANG table produces onto highlight.js-supported ids.
|
|
120
|
-
// ---------------------------------------------------------------------------
|
package/src/renderers.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { getLsStyle } from "@xynogen/pix-data/pix-config";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { FG_DIM, FG_RULE, RST } from "./ansi.js";
|
|
5
5
|
import { MAX_PREVIEW_LINES } from "./config.js";
|
|
6
6
|
import { hlBlock } from "./highlight.js";
|
|
7
7
|
import { dirIcon, fileIcon } from "./icons.js";
|
|
8
8
|
import { lang } from "./lang.js";
|
|
9
|
+
import type { FgTheme } from "./types.js";
|
|
9
10
|
import { lnum, normalizeLineEndings, pluralize, rule, termW } from "./utils.js";
|
|
10
11
|
|
|
11
12
|
/** Render syntax-highlighted file content with line numbers. */
|
|
@@ -14,13 +15,14 @@ export async function renderFileContent(
|
|
|
14
15
|
filePath: string,
|
|
15
16
|
offset = 1,
|
|
16
17
|
maxLines = MAX_PREVIEW_LINES,
|
|
18
|
+
theme?: FgTheme,
|
|
17
19
|
): Promise<string> {
|
|
18
20
|
const normalizedContent = normalizeLineEndings(content);
|
|
19
21
|
const lines = normalizedContent.split("\n");
|
|
20
22
|
const total = lines.length;
|
|
21
23
|
const show = lines.slice(0, maxLines);
|
|
22
24
|
const lg = lang(filePath);
|
|
23
|
-
const hl = await hlBlock(show.join("\n"), lg);
|
|
25
|
+
const hl = await hlBlock(show.join("\n"), lg, theme);
|
|
24
26
|
|
|
25
27
|
const tw = termW();
|
|
26
28
|
const startLine = offset;
|
|
@@ -50,14 +52,13 @@ export async function renderFileContent(
|
|
|
50
52
|
export function renderBashOutput(
|
|
51
53
|
text: string,
|
|
52
54
|
exitCode: number | null,
|
|
55
|
+
theme?: FgTheme,
|
|
53
56
|
): { summary: string; body: string } {
|
|
54
57
|
const isOk = exitCode === 0;
|
|
55
|
-
const statusFg = isOk ? FG_GREEN : FG_RED;
|
|
56
58
|
const statusIcon = isOk ? "✓" : "✗";
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
: `${FG_YELLOW}⚡ killed${RST}`;
|
|
59
|
+
const semantic = isOk ? "success" : "error";
|
|
60
|
+
const codeText = exitCode !== null ? `${statusIcon} exit ${exitCode}` : "⚡ killed";
|
|
61
|
+
const codeStr = theme ? theme.fg(exitCode !== null ? semantic : "warning", codeText) : codeText;
|
|
61
62
|
|
|
62
63
|
const lines = text.split("\n");
|
|
63
64
|
const maxShow = MAX_PREVIEW_LINES;
|
|
@@ -73,12 +74,14 @@ export function renderBashOutput(
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** Render ls output using the configured style (grid or tree). */
|
|
76
|
-
export function renderTree(text: string, basePath: string): string {
|
|
77
|
-
return getLsStyle() === "tree"
|
|
77
|
+
export function renderTree(text: string, basePath: string, theme?: FgTheme): string {
|
|
78
|
+
return getLsStyle() === "tree"
|
|
79
|
+
? renderLsTree(text, basePath, theme)
|
|
80
|
+
: renderLsGrid(text, basePath, theme);
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** Vertical tree view with connectors and icons. */
|
|
81
|
-
function renderLsTree(text: string, _basePath: string): string {
|
|
84
|
+
function renderLsTree(text: string, _basePath: string, theme?: FgTheme): string {
|
|
82
85
|
const lines = text.trim().split("\n").filter(Boolean);
|
|
83
86
|
if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
|
|
84
87
|
|
|
@@ -94,11 +97,10 @@ function renderLsTree(text: string, _basePath: string): string {
|
|
|
94
97
|
|
|
95
98
|
const isDir = entry.endsWith("/");
|
|
96
99
|
const name = isDir ? entry.slice(0, -1) : entry;
|
|
97
|
-
const icon = isDir ? dirIcon() : fileIcon(name);
|
|
98
|
-
const
|
|
99
|
-
const reset = isDir ? RST : "";
|
|
100
|
+
const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
|
|
101
|
+
const displayName = isDir && theme ? theme.fg("accent", name) : name;
|
|
100
102
|
|
|
101
|
-
out.push(`${connector}${icon}${
|
|
103
|
+
out.push(`${connector}${icon}${displayName}`);
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
if (total > MAX_PREVIEW_LINES) {
|
|
@@ -111,7 +113,7 @@ function renderLsTree(text: string, _basePath: string): string {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/** Horizontal grid with icons (like eza/ls). */
|
|
114
|
-
function renderLsGrid(text: string, _basePath: string): string {
|
|
116
|
+
function renderLsGrid(text: string, _basePath: string, theme?: FgTheme): string {
|
|
115
117
|
const lines = text.trim().split("\n").filter(Boolean);
|
|
116
118
|
if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
|
|
117
119
|
|
|
@@ -126,10 +128,9 @@ function renderLsGrid(text: string, _basePath: string): string {
|
|
|
126
128
|
const entry = raw.trim();
|
|
127
129
|
const isDir = entry.endsWith("/");
|
|
128
130
|
const name = isDir ? entry.slice(0, -1) : entry;
|
|
129
|
-
const icon = isDir ? dirIcon() : fileIcon(name);
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const cell = `${icon}${fg}${name}${reset}`;
|
|
131
|
+
const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
|
|
132
|
+
const displayName = isDir && theme ? theme.fg("accent", name) : name;
|
|
133
|
+
const cell = `${icon}${displayName}`;
|
|
133
134
|
cells.push(cell);
|
|
134
135
|
cellWidths.push(visibleWidth(cell));
|
|
135
136
|
}
|
package/src/tools/context.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -28,8 +28,8 @@ export type BgTheme = { getBgAnsi?: (key: string) => string };
|
|
|
28
28
|
|
|
29
29
|
export type FgTheme = {
|
|
30
30
|
fg: (key: string, text: string) => string;
|
|
31
|
-
// Optional raw-ANSI
|
|
32
|
-
//
|
|
31
|
+
// Optional raw-ANSI accessors exposed by Pi's theme. Diff foregrounds and
|
|
32
|
+
// generated tint backgrounds derive from these semantic theme colors.
|
|
33
33
|
getFgAnsi?: (key: string) => string;
|
|
34
34
|
};
|
|
35
35
|
|
package/src/utils.ts
CHANGED
|
@@ -35,14 +35,14 @@ function preserveToolBackground(ansi: string, bg: string): string {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function fillToolBackground(text: string, bg = BG_BASE): string {
|
|
39
|
-
const
|
|
38
|
+
export function fillToolBackground(text: string, bg = BG_BASE, width?: number): string {
|
|
39
|
+
const resolvedWidth = width ?? termW();
|
|
40
40
|
return text
|
|
41
41
|
.split("\n")
|
|
42
42
|
.map((line) => {
|
|
43
43
|
const normalized = preserveToolBackground(line, bg);
|
|
44
|
-
const fitted = preserveToolBackground(truncateToWidth(normalized,
|
|
45
|
-
const padding = Math.max(0,
|
|
44
|
+
const fitted = preserveToolBackground(truncateToWidth(normalized, resolvedWidth, ""), bg);
|
|
45
|
+
const padding = Math.max(0, resolvedWidth - visibleWidth(fitted));
|
|
46
46
|
return `${bg}${fitted}${" ".repeat(padding)}${RST}`;
|
|
47
47
|
})
|
|
48
48
|
.join("\n");
|