@xynogen/pix-pretty 1.7.21 → 1.7.24
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 +12 -0
- package/src/ansi.ts +9 -31
- package/src/dependency-security.test.ts +32 -0
- package/src/diff-render.ts +99 -144
- package/src/diff.test.ts +69 -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 +1 -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.24",
|
|
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,12 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as ansi from "./ansi.ts";
|
|
3
|
+
import { resolveBaseBackground } from "./ansi.ts";
|
|
4
|
+
|
|
5
|
+
describe("tool surfaces", () => {
|
|
6
|
+
test("always preserves terminal background", () => {
|
|
7
|
+
resolveBaseBackground({ getBgAnsi: () => "\x1b[48;2;10;20;30m" });
|
|
8
|
+
expect(ansi.BG_BASE).toBe("\x1b[49m");
|
|
9
|
+
expect(ansi.BG_ERROR).toBe("\x1b[49m");
|
|
10
|
+
expect(ansi.RST).toBe("\x1b[0m");
|
|
11
|
+
});
|
|
12
|
+
});
|
package/src/ansi.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { BgTheme } from "./types.js";
|
|
2
|
-
|
|
3
1
|
export let RST = "\x1b[0m";
|
|
4
2
|
export const BOLD = "\x1b[1m";
|
|
5
3
|
|
|
@@ -13,37 +11,17 @@ export const FG_BLUE = "\x1b[38;2;100;140;220m";
|
|
|
13
11
|
const FG_MUTED = "\x1b[38;2;139;148;158m";
|
|
14
12
|
|
|
15
13
|
const BG_DEFAULT = "\x1b[49m";
|
|
16
|
-
export let BG_BASE = BG_DEFAULT;
|
|
17
|
-
export let BG_ERROR = BG_DEFAULT;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
|
|
26
|
-
try {
|
|
27
|
-
const bgAnsi = theme.getBgAnsi?.(key);
|
|
28
|
-
return bgAnsi && parseAnsiRgb(bgAnsi) ? bgAnsi : null;
|
|
29
|
-
} catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
14
|
+
export let BG_BASE = BG_DEFAULT;
|
|
15
|
+
export let BG_ERROR = BG_DEFAULT;
|
|
16
|
+
|
|
17
|
+
/** Tool and diff renderers always preserve the terminal background. */
|
|
18
|
+
export function resolveBaseBackground(_theme: unknown): void {
|
|
19
|
+
BG_BASE = BG_DEFAULT;
|
|
20
|
+
BG_ERROR = BG_DEFAULT;
|
|
21
|
+
RST = "\x1b[0m";
|
|
32
22
|
}
|
|
33
23
|
|
|
34
|
-
|
|
35
|
-
* Recompute on each render so runtime theme changes are respected. */
|
|
36
|
-
export function resolveBaseBackground(theme: BgTheme | null | undefined): void {
|
|
37
|
-
if (!theme?.getBgAnsi) return;
|
|
38
|
-
|
|
39
|
-
BG_BASE = getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
|
|
40
|
-
BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
|
|
41
|
-
RST = `\x1b[0m${BG_BASE}`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const ESC_RE = "\u001b";
|
|
45
|
-
|
|
46
|
-
export const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
|
|
24
|
+
export const ANSI_CAPTURE_RE = /\x1b\[([0-9;]*)m/g;
|
|
47
25
|
|
|
48
26
|
// ---------------------------------------------------------------------------
|
|
49
27
|
// Low-contrast fix (same as pi-diff)
|
|
@@ -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
|
@@ -3,18 +3,16 @@
|
|
|
3
3
|
// vendored pretty extension's primitives (cli-highlight `hlBlock`, shared
|
|
4
4
|
// theme-aware `RST`/`BG_BASE` from ansi.ts).
|
|
5
5
|
//
|
|
6
|
-
// Engine note: pi-diff used Shiki's codeToANSI
|
|
7
|
-
//
|
|
8
|
-
// technique below works unchanged — diff backgrounds layer underneath and
|
|
9
|
-
// persist through fg switches.
|
|
6
|
+
// Engine note: pi-diff used Shiki's codeToANSI. pretty uses cli-highlight,
|
|
7
|
+
// shared with other pix-pretty renderers, and paints foreground tokens only.
|
|
10
8
|
|
|
11
9
|
import { pixConfig } from "@xynogen/pix-data/pix-config";
|
|
12
10
|
import * as Diff from "diff";
|
|
13
|
-
import { BG_BASE, BOLD, FG_DIM, FG_LNUM, FG_RULE, RST } from "./ansi.js";
|
|
11
|
+
import { BG_BASE, BOLD, FG_DIM, FG_GREEN, FG_LNUM, FG_RED, FG_RULE, RST } from "./ansi.js";
|
|
14
12
|
import { MAX_HL_CHARS, MAX_RENDER_LINES, WORD_DIFF_MIN_SIM } from "./config.js";
|
|
15
13
|
import type { DiffLine, ParsedDiff } from "./diff.js";
|
|
16
14
|
import { hlBlock } from "./highlight.js";
|
|
17
|
-
import type { BundledLanguage } from "./types.js";
|
|
15
|
+
import type { BundledLanguage, FgTheme } from "./types.js";
|
|
18
16
|
import { termW as utilsTermW } from "./utils.js";
|
|
19
17
|
|
|
20
18
|
// ---------------------------------------------------------------------------
|
|
@@ -26,59 +24,17 @@ function envInt(name: string, fallback: number): number {
|
|
|
26
24
|
return Number.isFinite(v) ? v : fallback;
|
|
27
25
|
}
|
|
28
26
|
|
|
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
27
|
// ---------------------------------------------------------------------------
|
|
48
|
-
// Diff-specific ANSI
|
|
28
|
+
// Diff-specific ANSI. Theme-backed colors are resolved per render; these
|
|
29
|
+
// constants are readable fallbacks for hosts that do not expose raw theme ANSI.
|
|
49
30
|
// ---------------------------------------------------------------------------
|
|
50
31
|
|
|
51
32
|
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
33
|
const dc = pixConfig().pretty.diff;
|
|
70
34
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const BG_DEL = envBg("DIFF_BG_DEL", hexToBg(dc.bgDel) || "\x1b[48;2;45;25;25m");
|
|
75
|
-
const BG_ADD_W = envBg("DIFF_BG_ADD_HL", hexToBg(dc.bgAddHighlight) || "\x1b[48;2;35;75;50m");
|
|
76
|
-
const BG_DEL_W = envBg("DIFF_BG_DEL_HL", hexToBg(dc.bgDelHighlight) || "\x1b[48;2;80;35;35m");
|
|
77
|
-
const BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", hexToBg(dc.bgGutterAdd) || "\x1b[48;2;18;32;26m");
|
|
78
|
-
const BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", hexToBg(dc.bgGutterDel) || "\x1b[48;2;38;22;22m");
|
|
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");
|
|
35
|
+
// Diff add/remove share the canonical green/red from ansi.ts (single source).
|
|
36
|
+
const FG_ADD = FG_GREEN;
|
|
37
|
+
const FG_DEL = FG_RED;
|
|
82
38
|
const FG_STRIPE = "\x1b[38;2;40;40;40m"; // diagonal stripes on filler cells
|
|
83
39
|
|
|
84
40
|
const BORDER_BAR = "▌";
|
|
@@ -113,81 +69,61 @@ export interface DiffColors {
|
|
|
113
69
|
fgAdd: string;
|
|
114
70
|
fgDel: string;
|
|
115
71
|
fgCtx: string;
|
|
72
|
+
/** Active Pi theme, forwarded to syntax highlighting. */
|
|
73
|
+
theme?: FgTheme;
|
|
74
|
+
bgAdd: string;
|
|
75
|
+
bgDel: string;
|
|
76
|
+
bgAddHighlight: string;
|
|
77
|
+
bgDelHighlight: string;
|
|
78
|
+
bgGutterAdd: string;
|
|
79
|
+
bgGutterDel: string;
|
|
116
80
|
}
|
|
117
81
|
|
|
118
82
|
export const DEFAULT_DIFF_COLORS: DiffColors = {
|
|
119
83
|
fgAdd: FG_ADD,
|
|
120
84
|
fgDel: FG_DEL,
|
|
121
85
|
fgCtx: FG_DIM,
|
|
86
|
+
bgAdd: BG_BASE,
|
|
87
|
+
bgDel: BG_BASE,
|
|
88
|
+
bgAddHighlight: BG_BASE,
|
|
89
|
+
bgDelHighlight: BG_BASE,
|
|
90
|
+
bgGutterAdd: BG_BASE,
|
|
91
|
+
bgGutterDel: BG_BASE,
|
|
122
92
|
};
|
|
123
93
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// minimum contrast ratio against the gutter background it sits on.
|
|
129
|
-
|
|
130
|
-
type Rgb = [number, number, number];
|
|
131
|
-
|
|
132
|
-
function parseAnsiRgb(seq: string, kind: "38" | "48"): Rgb | null {
|
|
133
|
-
const m = seq.match(new RegExp(`\\x1b\\[${kind};2;(\\d+);(\\d+);(\\d+)m`));
|
|
134
|
-
if (!m) return null;
|
|
135
|
-
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function relLuminance([r, g, b]: Rgb): number {
|
|
139
|
-
const f = (c: number) => {
|
|
140
|
-
const s = c / 255;
|
|
141
|
-
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
142
|
-
};
|
|
143
|
-
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function contrastRatio(a: Rgb, b: Rgb): number {
|
|
147
|
-
const la = relLuminance(a);
|
|
148
|
-
const lb = relLuminance(b);
|
|
149
|
-
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
150
|
-
return (hi + 0.05) / (lo + 0.05);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Keep hue, raise lightness toward white until contrast >= min (or capped). */
|
|
154
|
-
function ensureContrast(fg: string, bgSeq: string, min = 3): string {
|
|
155
|
-
const rgb = parseAnsiRgb(fg, "38");
|
|
156
|
-
const bg = parseAnsiRgb(bgSeq, "48");
|
|
157
|
-
if (!rgb || !bg) return fg; // can't reason about it — leave theme value
|
|
158
|
-
if (contrastRatio(rgb, bg) >= min) return fg; // already legible
|
|
159
|
-
let [r, g, b] = rgb;
|
|
160
|
-
for (let i = 0; i < 12 && contrastRatio([r, g, b], bg) < min; i++) {
|
|
161
|
-
r = Math.round(r + (255 - r) * 0.25);
|
|
162
|
-
g = Math.round(g + (255 - g) * 0.25);
|
|
163
|
-
b = Math.round(b + (255 - b) * 0.25);
|
|
164
|
-
}
|
|
165
|
-
return `\x1b[38;2;${r};${g};${b}m`;
|
|
166
|
-
}
|
|
94
|
+
type DiffTheme = {
|
|
95
|
+
fg?: FgTheme["fg"];
|
|
96
|
+
getFgAnsi?: (key: string) => string;
|
|
97
|
+
};
|
|
167
98
|
|
|
168
|
-
|
|
169
|
-
* back to hardcoded ANSI. BG_BASE is already kept in sync by ansi.ts's
|
|
170
|
-
* resolveBaseBackground (called from the tool renderers).
|
|
171
|
-
*
|
|
172
|
-
* Theme hue is preserved, but each add/del fg is contrast-checked against the
|
|
173
|
-
* gutter bg it is painted on and lifted if it would render too dark to read. */
|
|
174
|
-
export function resolveDiffColors(theme?: { getFgAnsi?: (key: string) => string }): DiffColors {
|
|
175
|
-
if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
|
|
99
|
+
function themeFg(theme: DiffTheme, key: string, fallback: string): string {
|
|
176
100
|
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
|
-
};
|
|
101
|
+
return theme.getFgAnsi?.(key) || fallback;
|
|
182
102
|
} catch {
|
|
183
|
-
return
|
|
103
|
+
return fallback;
|
|
184
104
|
}
|
|
185
105
|
}
|
|
186
106
|
|
|
107
|
+
export function resolveDiffColors(theme?: DiffTheme): DiffColors {
|
|
108
|
+
if (!theme) return DEFAULT_DIFF_COLORS;
|
|
109
|
+
return {
|
|
110
|
+
fgAdd: themeFg(theme, "toolDiffAdded", FG_ADD),
|
|
111
|
+
fgDel: themeFg(theme, "toolDiffRemoved", FG_DEL),
|
|
112
|
+
fgCtx: themeFg(theme, "toolDiffContext", FG_DIM),
|
|
113
|
+
theme: typeof theme.fg === "function" ? (theme as FgTheme) : undefined,
|
|
114
|
+
bgAdd: BG_BASE,
|
|
115
|
+
bgDel: BG_BASE,
|
|
116
|
+
bgAddHighlight: BG_BASE,
|
|
117
|
+
bgDelHighlight: BG_BASE,
|
|
118
|
+
bgGutterAdd: BG_BASE,
|
|
119
|
+
bgGutterDel: BG_BASE,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
187
123
|
/** Stable cache key for the resolved diff theme colors. */
|
|
188
|
-
export function diffThemeCacheKey(theme?:
|
|
189
|
-
const
|
|
190
|
-
return `${
|
|
124
|
+
export function diffThemeCacheKey(theme?: DiffTheme): string {
|
|
125
|
+
const { theme: _theme, ...colors } = resolveDiffColors(theme);
|
|
126
|
+
return `${Object.values(colors).join("|")}|${BG_BASE}`;
|
|
191
127
|
}
|
|
192
128
|
|
|
193
129
|
// ---------------------------------------------------------------------------
|
|
@@ -371,12 +307,27 @@ function rule(w: number): string {
|
|
|
371
307
|
return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`;
|
|
372
308
|
}
|
|
373
309
|
|
|
374
|
-
/** Compact "+a -d" summary
|
|
310
|
+
/** Compact plain "+a -d" summary for persisted renderer details. */
|
|
375
311
|
export function summarize(a: number, d: number): string {
|
|
376
|
-
const
|
|
377
|
-
if (a > 0)
|
|
378
|
-
if (d > 0)
|
|
379
|
-
return
|
|
312
|
+
const parts: string[] = [];
|
|
313
|
+
if (a > 0) parts.push(`+${a}`);
|
|
314
|
+
if (d > 0) parts.push(`-${d}`);
|
|
315
|
+
return parts.length ? parts.join(" ") : "no changes";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Apply active Pi theme colors to a plain diff summary at render time. */
|
|
319
|
+
export function renderDiffSummary(summary: string, theme: FgTheme): string {
|
|
320
|
+
if (summary === "no changes") return theme.fg("toolDiffContext", summary);
|
|
321
|
+
return summary
|
|
322
|
+
.split(" ")
|
|
323
|
+
.map((part) =>
|
|
324
|
+
part.startsWith("+")
|
|
325
|
+
? theme.fg("toolDiffAdded", part)
|
|
326
|
+
: part.startsWith("-")
|
|
327
|
+
? theme.fg("toolDiffRemoved", part)
|
|
328
|
+
: part,
|
|
329
|
+
)
|
|
330
|
+
.join(" ");
|
|
380
331
|
}
|
|
381
332
|
|
|
382
333
|
// ---------------------------------------------------------------------------
|
|
@@ -467,13 +418,17 @@ function injectBg(
|
|
|
467
418
|
}
|
|
468
419
|
|
|
469
420
|
/** Simple word diff (no syntax hl) — fallback when highlighting is unavailable. */
|
|
470
|
-
function plainWordDiff(
|
|
421
|
+
function plainWordDiff(
|
|
422
|
+
oldText: string,
|
|
423
|
+
newText: string,
|
|
424
|
+
dc: DiffColors,
|
|
425
|
+
): { old: string; new: string } {
|
|
471
426
|
const parts = Diff.diffWords(oldText, newText);
|
|
472
427
|
let o = "";
|
|
473
428
|
let n = "";
|
|
474
429
|
for (const p of parts) {
|
|
475
|
-
if (p.removed) o += `${
|
|
476
|
-
else if (p.added) n += `${
|
|
430
|
+
if (p.removed) o += `${dc.bgDelHighlight}${p.value}${RST}${dc.bgDel}`;
|
|
431
|
+
else if (p.added) n += `${dc.bgAddHighlight}${p.value}${RST}${dc.bgAdd}`;
|
|
477
432
|
else {
|
|
478
433
|
o += p.value;
|
|
479
434
|
n += p.value;
|
|
@@ -548,8 +503,8 @@ export async function renderUnified(
|
|
|
548
503
|
}
|
|
549
504
|
const [oldHL, newHL] = canHL
|
|
550
505
|
? await Promise.all([
|
|
551
|
-
hlBlock(oldSrc.join("\n"), language),
|
|
552
|
-
hlBlock(newSrc.join("\n"), language),
|
|
506
|
+
hlBlock(oldSrc.join("\n"), language, dc.theme),
|
|
507
|
+
hlBlock(newSrc.join("\n"), language, dc.theme),
|
|
553
508
|
])
|
|
554
509
|
: [oldSrc, newSrc];
|
|
555
510
|
|
|
@@ -634,42 +589,42 @@ export async function renderUnified(
|
|
|
634
589
|
if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
|
|
635
590
|
const del0 = at(dels, 0);
|
|
636
591
|
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, "+",
|
|
592
|
+
const delBody = injectBg(del0.hl, wd.oldRanges, dc.bgDel, dc.bgDelHighlight);
|
|
593
|
+
const addBody = injectBg(add0.hl, wd.newRanges, dc.bgAdd, dc.bgAddHighlight);
|
|
594
|
+
emitRow(del0.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, delBody, dc.bgDel);
|
|
595
|
+
emitRow(add0.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, addBody, dc.bgAdd);
|
|
641
596
|
continue;
|
|
642
597
|
}
|
|
643
598
|
if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
644
599
|
const del0 = at(dels, 0);
|
|
645
600
|
const add0 = at(adds, 0);
|
|
646
|
-
const pwd = plainWordDiff(del0.l.content, add0.l.content);
|
|
601
|
+
const pwd = plainWordDiff(del0.l.content, add0.l.content, dc);
|
|
647
602
|
emitRow(
|
|
648
603
|
del0.l.oldNum,
|
|
649
604
|
"-",
|
|
650
|
-
|
|
605
|
+
dc.bgGutterDel,
|
|
651
606
|
`${dc.fgDel}${BOLD}`,
|
|
652
|
-
`${
|
|
653
|
-
|
|
607
|
+
`${dc.bgDel}${pwd.old}`,
|
|
608
|
+
dc.bgDel,
|
|
654
609
|
);
|
|
655
610
|
emitRow(
|
|
656
611
|
add0.l.newNum,
|
|
657
612
|
"+",
|
|
658
|
-
|
|
613
|
+
dc.bgGutterAdd,
|
|
659
614
|
`${dc.fgAdd}${BOLD}`,
|
|
660
|
-
`${
|
|
661
|
-
|
|
615
|
+
`${dc.bgAdd}${pwd.new}`,
|
|
616
|
+
dc.bgAdd,
|
|
662
617
|
);
|
|
663
618
|
continue;
|
|
664
619
|
}
|
|
665
620
|
|
|
666
621
|
for (const d of dels) {
|
|
667
|
-
const body = canHL ? `${
|
|
668
|
-
emitRow(d.l.oldNum, "-",
|
|
622
|
+
const body = canHL ? `${dc.bgDel}${d.hl}` : `${dc.bgDel}${d.l.content}`;
|
|
623
|
+
emitRow(d.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, body, dc.bgDel);
|
|
669
624
|
}
|
|
670
625
|
for (const a of adds) {
|
|
671
|
-
const body = canHL ? `${
|
|
672
|
-
emitRow(a.l.newNum, "+",
|
|
626
|
+
const body = canHL ? `${dc.bgAdd}${a.hl}` : `${dc.bgAdd}${a.l.content}`;
|
|
627
|
+
emitRow(a.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, body, dc.bgAdd);
|
|
673
628
|
}
|
|
674
629
|
}
|
|
675
630
|
|
|
@@ -740,8 +695,8 @@ export async function renderSplit(
|
|
|
740
695
|
}
|
|
741
696
|
const [leftHL, rightHL] = canHL
|
|
742
697
|
? await Promise.all([
|
|
743
|
-
hlBlock(leftSrc.join("\n"), language),
|
|
744
|
-
hlBlock(rightSrc.join("\n"), language),
|
|
698
|
+
hlBlock(leftSrc.join("\n"), language, dc.theme),
|
|
699
|
+
hlBlock(rightSrc.join("\n"), language, dc.theme),
|
|
745
700
|
])
|
|
746
701
|
: [leftSrc, rightSrc];
|
|
747
702
|
|
|
@@ -776,8 +731,8 @@ export async function renderSplit(
|
|
|
776
731
|
|
|
777
732
|
const isDel = line.type === "del";
|
|
778
733
|
const isAdd = line.type === "add";
|
|
779
|
-
const gBg = isDel ?
|
|
780
|
-
const cBg = isDel ?
|
|
734
|
+
const gBg = isDel ? dc.bgGutterDel : isAdd ? dc.bgGutterAdd : BG_BASE;
|
|
735
|
+
const cBg = isDel ? dc.bgDel : isAdd ? dc.bgAdd : BG_BASE;
|
|
781
736
|
const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
|
|
782
737
|
const sign = isDel ? "-" : isAdd ? "+" : " ";
|
|
783
738
|
const num = isDel
|
|
@@ -793,7 +748,7 @@ export async function renderSplit(
|
|
|
793
748
|
|
|
794
749
|
let body: string;
|
|
795
750
|
if (ranges && ranges.length > 0) {
|
|
796
|
-
body = injectBg(hl, ranges, cBg, isDel ?
|
|
751
|
+
body = injectBg(hl, ranges, cBg, isDel ? dc.bgDelHighlight : dc.bgAddHighlight);
|
|
797
752
|
} else if (isDel || isAdd) {
|
|
798
753
|
body = `${cBg}${hl}`;
|
|
799
754
|
} else {
|
|
@@ -828,7 +783,7 @@ export async function renderSplit(
|
|
|
828
783
|
lResult = halfBuild(leftLine, lhl, wd.oldRanges, "left");
|
|
829
784
|
rResult = halfBuild(rightLine, rhl, wd.newRanges, "right");
|
|
830
785
|
} else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
831
|
-
const pwd = plainWordDiff(leftLine.content, rightLine.content);
|
|
786
|
+
const pwd = plainWordDiff(leftLine.content, rightLine.content, dc);
|
|
832
787
|
lI++;
|
|
833
788
|
rI++;
|
|
834
789
|
lResult = halfBuild(leftLine, pwd.old, null, "left");
|
package/src/diff.test.ts
CHANGED
|
@@ -1,9 +1,77 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
|
|
3
2
|
import { parseDiff } from "./diff.js";
|
|
3
|
+
import {
|
|
4
|
+
diffThemeCacheKey,
|
|
5
|
+
renderDiffSummary,
|
|
6
|
+
renderUnified,
|
|
7
|
+
resolveDiffColors,
|
|
8
|
+
} from "./diff-render.js";
|
|
4
9
|
|
|
5
10
|
const OLD = "line1\nline2\nline3";
|
|
6
11
|
const NEW = "line1\nCHANGED\nline3";
|
|
12
|
+
const ANSI_RE = /\x1b\[[0-9;]*m|<\/?syntax\w+>/g;
|
|
13
|
+
|
|
14
|
+
describe("theme-derived diff rendering", () => {
|
|
15
|
+
const theme = {
|
|
16
|
+
fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
|
|
17
|
+
getFgAnsi: (key: string) => {
|
|
18
|
+
if (key === "toolDiffAdded") return "\x1b[38;2;120;210;150m";
|
|
19
|
+
if (key === "toolDiffRemoved") return "\x1b[38;2;230;120;130m";
|
|
20
|
+
if (key === "toolDiffContext") return "\x1b[38;2;130;140;150m";
|
|
21
|
+
return "";
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it("uses semantic foregrounds without tint backgrounds", () => {
|
|
26
|
+
const colors = resolveDiffColors(theme);
|
|
27
|
+
expect(colors.fgAdd).toBe("\x1b[38;2;120;210;150m");
|
|
28
|
+
expect(colors.fgDel).toBe("\x1b[38;2;230;120;130m");
|
|
29
|
+
expect(colors.fgCtx).toBe("\x1b[38;2;130;140;150m");
|
|
30
|
+
for (const key of [
|
|
31
|
+
"bgAdd",
|
|
32
|
+
"bgDel",
|
|
33
|
+
"bgAddHighlight",
|
|
34
|
+
"bgDelHighlight",
|
|
35
|
+
"bgGutterAdd",
|
|
36
|
+
"bgGutterDel",
|
|
37
|
+
] as const) {
|
|
38
|
+
expect(colors[key]).toBe("\x1b[49m");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("includes semantic theme colors in cache identity", () => {
|
|
43
|
+
const changed = { ...theme, getFgAnsi: () => "\x1b[38;2;1;2;3m" };
|
|
44
|
+
expect(diffThemeCacheKey(theme)).not.toBe(diffThemeCacheKey(changed));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("colors persisted plain summaries only at render time", () => {
|
|
48
|
+
expect(renderDiffSummary("+3 -2", theme)).toBe(
|
|
49
|
+
"<toolDiffAdded>+3</toolDiffAdded> <toolDiffRemoved>-2</toolDiffRemoved>",
|
|
50
|
+
);
|
|
51
|
+
expect(renderDiffSummary("no changes", theme)).toBe(
|
|
52
|
+
"<toolDiffContext>no changes</toolDiffContext>",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps the foreground-only diff style", async () => {
|
|
57
|
+
const rendered = await renderUnified(
|
|
58
|
+
parseDiff("const oldValue = 1;", "const newValue = 2;"),
|
|
59
|
+
"typescript",
|
|
60
|
+
80,
|
|
61
|
+
resolveDiffColors({ ...theme, fg: (_key, text) => text }),
|
|
62
|
+
);
|
|
63
|
+
const lines = rendered.replace(ANSI_RE, "").split("\n");
|
|
64
|
+
|
|
65
|
+
expect(lines).toHaveLength(4);
|
|
66
|
+
expect(lines[0]).toMatch(/^─+$/);
|
|
67
|
+
expect(lines[1]).toMatch(/^▌\s+1- │ const oldValue = 1;\s*$/);
|
|
68
|
+
expect(lines[2]).toMatch(/^▌\s+1\+ │ const newValue = 2;\s*$/);
|
|
69
|
+
expect(lines[3]).toMatch(/^─+$/);
|
|
70
|
+
expect(rendered).toContain(theme.getFgAnsi("toolDiffRemoved"));
|
|
71
|
+
expect(rendered).toContain(theme.getFgAnsi("toolDiffAdded"));
|
|
72
|
+
expect(rendered).not.toMatch(/\x1b\[48(?:;[^m]*)?m/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
7
75
|
|
|
8
76
|
describe("parseDiff baseLine", () => {
|
|
9
77
|
it("is snippet-relative when baseLine omitted (default 0)", () => {
|
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,7 @@ 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 accessor
|
|
32
|
-
// to pull toolDiffAdded/Removed/Context colors. Absent on minimal themes.
|
|
31
|
+
// Optional raw-ANSI accessor exposed by Pi's theme for semantic foregrounds.
|
|
33
32
|
getFgAnsi?: (key: string) => string;
|
|
34
33
|
};
|
|
35
34
|
|
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");
|