@wierdbytes/pi-facelift 0.1.1 → 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 ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
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
+
29
+ ## 0.3.0
30
+
31
+ - Frame primitives (open-right rounded box for `read` / `bash` / `ls` /
32
+ `find` / `grep`) extracted to `@wierdbytes/pi-common/tool-frame`.
33
+ pi-facelift now consumes them as a library so the same chrome can be
34
+ reused by other extensions (e.g. `@wierdbytes/pi-web`).
35
+ - Status border colours now follow host theme tokens (`success` →
36
+ `theme.fg("success", …)`, pending → `warning`, error → `error`)
37
+ instead of pi-facelift's previous fixed RGB palette. Visual contract
38
+ is unchanged on the default pi theme; on custom themes the frame
39
+ inherits the user's palette.
40
+ - `formatDuration` (the bash bottom-label timer formatter) moved to
41
+ `@wierdbytes/pi-common/tool-frame` so other extensions can reuse it.
42
+ Behaviour unchanged.
43
+ - **Fix**: the `read` tool's frame body silently went empty after the
44
+ Phase-2 refactor because `renderFileContent` still referenced the
45
+ deleted `bodyW()` helper, and the highlighter promise's
46
+ `.catch(() => {})` swallowed the resulting `ReferenceError`. The
47
+ width is now computed inline as `termW() - 1` and the catch logs to
48
+ stderr so future regressions in the highlighter pipeline are
49
+ visible. Same `console.error` treatment applied to the matching
50
+ `renderGrepResults` catch.
package/README.md CHANGED
@@ -14,10 +14,19 @@ tool output.
14
14
  - **`bash`** — open-right frame with the duration + status pinned to the
15
15
  bottom border
16
16
 
17
- Status color follows pi: **green** on success, **yellow** while still
18
- running, **red** on non-zero / timeout / abort. The exit summary is
19
- consistent across statuses (`✓ exit 0`, `✗ exit N`, `⚡ timed out`,
20
- `⚡ aborted`). Duration is shown as `3.3s` / `1m3s` / `2h5m`.
17
+ Status colour follows the host theme tokens (`success` while finished,
18
+ `warning` while running, `error` on non-zero / timeout / abort) so the
19
+ chrome blends with whatever palette the user is running. The exit
20
+ summary is consistent across statuses (`✓ exit 0`, `✗ exit N`,
21
+ `⚡ timed out`, `⚡ aborted`). Duration is shown as `3.3s` / `1m3s` /
22
+ `2h5m`.
23
+
24
+ > Frame primitives (top/bottom borders, rail, multi-line title
25
+ > sub-tree) live in [`@wierdbytes/pi-common/tool-frame`][cf] and are
26
+ > shared with `@wierdbytes/pi-web` so every tool that opts into
27
+ > `renderShell: "self"` looks the same.
28
+
29
+ [cf]: ../common/README.md#tool-frame
21
30
  - **`ls`** — Nerd Font file icons + tree-oriented rendering.
22
31
  - **`find` / `grep`** — grouped / highlighted rendering on top of pi's
23
32
  built-in tool implementations (no extra search backend, no extra
@@ -30,6 +30,15 @@ class MockText {
30
30
  }
31
31
  }
32
32
 
33
+ // Minimal duck-typed theme: the new shared frame helpers in
34
+ // `@wierdbytes/pi-common/tool-frame` invoke `theme.fg(token, str)` to colour
35
+ // the chrome, so the empty-object placeholder this test used previously no
36
+ // longer suffices. Pass-through `fg` keeps assertions focused on structure.
37
+ const mockTheme = {
38
+ fg: (_key: string, text: string) => text,
39
+ bold: (text: string) => text,
40
+ };
41
+
33
42
  function mockToolFactory(exec: any) {
34
43
  return (_cwd: string) => ({
35
44
  name: "mock",
@@ -136,7 +145,7 @@ describe("image rendering terminal detection", () => {
136
145
  }));
137
146
 
138
147
  const result = await readTool.execute("t1", { path: "media/inline-image.png" }, null, null, {});
139
- const rendered = readTool.renderResult(result, {}, {}, {
148
+ const rendered = readTool.renderResult(result, {}, mockTheme, {
140
149
  lastComponent: new MockText(),
141
150
  isError: false,
142
151
  state: {},
package/index.ts CHANGED
@@ -40,8 +40,22 @@ import type {
40
40
  ReadToolInput,
41
41
  ToolRenderResultOptions,
42
42
  } from "@earendil-works/pi-coding-agent";
43
+ import type { Theme } from "@earendil-works/pi-coding-agent";
43
44
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
44
45
  import { codeToANSI } from "@shikijs/cli";
46
+ import { bundledThemes } from "shiki";
47
+ import {
48
+ formatDuration,
49
+ frameBodyLines,
50
+ frameBottom,
51
+ frameBottomWithLabel,
52
+ frameResult,
53
+ frameResultWithBottomLabel,
54
+ frameTop,
55
+ getFrameStatus,
56
+ renderToolError,
57
+ type FrameStatus,
58
+ } from "@wierdbytes/pi-common/tool-frame";
45
59
  import type { BundledLanguage, BundledTheme } from "shiki";
46
60
 
47
61
  // ---------------------------------------------------------------------------
@@ -50,12 +64,83 @@ import type { BundledLanguage, BundledTheme } from "shiki";
50
64
 
51
65
  const DEFAULT_THEME: BundledTheme = "github-dark";
52
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
+
53
138
  function getDefaultAgentDir(): string | undefined {
54
139
  const home = process.env.HOME ?? "";
55
140
  return home ? join(home, ".pi/agent") : undefined;
56
141
  }
57
142
 
58
- function readThemeFromSettings(agentDir?: string): BundledTheme | undefined {
143
+ function readThemeFromSettings(agentDir?: string): string | undefined {
59
144
  const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
60
145
  if (!resolvedAgentDir) return undefined;
61
146
 
@@ -63,27 +148,34 @@ function readThemeFromSettings(agentDir?: string): BundledTheme | undefined {
63
148
  const settings = JSON.parse(readFileSync(join(resolvedAgentDir, "settings.json"), "utf8")) as {
64
149
  theme?: unknown;
65
150
  };
66
- return typeof settings.theme === "string" ? (settings.theme as BundledTheme) : undefined;
151
+ return typeof settings.theme === "string" ? settings.theme : undefined;
67
152
  } catch {
68
153
  return undefined;
69
154
  }
70
155
  }
71
156
 
72
157
  function resolvePrettyTheme(agentDir?: string): BundledTheme {
73
- return (
74
- (process.env.FACELIFT_THEME as BundledTheme | undefined) ??
75
- readThemeFromSettings(agentDir) ??
76
- DEFAULT_THEME
77
- );
158
+ const raw = process.env.FACELIFT_THEME ?? readThemeFromSettings(agentDir);
159
+ return resolveBundledTheme(raw);
78
160
  }
79
161
 
80
162
  let THEME: BundledTheme = resolvePrettyTheme();
81
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
+
82
173
  function setPrettyTheme(agentDir?: string): void {
83
174
  const resolvedTheme = resolvePrettyTheme(agentDir);
84
175
  if (resolvedTheme === THEME) return;
85
176
  THEME = resolvedTheme;
86
177
  _cache.clear();
178
+ _hlErrorLogged.clear();
87
179
  codeToANSI("", "typescript", THEME).catch(() => {});
88
180
  }
89
181
 
@@ -149,10 +241,6 @@ function resolveBaseBackground(theme: BgTheme | null | undefined): void {
149
241
  RST = `\x1b[0m${BG_BASE}`;
150
242
  }
151
243
 
152
- function renderToolError(error: string, theme: FgTheme): string {
153
- return frameResult(theme.fg("error", error), "error");
154
- }
155
-
156
244
  const ESC_RE = "\u001b";
157
245
  const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
158
246
 
@@ -207,229 +295,11 @@ function lnum(n: number, w: number): string {
207
295
  }
208
296
 
209
297
  // ---------------------------------------------------------------------------
210
- // Frame — open-right rounded box for tool output.
211
- //
212
- // Replaces pi core's `toolSuccessBg` fill (the green left rail in the
213
- // default tool shell) with a status-aware single-line frame that reads
214
- // the same way as the settings modal in pi-wierd-stuff
215
- // (`packages/common/settings/frame.ts`), but with the right side left
216
- // open so long lines fade out naturally:
217
- //
218
- // ╭── read /some/file.ext ─────
219
- // │ 12 lines
220
- // │ 535 │ const x = 1;
221
- // │ 536 │ const y = 2;
222
- // ╰─────────────────────────
223
- //
224
- // Tools opt in by setting `renderShell: "self"` on their definition,
225
- // which tells pi core to skip the colored Box wrapper and let the tool
226
- // draw its own framing.
298
+ // Frame helpers now live in `@wierdbytes/pi-common/tool-frame`. Status
299
+ // border colours follow host theme tokens (`success` / `warning` /
300
+ // `error`) instead of the previous fixed RGB palette.
227
301
  // ---------------------------------------------------------------------------
228
302
 
229
- type FrameStatus = "pending" | "success" | "error";
230
-
231
- function getFrameStatus(ctx: { isError?: boolean; isPartial?: boolean }): FrameStatus {
232
- if (ctx.isError) return "error";
233
- if (ctx.isPartial) return "pending";
234
- return "success";
235
- }
236
-
237
- function getFrameColor(status: FrameStatus): string {
238
- switch (status) {
239
- case "error":
240
- return FG_RED;
241
- case "pending":
242
- return FG_YELLOW;
243
- default:
244
- return FG_GREEN;
245
- }
246
- }
247
-
248
- /** Inner content width — terminal width minus the `│` left rail (1 col). */
249
- function bodyW(): number {
250
- return Math.max(1, termW() - 1);
251
- }
252
-
253
- /**
254
- * Find the visible column of the first space character in an ANSI-formatted
255
- * string, ignoring ANSI escape sequences. Returns -1 if no space is found
256
- * before a newline (or end of string).
257
- *
258
- * Used by `frameTop` to align continuation rows under the first argument of
259
- * the tool title (e.g., the column where `cd` starts in `bash cd …`).
260
- */
261
- function firstVisibleSpace(s: string): number {
262
- let visiblePos = 0;
263
- let i = 0;
264
- while (i < s.length) {
265
- const ch = s[i];
266
- if (ch === "\x1b") {
267
- const next = s[i + 1];
268
- if (next === "[") {
269
- const end = s.indexOf("m", i + 2);
270
- if (end < 0) return -1;
271
- i = end + 1;
272
- continue;
273
- }
274
- // Unknown escape — bail out
275
- return -1;
276
- }
277
- if (ch === "\n") return -1;
278
- if (ch === " ") return visiblePos;
279
- visiblePos += 1;
280
- i += 1;
281
- }
282
- return -1;
283
- }
284
-
285
- /**
286
- * Render the top border of the frame: `╭── <title> ─────`.
287
- * The title may contain ANSI codes; visible width is measured with pi-tui.
288
- * Truncated with `…` if it would overflow the terminal width.
289
- *
290
- * Multi-line titles (e.g., a multi-line bash command) are rendered with the
291
- * first line as the top border and remaining lines as rail-prefixed
292
- * continuation rows in a sub-tree:
293
- *
294
- * ╭── bash cd /…/pi-wierd-stuff && \ ──────────────
295
- * │ │ echo "line 1" \
296
- * │ ╚ echo "line 2"
297
- *
298
- * Each continuation row is composed of:
299
- * • the outer frame rail `│` (frame status color)
300
- * • padding sized so the sub-tree connector lines up with the first arg
301
- * character of the first row
302
- * • a sub-tree connector (`│` for non-last rows, `╰` for the last)
303
- * • a single separator space
304
- * • the row's content (callers are expected to wrap each line in the
305
- * same color as the first-row args so the sub-tree text matches)
306
- *
307
- * Trailing dashes are only emitted on the first row so continuation rows
308
- * stay clean.
309
- */
310
- function frameTop(titleAnsi: string, status: FrameStatus): string {
311
- const color = getFrameColor(status);
312
- const w = termW();
313
- const titleLines = titleAnsi.split("\n");
314
- const firstTitle = titleLines[0];
315
- const continuations = titleLines.slice(1);
316
-
317
- // Layout: `╭` (1) + `──` (2) + ` ` (1) + title + ` ` (1) + trailing dashes (≥1)
318
- const minTrailing = 1;
319
- const fixed = 1 + 2 + 1 + 1 + minTrailing;
320
- const maxTitleW = Math.max(0, w - fixed);
321
-
322
- let title = firstTitle;
323
- let tw = visibleWidth(firstTitle);
324
- if (tw > maxTitleW) {
325
- title = truncateToWidth(firstTitle, maxTitleW, "…");
326
- tw = visibleWidth(title);
327
- }
328
- const trailing = Math.max(minTrailing, w - 1 - 2 - 1 - tw - 1);
329
- const firstLine = `${color}╭──${RST} ${title} ${color}${"─".repeat(trailing)}${RST}`;
330
-
331
- if (continuations.length === 0) return firstLine;
332
-
333
- // Continuation rows: rail + padding + sub-tree connector + space + content.
334
- // First line layout:
335
- // col 1: `╭`
336
- // cols 2-3: `──`
337
- // col 4: literal space
338
- // cols 5..: title (toolName + space + args…)
339
- // If the toolName ends at visible-offset N (i.e., the space is at offset N),
340
- // the first arg char sits at column N + 6 (1-indexed). We align the sub-tree
341
- // connector two columns before it (at contentCol - 2) and put the content
342
- // itself at the same column as the first row's first arg char, so the rows
343
- // read as a tree hanging off the title.
344
- const toolNameEnd = firstVisibleSpace(firstTitle);
345
- const contentCol = toolNameEnd >= 0 ? toolNameEnd + 6 : 6;
346
- const padBetween = " ".repeat(Math.max(0, contentCol - 4));
347
- const rail = `${color}│${RST}`;
348
- const innerW = Math.max(1, w - contentCol + 1);
349
- const lastIdx = continuations.length - 1;
350
-
351
- const contLines = continuations.map((line, idx) => {
352
- const connectorChar = idx === lastIdx ? "╰" : "│";
353
- const connector = `${color}${connectorChar}${RST}`;
354
- const fitted = visibleWidth(line) > innerW ? truncateToWidth(line, innerW, "…") : line;
355
- return `${rail}${padBetween}${connector} ${fitted}`;
356
- });
357
-
358
- return [firstLine, ...contLines].join("\n");
359
- }
360
-
361
- /** Render the bottom border of the frame: `╰─────────────`. */
362
- function frameBottom(status: FrameStatus): string {
363
- const color = getFrameColor(status);
364
- const w = termW();
365
- return `${color}╰${"─".repeat(Math.max(1, w - 1))}${RST}`;
366
- }
367
-
368
- /**
369
- * Render the bottom border with an inline label, mirroring `frameTop`:
370
- * `╰── <label> ──────`. Used by the bash renderer to keep the exit
371
- * summary attached to the frame instead of as the first body line.
372
- */
373
- function frameBottomWithLabel(labelAnsi: string, status: FrameStatus): string {
374
- const color = getFrameColor(status);
375
- const w = termW();
376
- const minTrailing = 1;
377
- const fixed = 1 + 2 + 1 + 1 + minTrailing;
378
- const maxLabelW = Math.max(0, w - fixed);
379
-
380
- let label = labelAnsi;
381
- let lw = visibleWidth(labelAnsi);
382
- if (lw > maxLabelW) {
383
- label = truncateToWidth(labelAnsi, maxLabelW, "…");
384
- lw = visibleWidth(label);
385
- }
386
- const trailing = Math.max(minTrailing, w - 1 - 2 - 1 - lw - 1);
387
- return `${color}╰──${RST} ${label} ${color}${"─".repeat(trailing)}${RST}`;
388
- }
389
-
390
- /**
391
- * Prefix every line in `text` with the frame's left rail (`│`).
392
- * Each logical line is right-truncated to `bodyW()` so the visible width
393
- * including the rail never exceeds `termW()`.
394
- *
395
- * Embedded carriage returns (`\r`) are collapsed using terminal-style
396
- * overwrite semantics: only the content after the last `\r` on a line is
397
- * kept. Without this, sequences like `git rebase`'s
398
- * `Rebasing (1/1)\rSuccessfully rebased…` would clobber the rail when the
399
- * terminal honors `\r` as cursor-reset, leaving the line visibly
400
- * unprefixed.
401
- */
402
- function frameBodyLines(text: string, status: FrameStatus): string {
403
- const color = getFrameColor(status);
404
- const rail = `${color}│${RST}`;
405
- const w = bodyW();
406
- return text
407
- .split("\n")
408
- .map((line) => {
409
- // Terminal-style \r handling: keep only what survives after the last
410
- // carriage return so the rail isn't overwritten by progress messages.
411
- const lastCR = line.lastIndexOf("\r");
412
- const safeLine = lastCR >= 0 ? line.slice(lastCR + 1) : line;
413
- const fitted = safeLine && visibleWidth(safeLine) > w ? truncateToWidth(safeLine, w, "") : safeLine;
414
- return `${rail}${fitted}`;
415
- })
416
- .join("\n");
417
- }
418
-
419
- /** Wrap `body` as the result-side of the frame: rail-prefixed lines + bottom border.
420
- * An empty `body` collapses to just the bottom border, mirroring
421
- * `frameResultWithBottomLabel` so the frame doesn't show an empty rail row. */
422
- function frameResult(body: string, status: FrameStatus): string {
423
- const body_ = body ? `${frameBodyLines(body, status)}\n` : "";
424
- return `${body_}${frameBottom(status)}`;
425
- }
426
-
427
- /** Same as `frameResult` but embeds `label` inline in the bottom border. */
428
- function frameResultWithBottomLabel(body: string, labelAnsi: string, status: FrameStatus): string {
429
- const body_ = body ? `${frameBodyLines(body, status)}\n` : "";
430
- return `${body_}${frameBottomWithLabel(labelAnsi, status)}`;
431
- }
432
-
433
303
  // ---------------------------------------------------------------------------
434
304
  // Language detection
435
305
  // ---------------------------------------------------------------------------
@@ -644,21 +514,6 @@ export const __imageInternals = {
644
514
  },
645
515
  };
646
516
 
647
- /**
648
- * Compact human-readable duration: `3.3s`, `1m3s`, `2h5m`.
649
- * Mirrors the style requested by the user for the bash bottom-border label.
650
- */
651
- function formatDuration(ms: number): string {
652
- const totalSec = Math.max(0, ms) / 1000;
653
- if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
654
- const totalMin = Math.floor(totalSec / 60);
655
- const sec = Math.floor(totalSec % 60);
656
- if (totalMin < 60) return sec > 0 ? `${totalMin}m${sec}s` : `${totalMin}m`;
657
- const hours = Math.floor(totalMin / 60);
658
- const min = totalMin % 60;
659
- return min > 0 ? `${hours}h${min}m` : `${hours}h`;
660
- }
661
-
662
517
  /**
663
518
  * Get human-readable file size
664
519
  */
@@ -790,6 +645,7 @@ function dirIcon(): string {
790
645
  codeToANSI("", "typescript", THEME).catch(() => {});
791
646
 
792
647
  const _cache = new Map<string, string[]>();
648
+ const _hlErrorLogged = new Set<string>();
793
649
 
794
650
  function _touch(k: string, v: string[]): string[] {
795
651
  _cache.delete(k);
@@ -814,11 +670,30 @@ async function hlBlock(code: string, language: BundledLanguage | undefined): Pro
814
670
  const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
815
671
  const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
816
672
  return _touch(k, out);
817
- } 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
+ }
818
686
  return code.split("\n");
819
687
  }
820
688
  }
821
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
+
822
697
  // ---------------------------------------------------------------------------
823
698
  // Renderers
824
699
  // ---------------------------------------------------------------------------
@@ -837,8 +712,9 @@ async function renderFileContent(
837
712
  const lg = lang(filePath);
838
713
  const hl = await hlBlock(show.join("\n"), lg);
839
714
 
840
- // Reserve 2 cols for the outer frame's `│ ` rail — rendered by frameBodyLines.
841
- const tw = bodyW();
715
+ // Reserve 1 col for the outer frame's `│` rail (drawn later by
716
+ // `frameBodyLines`) so the highlighted lines fit inside the frame.
717
+ const tw = Math.max(1, termW() - 1);
842
718
  const startLine = offset;
843
719
  const endLine = startLine + show.length - 1;
844
720
  const nw = Math.max(3, String(endLine).length);
@@ -995,6 +871,15 @@ type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
995
871
  type TextComponentLike = { setText(value: string): void; getText?: () => string };
996
872
  type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
997
873
  type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
874
+
875
+ /**
876
+ * Cast the loose `ThemeLike` (used by the duck-typed render hooks) to the
877
+ * real `Theme` class expected by the shared
878
+ * `@wierdbytes/pi-common/tool-frame` helpers. The helpers only call
879
+ * `theme.fg(token, str)`, which `ThemeLike` already provides, so the cast
880
+ * is structurally safe at runtime.
881
+ */
882
+ const asTheme = (theme: ThemeLike): Theme => theme as unknown as Theme;
998
883
  type RenderContextLike<TState extends Record<string, unknown> = Record<string, unknown>> = {
999
884
  lastComponent?: TextComponentLike;
1000
885
  state: TState;
@@ -1208,7 +1093,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1208
1093
  const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
1209
1094
  const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
1210
1095
  const title = `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`;
1211
- text.setText(frameTop(title, getFrameStatus(ctx)));
1096
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1212
1097
  return text;
1213
1098
  },
1214
1099
 
@@ -1221,9 +1106,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1221
1106
  resolveBaseBackground(theme);
1222
1107
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1223
1108
  const status = getFrameStatus(ctx);
1109
+ const t = asTheme(theme);
1110
+ const w = termW();
1224
1111
 
1225
1112
  if (ctx.isError) {
1226
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1113
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1227
1114
  return text;
1228
1115
  }
1229
1116
 
@@ -1237,35 +1124,41 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1237
1124
  const sizeStr = humanSize(byteSize);
1238
1125
  const mimeStr = d.mimeType ?? "image";
1239
1126
 
1240
- text.setText(frameResult(`${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`, status));
1127
+ text.setText(frameResult(`${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`, status, t, w));
1241
1128
  return text;
1242
1129
  }
1243
1130
 
1244
1131
  if (d?._type === "readFile" && d.content) {
1245
- const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}:${status}`;
1132
+ const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${w}:${status}`;
1246
1133
  if (ctx.state._rk !== key) {
1247
1134
  ctx.state._rk = key;
1248
1135
  // Initial render: just the frame chrome until shiki resolves.
1249
1136
  // Once `renderFileContent` returns we re-cache with the highlighted body.
1250
- ctx.state._rt = frameResult("", status);
1137
+ ctx.state._rt = frameResult("", status, t, w);
1251
1138
 
1252
1139
  const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
1253
1140
  renderFileContent(d.content, d.filePath, d.offset, maxShow)
1254
1141
  .then((rendered: string) => {
1255
1142
  if (ctx.state._rk !== key) return;
1256
- ctx.state._rt = frameResult(rendered, status);
1143
+ ctx.state._rt = frameResult(rendered, status, t, w);
1257
1144
  ctx.invalidate();
1258
1145
  })
1259
- .catch(() => {});
1146
+ .catch((err: unknown) => {
1147
+ // Surface to stderr so silent regressions in the highlighter
1148
+ // (e.g. a stale helper reference) are visible during dev
1149
+ // instead of producing an empty-body frame.
1150
+ const msg = err instanceof Error ? err.message : String(err);
1151
+ console.error(`pi-facelift: read render failed: ${msg}`);
1152
+ });
1260
1153
  }
1261
- text.setText(ctx.state._rt ?? frameResult("", status));
1154
+ text.setText(ctx.state._rt ?? frameResult("", status, t, w));
1262
1155
  return text;
1263
1156
  }
1264
1157
 
1265
1158
  // Fallback
1266
1159
  const fallback = result.content?.[0];
1267
1160
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "read";
1268
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1161
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1269
1162
  return text;
1270
1163
  },
1271
1164
  });
@@ -1334,7 +1227,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1334
1227
  const restCmd = cmdLines.slice(1).map((line) => line.replace(/^\s+/, ""));
1335
1228
  const firstTitle = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", firstCmd)}${timeout}`;
1336
1229
  const title = [firstTitle, ...restCmd.map((line) => theme.fg("accent", line))].join("\n");
1337
- t.setText(frameTop(title, getFrameStatus(ctx)));
1230
+ t.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1338
1231
  return t;
1339
1232
  },
1340
1233
 
@@ -1347,6 +1240,8 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1347
1240
  resolveBaseBackground(theme);
1348
1241
  const t = ctx.lastComponent ?? new TextComponent("", 0, 0);
1349
1242
  const status = getFrameStatus(ctx);
1243
+ const frameTheme = asTheme(theme);
1244
+ const frameWidth = termW();
1350
1245
  const state = ctx.state;
1351
1246
 
1352
1247
  // Live timer: tick every second while still streaming, freeze on final/error.
@@ -1413,9 +1308,9 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1413
1308
  const body = out.join("\n");
1414
1309
 
1415
1310
  if (label) {
1416
- t.setText(frameResultWithBottomLabel(body, label, status));
1311
+ t.setText(frameResultWithBottomLabel(body, label, status, frameTheme, frameWidth));
1417
1312
  } else {
1418
- t.setText(frameResult(body, status));
1313
+ t.setText(frameResult(body, status, frameTheme, frameWidth));
1419
1314
  }
1420
1315
  return t;
1421
1316
  },
@@ -1461,7 +1356,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1461
1356
  const fp = args.path ?? ".";
1462
1357
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1463
1358
  const title = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`;
1464
- text.setText(frameTop(title, getFrameStatus(ctx)));
1359
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1465
1360
  return text;
1466
1361
  },
1467
1362
 
@@ -1469,9 +1364,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1469
1364
  resolveBaseBackground(theme);
1470
1365
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1471
1366
  const status = getFrameStatus(ctx);
1367
+ const t = asTheme(theme);
1368
+ const w = termW();
1472
1369
 
1473
1370
  if (ctx.isError) {
1474
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1371
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1475
1372
  return text;
1476
1373
  }
1477
1374
 
@@ -1479,13 +1376,13 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1479
1376
  if (d?._type === "lsResult" && d.text) {
1480
1377
  const tree = renderTree(d.text, d.path);
1481
1378
  const info = `${FG_DIM}${d.entryCount} entries${RST}`;
1482
- text.setText(frameResultWithBottomLabel(tree, info, status));
1379
+ text.setText(frameResultWithBottomLabel(tree, info, status, t, w));
1483
1380
  return text;
1484
1381
  }
1485
1382
 
1486
1383
  const fallback = result.content?.[0];
1487
1384
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "listed";
1488
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1385
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1489
1386
  return text;
1490
1387
  },
1491
1388
  });
@@ -1530,7 +1427,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1530
1427
  const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1531
1428
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1532
1429
  const title = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`;
1533
- text.setText(frameTop(title, getFrameStatus(ctx)));
1430
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1534
1431
  return text;
1535
1432
  },
1536
1433
 
@@ -1543,9 +1440,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1543
1440
  resolveBaseBackground(theme);
1544
1441
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1545
1442
  const status = getFrameStatus(ctx);
1443
+ const t = asTheme(theme);
1444
+ const w = termW();
1546
1445
 
1547
1446
  if (ctx.isError) {
1548
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1447
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1549
1448
  return text;
1550
1449
  }
1551
1450
 
@@ -1553,13 +1452,13 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1553
1452
  if (d?._type === "findResult" && d.text) {
1554
1453
  const rendered = renderFindResults(d.text);
1555
1454
  const info = `${FG_DIM}${d.matchCount} files${RST}`;
1556
- text.setText(frameResultWithBottomLabel(rendered, info, status));
1455
+ text.setText(frameResultWithBottomLabel(rendered, info, status, t, w));
1557
1456
  return text;
1558
1457
  }
1559
1458
 
1560
1459
  const fallback = result.content?.[0];
1561
1460
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "found";
1562
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1461
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1563
1462
  return text;
1564
1463
  },
1565
1464
  });
@@ -1615,7 +1514,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1615
1514
  const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1616
1515
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1617
1516
  const title = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`;
1618
- text.setText(frameTop(title, getFrameStatus(ctx)));
1517
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1619
1518
  return text;
1620
1519
  },
1621
1520
 
@@ -1628,37 +1527,42 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1628
1527
  resolveBaseBackground(theme);
1629
1528
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1630
1529
  const status = getFrameStatus(ctx);
1530
+ const t = asTheme(theme);
1531
+ const w = termW();
1631
1532
 
1632
1533
  if (ctx.isError) {
1633
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1534
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1634
1535
  return text;
1635
1536
  }
1636
1537
 
1637
1538
  const d = result.details;
1638
1539
  if (d?._type === "grepResult" && d.text) {
1639
- const key = `grep:${d.pattern}:${d.matchCount}:${termW()}:${status}`;
1540
+ const key = `grep:${d.pattern}:${d.matchCount}:${w}:${status}`;
1640
1541
  if (ctx.state._gk !== key) {
1641
1542
  ctx.state._gk = key;
1642
1543
  const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1643
- ctx.state._gt = frameResultWithBottomLabel("", info, status);
1544
+ ctx.state._gt = frameResultWithBottomLabel("", info, status, t, w);
1644
1545
 
1645
1546
  renderGrepResults(d.text, d.pattern)
1646
1547
  .then((rendered: string) => {
1647
1548
  if (ctx.state._gk !== key) return;
1648
- ctx.state._gt = frameResultWithBottomLabel(rendered, info, status);
1549
+ ctx.state._gt = frameResultWithBottomLabel(rendered, info, status, t, w);
1649
1550
  ctx.invalidate();
1650
1551
  })
1651
- .catch(() => {});
1552
+ .catch((err: unknown) => {
1553
+ const msg = err instanceof Error ? err.message : String(err);
1554
+ console.error(`pi-facelift: grep render failed: ${msg}`);
1555
+ });
1652
1556
  }
1653
1557
  text.setText(
1654
- ctx.state._gt ?? frameResultWithBottomLabel("", `${FG_DIM}${d.matchCount} matches${RST}`, status),
1558
+ ctx.state._gt ?? frameResultWithBottomLabel("", `${FG_DIM}${d.matchCount} matches${RST}`, status, t, w),
1655
1559
  );
1656
1560
  return text;
1657
1561
  }
1658
1562
 
1659
1563
  const fallback = result.content?.[0];
1660
1564
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
1661
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1565
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1662
1566
  return text;
1663
1567
  },
1664
1568
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.1.1",
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": [
@@ -52,7 +52,8 @@
52
52
  "demo": "bun scripts/demo.ts"
53
53
  },
54
54
  "dependencies": {
55
- "@shikijs/cli": "^4.0.2"
55
+ "@shikijs/cli": "^4.0.2",
56
+ "@wierdbytes/pi-common": "^0.3.0"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "@earendil-works/pi-ai": ">=0.74.0",
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Regression tests for the `read` tool's body rendering.
3
+ *
4
+ * History: a previous refactor stranded a `bodyW()` reference inside
5
+ * `renderFileContent` after the helper itself was deleted. The
6
+ * resulting `ReferenceError` was swallowed by `.catch(() => {})` on
7
+ * the shiki promise, leaving every `read` result with a top + bottom
8
+ * border but no body lines at all. These tests force the async
9
+ * highlighter to run and assert that the body actually populates.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import piFaceliftExtension from "./index.ts";
15
+
16
+ class MockText {
17
+ private text = "";
18
+ constructor(_text = "", _x = 0, _y = 0) {}
19
+ setText(value: string) {
20
+ this.text = value;
21
+ }
22
+ getText() {
23
+ return this.text;
24
+ }
25
+ }
26
+
27
+ const mockTheme = {
28
+ fg: (_key: string, text: string) => text,
29
+ bold: (text: string) => text,
30
+ };
31
+
32
+ function mockToolFactory(exec: any) {
33
+ return (_cwd: string) => ({
34
+ name: "mock",
35
+ description: "mock",
36
+ parameters: { type: "object", properties: {} },
37
+ execute: exec,
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Pin `process.stdout.columns` for the lifetime of `fn`, including
43
+ * across `await` boundaries. The naive sync `try/finally` version
44
+ * restores the descriptor immediately when `fn` returns its promise,
45
+ * so any code that runs after the first `await` sees the *original*
46
+ * width — enough to flip a width-keyed cache (`_rk`) and force a
47
+ * shiki re-run, which clobbers the populated `_rt` we waited for.
48
+ */
49
+ async function withStdoutColumns<T>(
50
+ columns: number,
51
+ fn: () => T | Promise<T>,
52
+ ): Promise<T> {
53
+ const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");
54
+ Object.defineProperty(process.stdout, "columns", {
55
+ configurable: true,
56
+ value: columns,
57
+ });
58
+ try {
59
+ return await fn();
60
+ } finally {
61
+ if (descriptor)
62
+ Object.defineProperty(process.stdout, "columns", descriptor);
63
+ else
64
+ delete (process.stdout as NodeJS.WriteStream & { columns?: number })
65
+ .columns;
66
+ }
67
+ }
68
+
69
+ function loadReadTool(readExec: any) {
70
+ const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
71
+ const tools = new Map<string, any>();
72
+ const pi = {
73
+ registerTool: (tool: any) => tools.set(tool.name, tool),
74
+ registerCommand: () => {},
75
+ on: () => {},
76
+ };
77
+
78
+ piFaceliftExtension(pi, {
79
+ sdk: {
80
+ createReadToolDefinition: mockToolFactory(readExec),
81
+ createBashToolDefinition: mockToolFactory(noopExec),
82
+ createLsToolDefinition: mockToolFactory(noopExec),
83
+ createFindToolDefinition: mockToolFactory(noopExec),
84
+ createGrepToolDefinition: mockToolFactory(noopExec),
85
+ getAgentDir: () => "/tmp/pi-facelift-test",
86
+ },
87
+ TextComponent: MockText,
88
+ });
89
+
90
+ return tools.get("read");
91
+ }
92
+
93
+ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "");
94
+
95
+ /**
96
+ * Wait until `state._rt` is populated by shiki's async render. Shiki's
97
+ * first call cold-starts the highlighter (registers the language
98
+ * grammar, etc.) which can take a few hundred ms in CI; we poll up to
99
+ * a few seconds before giving up so the test still fails fast on a
100
+ * real regression.
101
+ */
102
+ /**
103
+ * Wait until `state._rt` is populated by shiki's async render. Shiki's
104
+ * first call cold-starts the highlighter (registers the language
105
+ * grammar, etc.) which can take a few hundred ms in CI; we poll up to
106
+ * a few seconds before giving up so the test still fails fast on a
107
+ * real regression.
108
+ */
109
+ async function waitForCachedBody(state: { _rt?: string }): Promise<void> {
110
+ const deadline = Date.now() + 3000;
111
+ while (Date.now() < deadline) {
112
+ await new Promise((r) => setTimeout(r, 25));
113
+ if (state._rt && state._rt.split("\n").length > 1) return;
114
+ }
115
+ }
116
+
117
+ describe("read renderResult body", () => {
118
+ it("populates the frame body with file contents (regression: bodyW reference)", async () => {
119
+ const fileContent = [
120
+ "line one",
121
+ "line two",
122
+ "line three",
123
+ "line four",
124
+ "line five",
125
+ ].join("\n");
126
+
127
+ const readTool = loadReadTool(async () => ({
128
+ content: [{ type: "text", text: fileContent }],
129
+ }));
130
+
131
+ // First, run execute() so the result has the expected `details`
132
+ // payload that renderResult dispatches on.
133
+ const result = await readTool.execute(
134
+ "t1",
135
+ { path: "fixtures/sample.txt" },
136
+ null,
137
+ null,
138
+ {},
139
+ );
140
+
141
+ await withStdoutColumns(80, async () => {
142
+ let invalidated = 0;
143
+ const ctx: any = {
144
+ lastComponent: new MockText(),
145
+ isError: false,
146
+ isPartial: false,
147
+ state: {},
148
+ expanded: false,
149
+ invalidate: () => {
150
+ invalidated += 1;
151
+ },
152
+ };
153
+
154
+ // First render: kicks off shiki, sets the cached `_rt` to an
155
+ // empty-body frame. The async pipeline then populates `_rt`.
156
+ const rendered = readTool.renderResult(result, {}, mockTheme, ctx);
157
+ expect(rendered.getText()).toMatch(/^╰─+$/m); // bottom border present
158
+
159
+ await waitForCachedBody(ctx.state);
160
+
161
+ // After the highlighter resolves, `ctx.invalidate` should have
162
+ // fired so pi-tui re-invokes the renderer. Simulate that.
163
+ expect(invalidated).toBeGreaterThanOrEqual(1);
164
+ const rendered2 = readTool.renderResult(result, {}, mockTheme, ctx);
165
+ const lines = rendered2.getText().split("\n");
166
+
167
+ // Top border is drawn by renderCall, not renderResult — so the
168
+ // renderResult output starts with at least one body line and
169
+ // ends with the bottom border. Body rows carry the `│` rail.
170
+ const bodyRows = lines.filter((l: string) =>
171
+ stripAnsi(l).startsWith("│"),
172
+ );
173
+ expect(bodyRows.length).toBeGreaterThan(0);
174
+
175
+ // Body must contain each source line by content.
176
+ const body = lines.map(stripAnsi).join("\n");
177
+ expect(body).toContain("line one");
178
+ expect(body).toContain("line five");
179
+ });
180
+ });
181
+
182
+ it("renders an unknown-language file without dying (uses fallback path)", async () => {
183
+ const readTool = loadReadTool(async () => ({
184
+ content: [{ type: "text", text: "hello world" }],
185
+ }));
186
+ const result = await readTool.execute(
187
+ "t2",
188
+ { path: "fixtures/no-extension" },
189
+ null,
190
+ null,
191
+ {},
192
+ );
193
+
194
+ await withStdoutColumns(80, async () => {
195
+ const ctx: any = {
196
+ lastComponent: new MockText(),
197
+ isError: false,
198
+ isPartial: false,
199
+ state: {},
200
+ expanded: false,
201
+ invalidate: () => {},
202
+ };
203
+ readTool.renderResult(result, {}, mockTheme, ctx);
204
+ await waitForCachedBody(ctx.state);
205
+ const rendered = readTool.renderResult(result, {}, mockTheme, ctx);
206
+ expect(stripAnsi(rendered.getText())).toContain("hello world");
207
+ });
208
+ });
209
+ });
@@ -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
+ });