@wierdbytes/pi-facelift 0.3.0 → 0.3.1

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - **Fix**: `read` bodies rendered as plain uncolored text whenever
6
+ pi's `settings.theme` was a name Shiki doesn't ship (e.g.
7
+ `tokyo-night-storm`). pi-facelift forwarded the raw value to
8
+ Shiki's `codeToANSI`, which threw, and `hlBlock`'s `catch` block
9
+ silently fell back to plain `code.split("\n")` — leaving frame
10
+ chrome and line numbers but no syntax highlighting.
11
+ Three changes land together:
12
+ 1. The resolved theme is now validated against Shiki's
13
+ `bundledThemes` set; unknown names fall back to
14
+ `DEFAULT_THEME` (`github-dark`) instead of being passed through.
15
+ 2. Common pi/host theme aliases are mapped to their nearest Shiki
16
+ bundled equivalent (`tokyo-night-storm` → `tokyo-night`,
17
+ `catppuccin` → `catppuccin-mocha`, `material-darker` →
18
+ `material-theme-darker`, `gruvbox-dark` → `gruvbox-dark-medium`,
19
+ `solarized` → `solarized-dark`, `one-dark` → `one-dark-pro`,
20
+ and Tokyo Night/Material/Catppuccin variants). Lookup is
21
+ case-insensitive.
22
+ 3. The `hlBlock` catch now logs once per `(theme, language)` to
23
+ stderr (instead of swallowing every error forever) so future
24
+ regressions in the highlighter pipeline surface during dev.
25
+ A one-shot `console.error` is also emitted the first time an
26
+ unknown theme name is rejected, advertising `FACELIFT_THEME` as the
27
+ override knob.
28
+
3
29
  ## 0.3.0
4
30
 
5
31
  - Frame primitives (open-right rounded box for `read` / `bash` / `ls` /
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): BundledTheme | undefined {
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" ? (settings.theme as BundledTheme) : undefined;
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
- return (
87
- (process.env.FACELIFT_THEME as BundledTheme | undefined) ??
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
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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
+ });