@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 +50 -0
- package/README.md +13 -4
- package/image-rendering.test.ts +10 -1
- package/index.ts +182 -278
- package/package.json +3 -2
- package/read-rendering.test.ts +209 -0
- package/theme-resolution.test.ts +238 -0
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
|
|
18
|
-
running,
|
|
19
|
-
|
|
20
|
-
|
|
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
|
package/image-rendering.test.ts
CHANGED
|
@@ -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):
|
|
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" ?
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
|
841
|
-
|
|
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",
|
|
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}:${
|
|
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",
|
|
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",
|
|
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",
|
|
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}:${
|
|
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.
|
|
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
|
+
});
|