@wierdbytes/pi-facelift 0.3.0 → 0.3.2
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/CHANGELOG.md +39 -0
- package/bash-rendering.test.ts +16 -13
- package/index.ts +113 -10
- package/package.json +1 -1
- package/theme-resolution.test.ts +238 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
- **Change**: `bash` tool titles always render the full command, even
|
|
6
|
+
in collapsed view. Previously the multi-line command string was
|
|
7
|
+
sliced at 80 chars (with a trailing `…`) before being split into
|
|
8
|
+
sub-tree continuation rows, which clobbered the second line of
|
|
9
|
+
short multi-step commands like
|
|
10
|
+
`tmux kill-session -t poc 2>/dev/null\nrm -f /Users/.../poc/long-path`.
|
|
11
|
+
Per-line right-truncation inside `frameTop` is unchanged — lines
|
|
12
|
+
that genuinely overflow the terminal width are still clipped — so
|
|
13
|
+
the only behavior change is that long commands no longer hide
|
|
14
|
+
content behind the expand toggle.
|
|
15
|
+
|
|
16
|
+
## 0.3.1
|
|
17
|
+
|
|
18
|
+
- **Fix**: `read` bodies rendered as plain uncolored text whenever
|
|
19
|
+
pi's `settings.theme` was a name Shiki doesn't ship (e.g.
|
|
20
|
+
`tokyo-night-storm`). pi-facelift forwarded the raw value to
|
|
21
|
+
Shiki's `codeToANSI`, which threw, and `hlBlock`'s `catch` block
|
|
22
|
+
silently fell back to plain `code.split("\n")` — leaving frame
|
|
23
|
+
chrome and line numbers but no syntax highlighting.
|
|
24
|
+
Three changes land together:
|
|
25
|
+
1. The resolved theme is now validated against Shiki's
|
|
26
|
+
`bundledThemes` set; unknown names fall back to
|
|
27
|
+
`DEFAULT_THEME` (`github-dark`) instead of being passed through.
|
|
28
|
+
2. Common pi/host theme aliases are mapped to their nearest Shiki
|
|
29
|
+
bundled equivalent (`tokyo-night-storm` → `tokyo-night`,
|
|
30
|
+
`catppuccin` → `catppuccin-mocha`, `material-darker` →
|
|
31
|
+
`material-theme-darker`, `gruvbox-dark` → `gruvbox-dark-medium`,
|
|
32
|
+
`solarized` → `solarized-dark`, `one-dark` → `one-dark-pro`,
|
|
33
|
+
and Tokyo Night/Material/Catppuccin variants). Lookup is
|
|
34
|
+
case-insensitive.
|
|
35
|
+
3. The `hlBlock` catch now logs once per `(theme, language)` to
|
|
36
|
+
stderr (instead of swallowing every error forever) so future
|
|
37
|
+
regressions in the highlighter pipeline surface during dev.
|
|
38
|
+
A one-shot `console.error` is also emitted the first time an
|
|
39
|
+
unknown theme name is rejected, advertising `FACELIFT_THEME` as the
|
|
40
|
+
override knob.
|
|
41
|
+
|
|
3
42
|
## 0.3.0
|
|
4
43
|
|
|
5
44
|
- Frame primitives (open-right rounded box for `read` / `bash` / `ls` /
|
package/bash-rendering.test.ts
CHANGED
|
@@ -72,21 +72,24 @@ function loadBashTool() {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
describe("bash renderCall expansion", () => {
|
|
75
|
-
it("
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
it("shows the full command even when collapsed (no length-based truncation)", () => {
|
|
76
|
+
withStdoutColumns(200, () => {
|
|
77
|
+
const bashTool = loadBashTool();
|
|
78
|
+
// 120-char command fits inside a 200-col frame, so the only previous
|
|
79
|
+
// reason it would have been clipped was the old 80-char compact cap.
|
|
80
|
+
const command = `printf '${"x".repeat(120)}'`;
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
const rendered = bashTool.renderCall({ command }, mockTheme, {
|
|
83
|
+
lastComponent: new MockText(),
|
|
84
|
+
isError: false,
|
|
85
|
+
state: {},
|
|
86
|
+
expanded: false,
|
|
87
|
+
invalidate: () => {},
|
|
88
|
+
});
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
expect(rendered.getText()).toContain("bash");
|
|
91
|
+
expect(rendered.getText()).toContain(command);
|
|
92
|
+
});
|
|
90
93
|
});
|
|
91
94
|
|
|
92
95
|
it("shows the full command when expanded", () => {
|
package/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
44
44
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
45
45
|
import { codeToANSI } from "@shikijs/cli";
|
|
46
|
+
import { bundledThemes } from "shiki";
|
|
46
47
|
import {
|
|
47
48
|
formatDuration,
|
|
48
49
|
frameBodyLines,
|
|
@@ -63,12 +64,83 @@ import type { BundledLanguage, BundledTheme } from "shiki";
|
|
|
63
64
|
|
|
64
65
|
const DEFAULT_THEME: BundledTheme = "github-dark";
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Aliases that map common pi/host theme names (or popular external
|
|
69
|
+
* themes that aren't bundled with Shiki) to their closest Shiki
|
|
70
|
+
* `BundledTheme` equivalent.
|
|
71
|
+
*
|
|
72
|
+
* Keys are lowercased; values must be valid `BundledTheme` keys.
|
|
73
|
+
* The full Shiki theme list lives at https://shiki.style/themes.
|
|
74
|
+
*
|
|
75
|
+
* Why this exists: pi's `settings.theme` is consumed by the host UI
|
|
76
|
+
* and may use names Shiki doesn't ship (e.g. `tokyo-night-storm`).
|
|
77
|
+
* Without aliasing, `codeToANSI` throws and `hlBlock` silently falls
|
|
78
|
+
* back to plain text — leaving every `read` body uncolored.
|
|
79
|
+
*/
|
|
80
|
+
const THEME_ALIASES: Record<string, BundledTheme> = {
|
|
81
|
+
// Tokyo Night family (storm/night/day are Tokyo Night palette variants;
|
|
82
|
+
// Shiki only ships the storm-equivalent under the bare `tokyo-night` name).
|
|
83
|
+
"tokyo-night-storm": "tokyo-night",
|
|
84
|
+
"tokyo-night-night": "tokyo-night",
|
|
85
|
+
"tokyo-night-day": "tokyo-night",
|
|
86
|
+
tokyonight: "tokyo-night",
|
|
87
|
+
"tokyonight-storm": "tokyo-night",
|
|
88
|
+
"tokyonight-night": "tokyo-night",
|
|
89
|
+
"tokyonight-day": "tokyo-night",
|
|
90
|
+
// Catppuccin (default → mocha, the most common dark variant)
|
|
91
|
+
catppuccin: "catppuccin-mocha",
|
|
92
|
+
// Dracula
|
|
93
|
+
"dracula-pro": "dracula",
|
|
94
|
+
// Material
|
|
95
|
+
material: "material-theme",
|
|
96
|
+
"material-darker": "material-theme-darker",
|
|
97
|
+
"material-lighter": "material-theme-lighter",
|
|
98
|
+
"material-ocean": "material-theme-ocean",
|
|
99
|
+
"material-palenight": "material-theme-palenight",
|
|
100
|
+
// Gruvbox (medium contrast is the canonical default)
|
|
101
|
+
"gruvbox-dark": "gruvbox-dark-medium",
|
|
102
|
+
"gruvbox-light": "gruvbox-light-medium",
|
|
103
|
+
// Solarized (default → dark)
|
|
104
|
+
solarized: "solarized-dark",
|
|
105
|
+
// One Dark
|
|
106
|
+
"one-dark": "one-dark-pro",
|
|
107
|
+
onedark: "one-dark-pro",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const _themeWarningShown = new Set<string>();
|
|
111
|
+
|
|
112
|
+
function isBundledTheme(name: string): name is BundledTheme {
|
|
113
|
+
return Object.prototype.hasOwnProperty.call(bundledThemes, name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function warnInvalidTheme(rawName: string, fallback: BundledTheme): void {
|
|
117
|
+
if (_themeWarningShown.has(rawName)) return;
|
|
118
|
+
_themeWarningShown.add(rawName);
|
|
119
|
+
console.error(
|
|
120
|
+
`pi-facelift: theme "${rawName}" is not a Shiki bundled theme; ` +
|
|
121
|
+
`falling back to "${fallback}". ` +
|
|
122
|
+
`Set FACELIFT_THEME to one of: ${Object.keys(bundledThemes).sort().join(", ")}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve a raw theme name to a valid `BundledTheme`, applying aliases. */
|
|
127
|
+
function resolveBundledTheme(rawName: string | undefined): BundledTheme {
|
|
128
|
+
if (!rawName) return DEFAULT_THEME;
|
|
129
|
+
const trimmed = rawName.trim();
|
|
130
|
+
if (!trimmed) return DEFAULT_THEME;
|
|
131
|
+
if (isBundledTheme(trimmed)) return trimmed;
|
|
132
|
+
const aliased = THEME_ALIASES[trimmed.toLowerCase()];
|
|
133
|
+
if (aliased) return aliased;
|
|
134
|
+
warnInvalidTheme(trimmed, DEFAULT_THEME);
|
|
135
|
+
return DEFAULT_THEME;
|
|
136
|
+
}
|
|
137
|
+
|
|
66
138
|
function getDefaultAgentDir(): string | undefined {
|
|
67
139
|
const home = process.env.HOME ?? "";
|
|
68
140
|
return home ? join(home, ".pi/agent") : undefined;
|
|
69
141
|
}
|
|
70
142
|
|
|
71
|
-
function readThemeFromSettings(agentDir?: string):
|
|
143
|
+
function readThemeFromSettings(agentDir?: string): string | undefined {
|
|
72
144
|
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
|
73
145
|
if (!resolvedAgentDir) return undefined;
|
|
74
146
|
|
|
@@ -76,27 +148,34 @@ function readThemeFromSettings(agentDir?: string): BundledTheme | undefined {
|
|
|
76
148
|
const settings = JSON.parse(readFileSync(join(resolvedAgentDir, "settings.json"), "utf8")) as {
|
|
77
149
|
theme?: unknown;
|
|
78
150
|
};
|
|
79
|
-
return typeof settings.theme === "string" ?
|
|
151
|
+
return typeof settings.theme === "string" ? settings.theme : undefined;
|
|
80
152
|
} catch {
|
|
81
153
|
return undefined;
|
|
82
154
|
}
|
|
83
155
|
}
|
|
84
156
|
|
|
85
157
|
function resolvePrettyTheme(agentDir?: string): BundledTheme {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
readThemeFromSettings(agentDir) ??
|
|
89
|
-
DEFAULT_THEME
|
|
90
|
-
);
|
|
158
|
+
const raw = process.env.FACELIFT_THEME ?? readThemeFromSettings(agentDir);
|
|
159
|
+
return resolveBundledTheme(raw);
|
|
91
160
|
}
|
|
92
161
|
|
|
93
162
|
let THEME: BundledTheme = resolvePrettyTheme();
|
|
94
163
|
|
|
164
|
+
/** Test-only hooks for theme resolution (alias map, bundled validation, fallback warning). */
|
|
165
|
+
export const __themeInternals = {
|
|
166
|
+
DEFAULT_THEME,
|
|
167
|
+
THEME_ALIASES,
|
|
168
|
+
isBundledTheme,
|
|
169
|
+
resolveBundledTheme,
|
|
170
|
+
resetWarningsForTests: () => _themeWarningShown.clear(),
|
|
171
|
+
};
|
|
172
|
+
|
|
95
173
|
function setPrettyTheme(agentDir?: string): void {
|
|
96
174
|
const resolvedTheme = resolvePrettyTheme(agentDir);
|
|
97
175
|
if (resolvedTheme === THEME) return;
|
|
98
176
|
THEME = resolvedTheme;
|
|
99
177
|
_cache.clear();
|
|
178
|
+
_hlErrorLogged.clear();
|
|
100
179
|
codeToANSI("", "typescript", THEME).catch(() => {});
|
|
101
180
|
}
|
|
102
181
|
|
|
@@ -566,6 +645,7 @@ function dirIcon(): string {
|
|
|
566
645
|
codeToANSI("", "typescript", THEME).catch(() => {});
|
|
567
646
|
|
|
568
647
|
const _cache = new Map<string, string[]>();
|
|
648
|
+
const _hlErrorLogged = new Set<string>();
|
|
569
649
|
|
|
570
650
|
function _touch(k: string, v: string[]): string[] {
|
|
571
651
|
_cache.delete(k);
|
|
@@ -590,11 +670,30 @@ async function hlBlock(code: string, language: BundledLanguage | undefined): Pro
|
|
|
590
670
|
const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
|
|
591
671
|
const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
|
|
592
672
|
return _touch(k, out);
|
|
593
|
-
} catch {
|
|
673
|
+
} catch (err) {
|
|
674
|
+
// Log once per (theme, language) so silent regressions are visible
|
|
675
|
+
// during dev (e.g. an unsupported theme name slipping through, or a
|
|
676
|
+
// grammar load failure for a niche language) without spamming on
|
|
677
|
+
// every render. The plain-text fallback below keeps the body usable.
|
|
678
|
+
const tag = `${THEME}\0${language}`;
|
|
679
|
+
if (!_hlErrorLogged.has(tag)) {
|
|
680
|
+
_hlErrorLogged.add(tag);
|
|
681
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
682
|
+
console.error(
|
|
683
|
+
`pi-facelift: shiki highlighting failed for theme="${THEME}" language="${language}": ${msg}`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
594
686
|
return code.split("\n");
|
|
595
687
|
}
|
|
596
688
|
}
|
|
597
689
|
|
|
690
|
+
/** Test-only hooks for the Shiki highlighter pipeline. */
|
|
691
|
+
export const __hlInternals = {
|
|
692
|
+
hlBlock,
|
|
693
|
+
resetErrorLogForTests: () => _hlErrorLogged.clear(),
|
|
694
|
+
resetCacheForTests: () => _cache.clear(),
|
|
695
|
+
};
|
|
696
|
+
|
|
598
697
|
// ---------------------------------------------------------------------------
|
|
599
698
|
// Renderers
|
|
600
699
|
// ---------------------------------------------------------------------------
|
|
@@ -1115,7 +1214,6 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1115
1214
|
state.endedAt = undefined;
|
|
1116
1215
|
}
|
|
1117
1216
|
const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
|
|
1118
|
-
const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
|
|
1119
1217
|
|
|
1120
1218
|
// Multi-line bash commands (shell line continuations with `\`, here-docs,
|
|
1121
1219
|
// or embedded newlines) need each line wrapped in the accent color
|
|
@@ -1123,7 +1221,12 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1123
1221
|
// whitespace on continuation lines is dropped — the sub-tree connector
|
|
1124
1222
|
// already provides visual indentation, so the user's heredoc indent
|
|
1125
1223
|
// would just push content right and misalign the tree.
|
|
1126
|
-
|
|
1224
|
+
//
|
|
1225
|
+
// The full command is always rendered in the title — no length-based
|
|
1226
|
+
// truncation in compact mode. `frameTop` still right-truncates any
|
|
1227
|
+
// individual line that exceeds the frame width, which is a separate
|
|
1228
|
+
// (display-fit) concern from hiding command content.
|
|
1229
|
+
const cmdLines = cmd.split("\n");
|
|
1127
1230
|
const firstCmd = cmdLines[0];
|
|
1128
1231
|
const restCmd = cmdLines.slice(1).map((line) => line.replace(/^\s+/, ""));
|
|
1129
1232
|
const firstTitle = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", firstCmd)}${timeout}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wierdbytes/pi-facelift",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Cosmetic facelift for built-in pi tool output — open-right rounded frames, syntax-highlighted reads, status-aware bash summaries, find/grep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for Shiki theme resolution.
|
|
3
|
+
*
|
|
4
|
+
* Background: pi's `~/.pi/agent/settings.json` `theme` field is consumed
|
|
5
|
+
* by the host UI and may carry names that aren't valid Shiki bundled
|
|
6
|
+
* themes (e.g. `tokyo-night-storm`). Before this regression net,
|
|
7
|
+
* `pi-facelift` passed the raw value straight to Shiki's `codeToANSI`,
|
|
8
|
+
* which threw, and `hlBlock`'s `catch` block silently fell back to
|
|
9
|
+
* uncolored text — leaving every `read` body in plain whitish output
|
|
10
|
+
* with no obvious error.
|
|
11
|
+
*
|
|
12
|
+
* These tests pin three guarantees:
|
|
13
|
+
* 1. Bundled themes pass through untouched.
|
|
14
|
+
* 2. Common pi/host theme names are aliased to their nearest Shiki
|
|
15
|
+
* bundled equivalent (case-insensitive).
|
|
16
|
+
* 3. Unknown / empty / whitespace-only theme names fall back to
|
|
17
|
+
* `DEFAULT_THEME` and emit a one-shot console.error so silent
|
|
18
|
+
* regressions are visible during dev.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
22
|
+
import { bundledThemes } from "shiki";
|
|
23
|
+
|
|
24
|
+
import { __hlInternals, __themeInternals } from "./index.ts";
|
|
25
|
+
|
|
26
|
+
const { DEFAULT_THEME, THEME_ALIASES, isBundledTheme, resolveBundledTheme, resetWarningsForTests } =
|
|
27
|
+
__themeInternals;
|
|
28
|
+
|
|
29
|
+
const { hlBlock, resetCacheForTests, resetErrorLogForTests } = __hlInternals;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
resetWarningsForTests();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("theme resolution: alias map sanity", () => {
|
|
36
|
+
it("DEFAULT_THEME is a valid Shiki bundled theme", () => {
|
|
37
|
+
expect(isBundledTheme(DEFAULT_THEME)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("every alias value points at a valid Shiki bundled theme", () => {
|
|
41
|
+
for (const [from, to] of Object.entries(THEME_ALIASES)) {
|
|
42
|
+
expect(isBundledTheme(to), `alias ${from} -> ${to} must be bundled`).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("alias keys are lowercase (matching the lookup convention)", () => {
|
|
47
|
+
for (const key of Object.keys(THEME_ALIASES)) {
|
|
48
|
+
expect(key).toBe(key.toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not alias names that are already bundled (would be dead code)", () => {
|
|
53
|
+
for (const key of Object.keys(THEME_ALIASES)) {
|
|
54
|
+
expect(
|
|
55
|
+
Object.prototype.hasOwnProperty.call(bundledThemes, key),
|
|
56
|
+
`alias key "${key}" is already a bundled theme; alias entry is unreachable`,
|
|
57
|
+
).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("resolveBundledTheme: passthrough + fallbacks", () => {
|
|
63
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
errorSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns a bundled theme name unchanged", () => {
|
|
73
|
+
expect(resolveBundledTheme("github-dark")).toBe("github-dark");
|
|
74
|
+
expect(resolveBundledTheme("dracula")).toBe("dracula");
|
|
75
|
+
expect(resolveBundledTheme("nord")).toBe("nord");
|
|
76
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("falls back to DEFAULT_THEME for undefined/empty/whitespace input", () => {
|
|
80
|
+
expect(resolveBundledTheme(undefined)).toBe(DEFAULT_THEME);
|
|
81
|
+
expect(resolveBundledTheme("")).toBe(DEFAULT_THEME);
|
|
82
|
+
expect(resolveBundledTheme(" ")).toBe(DEFAULT_THEME);
|
|
83
|
+
// Empty/whitespace input is the "no theme configured" path — no warning.
|
|
84
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("trims surrounding whitespace before validating", () => {
|
|
88
|
+
expect(resolveBundledTheme(" github-dark ")).toBe("github-dark");
|
|
89
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("resolveBundledTheme: alias resolution", () => {
|
|
94
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
98
|
+
});
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
errorSpy.mockRestore();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The original bug report: pi user has `"theme": "tokyo-night-storm"`,
|
|
104
|
+
// Shiki only ships `tokyo-night`.
|
|
105
|
+
it("aliases tokyo-night-storm to tokyo-night (the original bug report)", () => {
|
|
106
|
+
expect(resolveBundledTheme("tokyo-night-storm")).toBe("tokyo-night");
|
|
107
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("aliases other tokyo-night variants", () => {
|
|
111
|
+
expect(resolveBundledTheme("tokyo-night-night")).toBe("tokyo-night");
|
|
112
|
+
expect(resolveBundledTheme("tokyo-night-day")).toBe("tokyo-night");
|
|
113
|
+
expect(resolveBundledTheme("tokyonight")).toBe("tokyo-night");
|
|
114
|
+
expect(resolveBundledTheme("tokyonight-storm")).toBe("tokyo-night");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("aliases case-insensitively", () => {
|
|
118
|
+
expect(resolveBundledTheme("Tokyo-Night-Storm")).toBe("tokyo-night");
|
|
119
|
+
expect(resolveBundledTheme("CATPPUCCIN")).toBe("catppuccin-mocha");
|
|
120
|
+
expect(resolveBundledTheme("Material")).toBe("material-theme");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("aliases catppuccin family defaults", () => {
|
|
124
|
+
expect(resolveBundledTheme("catppuccin")).toBe("catppuccin-mocha");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("aliases gruvbox/material/solarized/one-dark families to canonical variants", () => {
|
|
128
|
+
expect(resolveBundledTheme("gruvbox-dark")).toBe("gruvbox-dark-medium");
|
|
129
|
+
expect(resolveBundledTheme("gruvbox-light")).toBe("gruvbox-light-medium");
|
|
130
|
+
expect(resolveBundledTheme("material")).toBe("material-theme");
|
|
131
|
+
expect(resolveBundledTheme("material-darker")).toBe("material-theme-darker");
|
|
132
|
+
expect(resolveBundledTheme("solarized")).toBe("solarized-dark");
|
|
133
|
+
expect(resolveBundledTheme("one-dark")).toBe("one-dark-pro");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("resolveBundledTheme: invalid theme warning", () => {
|
|
138
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
142
|
+
});
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
errorSpy.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("falls back to DEFAULT_THEME for an unknown name and warns once", () => {
|
|
148
|
+
expect(resolveBundledTheme("not-a-real-theme-9000")).toBe(DEFAULT_THEME);
|
|
149
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
150
|
+
|
|
151
|
+
const msg = errorSpy.mock.calls[0]?.[0];
|
|
152
|
+
expect(msg).toContain("pi-facelift");
|
|
153
|
+
expect(msg).toContain("not-a-real-theme-9000");
|
|
154
|
+
expect(msg).toContain(DEFAULT_THEME);
|
|
155
|
+
// The warning should advertise FACELIFT_THEME as the fix knob.
|
|
156
|
+
expect(msg).toContain("FACELIFT_THEME");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("warns at most once per distinct invalid theme (no per-render spam)", () => {
|
|
160
|
+
expect(resolveBundledTheme("bogus-theme")).toBe(DEFAULT_THEME);
|
|
161
|
+
expect(resolveBundledTheme("bogus-theme")).toBe(DEFAULT_THEME);
|
|
162
|
+
expect(resolveBundledTheme("bogus-theme")).toBe(DEFAULT_THEME);
|
|
163
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("warns separately for distinct invalid names", () => {
|
|
167
|
+
expect(resolveBundledTheme("first-bad")).toBe(DEFAULT_THEME);
|
|
168
|
+
expect(resolveBundledTheme("second-bad")).toBe(DEFAULT_THEME);
|
|
169
|
+
expect(errorSpy).toHaveBeenCalledTimes(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("does NOT warn for names resolved via the alias map", () => {
|
|
173
|
+
// `tokyo-night-storm` is invalid for Shiki but covered by an alias —
|
|
174
|
+
// this is the *fixed* path, not a regression, so it must stay quiet.
|
|
175
|
+
expect(resolveBundledTheme("tokyo-night-storm")).toBe("tokyo-night");
|
|
176
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("hlBlock: Shiki failure logging + plain-text fallback", () => {
|
|
181
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
182
|
+
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
185
|
+
resetCacheForTests();
|
|
186
|
+
resetErrorLogForTests();
|
|
187
|
+
});
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
errorSpy.mockRestore();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns plain split lines when language is missing (no shiki call, no log)", async () => {
|
|
193
|
+
const out = await hlBlock("a\nb\nc", undefined);
|
|
194
|
+
expect(out).toEqual(["a", "b", "c"]);
|
|
195
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns [''] for empty input", async () => {
|
|
199
|
+
expect(await hlBlock("", "typescript")).toEqual([""]);
|
|
200
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns ANSI-colored lines for a valid theme + language (sanity)", async () => {
|
|
204
|
+
const out = await hlBlock("const x = 1;", "typescript");
|
|
205
|
+
expect(out.length).toBeGreaterThan(0);
|
|
206
|
+
// Joined output must contain at least one ANSI escape — proof shiki ran.
|
|
207
|
+
expect(out.join("\n")).toMatch(/\x1b\[/);
|
|
208
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("falls back to plain split lines when shiki rejects a bogus language, and logs once", async () => {
|
|
212
|
+
// `"definitely-not-a-language"` is not a `BundledLanguage`, but the
|
|
213
|
+
// runtime check in shiki throws synchronously inside the promise.
|
|
214
|
+
// We cast through `unknown` to bypass the compile-time guard — this
|
|
215
|
+
// is the same shape a stale settings.theme value would take if it
|
|
216
|
+
// somehow reached the highlighter.
|
|
217
|
+
const bogusLang = "definitely-not-a-language" as unknown as Parameters<
|
|
218
|
+
typeof hlBlock
|
|
219
|
+
>[1];
|
|
220
|
+
const src = "const x = 1;";
|
|
221
|
+
|
|
222
|
+
const out1 = await hlBlock(src, bogusLang);
|
|
223
|
+
expect(out1).toEqual([src]);
|
|
224
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
225
|
+
|
|
226
|
+
const msg = errorSpy.mock.calls[0]?.[0];
|
|
227
|
+
expect(msg).toContain("pi-facelift");
|
|
228
|
+
expect(msg).toContain("shiki");
|
|
229
|
+
expect(msg).toContain("definitely-not-a-language");
|
|
230
|
+
|
|
231
|
+
// Second call with the same (theme, language) tag must NOT re-log.
|
|
232
|
+
// We change the source so the cache key differs and the catch path
|
|
233
|
+
// runs again — still only one log.
|
|
234
|
+
const out2 = await hlBlock("let y = 2;", bogusLang);
|
|
235
|
+
expect(out2).toEqual(["let y = 2;"]);
|
|
236
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
237
|
+
});
|
|
238
|
+
});
|