@wierdbytes/pi-facelift 0.1.1 → 0.3.0

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,24 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - Frame primitives (open-right rounded box for `read` / `bash` / `ls` /
6
+ `find` / `grep`) extracted to `@wierdbytes/pi-common/tool-frame`.
7
+ pi-facelift now consumes them as a library so the same chrome can be
8
+ reused by other extensions (e.g. `@wierdbytes/pi-web`).
9
+ - Status border colours now follow host theme tokens (`success` →
10
+ `theme.fg("success", …)`, pending → `warning`, error → `error`)
11
+ instead of pi-facelift's previous fixed RGB palette. Visual contract
12
+ is unchanged on the default pi theme; on custom themes the frame
13
+ inherits the user's palette.
14
+ - `formatDuration` (the bash bottom-label timer formatter) moved to
15
+ `@wierdbytes/pi-common/tool-frame` so other extensions can reuse it.
16
+ Behaviour unchanged.
17
+ - **Fix**: the `read` tool's frame body silently went empty after the
18
+ Phase-2 refactor because `renderFileContent` still referenced the
19
+ deleted `bodyW()` helper, and the highlighter promise's
20
+ `.catch(() => {})` swallowed the resulting `ReferenceError`. The
21
+ width is now computed inline as `termW() - 1` and the catch logs to
22
+ stderr so future regressions in the highlighter pipeline are
23
+ visible. Same `console.error` treatment applied to the matching
24
+ `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,21 @@ 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 {
47
+ formatDuration,
48
+ frameBodyLines,
49
+ frameBottom,
50
+ frameBottomWithLabel,
51
+ frameResult,
52
+ frameResultWithBottomLabel,
53
+ frameTop,
54
+ getFrameStatus,
55
+ renderToolError,
56
+ type FrameStatus,
57
+ } from "@wierdbytes/pi-common/tool-frame";
45
58
  import type { BundledLanguage, BundledTheme } from "shiki";
46
59
 
47
60
  // ---------------------------------------------------------------------------
@@ -149,10 +162,6 @@ function resolveBaseBackground(theme: BgTheme | null | undefined): void {
149
162
  RST = `\x1b[0m${BG_BASE}`;
150
163
  }
151
164
 
152
- function renderToolError(error: string, theme: FgTheme): string {
153
- return frameResult(theme.fg("error", error), "error");
154
- }
155
-
156
165
  const ESC_RE = "\u001b";
157
166
  const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
158
167
 
@@ -207,229 +216,11 @@ function lnum(n: number, w: number): string {
207
216
  }
208
217
 
209
218
  // ---------------------------------------------------------------------------
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.
219
+ // Frame helpers now live in `@wierdbytes/pi-common/tool-frame`. Status
220
+ // border colours follow host theme tokens (`success` / `warning` /
221
+ // `error`) instead of the previous fixed RGB palette.
227
222
  // ---------------------------------------------------------------------------
228
223
 
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
224
  // ---------------------------------------------------------------------------
434
225
  // Language detection
435
226
  // ---------------------------------------------------------------------------
@@ -644,21 +435,6 @@ export const __imageInternals = {
644
435
  },
645
436
  };
646
437
 
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
438
  /**
663
439
  * Get human-readable file size
664
440
  */
@@ -837,8 +613,9 @@ async function renderFileContent(
837
613
  const lg = lang(filePath);
838
614
  const hl = await hlBlock(show.join("\n"), lg);
839
615
 
840
- // Reserve 2 cols for the outer frame's `│ ` rail — rendered by frameBodyLines.
841
- const tw = bodyW();
616
+ // Reserve 1 col for the outer frame's `│` rail (drawn later by
617
+ // `frameBodyLines`) so the highlighted lines fit inside the frame.
618
+ const tw = Math.max(1, termW() - 1);
842
619
  const startLine = offset;
843
620
  const endLine = startLine + show.length - 1;
844
621
  const nw = Math.max(3, String(endLine).length);
@@ -995,6 +772,15 @@ type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
995
772
  type TextComponentLike = { setText(value: string): void; getText?: () => string };
996
773
  type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
997
774
  type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
775
+
776
+ /**
777
+ * Cast the loose `ThemeLike` (used by the duck-typed render hooks) to the
778
+ * real `Theme` class expected by the shared
779
+ * `@wierdbytes/pi-common/tool-frame` helpers. The helpers only call
780
+ * `theme.fg(token, str)`, which `ThemeLike` already provides, so the cast
781
+ * is structurally safe at runtime.
782
+ */
783
+ const asTheme = (theme: ThemeLike): Theme => theme as unknown as Theme;
998
784
  type RenderContextLike<TState extends Record<string, unknown> = Record<string, unknown>> = {
999
785
  lastComponent?: TextComponentLike;
1000
786
  state: TState;
@@ -1208,7 +994,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1208
994
  const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
1209
995
  const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
1210
996
  const title = `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`;
1211
- text.setText(frameTop(title, getFrameStatus(ctx)));
997
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1212
998
  return text;
1213
999
  },
1214
1000
 
@@ -1221,9 +1007,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1221
1007
  resolveBaseBackground(theme);
1222
1008
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1223
1009
  const status = getFrameStatus(ctx);
1010
+ const t = asTheme(theme);
1011
+ const w = termW();
1224
1012
 
1225
1013
  if (ctx.isError) {
1226
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1014
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1227
1015
  return text;
1228
1016
  }
1229
1017
 
@@ -1237,35 +1025,41 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1237
1025
  const sizeStr = humanSize(byteSize);
1238
1026
  const mimeStr = d.mimeType ?? "image";
1239
1027
 
1240
- text.setText(frameResult(`${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`, status));
1028
+ text.setText(frameResult(`${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`, status, t, w));
1241
1029
  return text;
1242
1030
  }
1243
1031
 
1244
1032
  if (d?._type === "readFile" && d.content) {
1245
- const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}:${status}`;
1033
+ const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${w}:${status}`;
1246
1034
  if (ctx.state._rk !== key) {
1247
1035
  ctx.state._rk = key;
1248
1036
  // Initial render: just the frame chrome until shiki resolves.
1249
1037
  // Once `renderFileContent` returns we re-cache with the highlighted body.
1250
- ctx.state._rt = frameResult("", status);
1038
+ ctx.state._rt = frameResult("", status, t, w);
1251
1039
 
1252
1040
  const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
1253
1041
  renderFileContent(d.content, d.filePath, d.offset, maxShow)
1254
1042
  .then((rendered: string) => {
1255
1043
  if (ctx.state._rk !== key) return;
1256
- ctx.state._rt = frameResult(rendered, status);
1044
+ ctx.state._rt = frameResult(rendered, status, t, w);
1257
1045
  ctx.invalidate();
1258
1046
  })
1259
- .catch(() => {});
1047
+ .catch((err: unknown) => {
1048
+ // Surface to stderr so silent regressions in the highlighter
1049
+ // (e.g. a stale helper reference) are visible during dev
1050
+ // instead of producing an empty-body frame.
1051
+ const msg = err instanceof Error ? err.message : String(err);
1052
+ console.error(`pi-facelift: read render failed: ${msg}`);
1053
+ });
1260
1054
  }
1261
- text.setText(ctx.state._rt ?? frameResult("", status));
1055
+ text.setText(ctx.state._rt ?? frameResult("", status, t, w));
1262
1056
  return text;
1263
1057
  }
1264
1058
 
1265
1059
  // Fallback
1266
1060
  const fallback = result.content?.[0];
1267
1061
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "read";
1268
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1062
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1269
1063
  return text;
1270
1064
  },
1271
1065
  });
@@ -1334,7 +1128,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1334
1128
  const restCmd = cmdLines.slice(1).map((line) => line.replace(/^\s+/, ""));
1335
1129
  const firstTitle = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", firstCmd)}${timeout}`;
1336
1130
  const title = [firstTitle, ...restCmd.map((line) => theme.fg("accent", line))].join("\n");
1337
- t.setText(frameTop(title, getFrameStatus(ctx)));
1131
+ t.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1338
1132
  return t;
1339
1133
  },
1340
1134
 
@@ -1347,6 +1141,8 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1347
1141
  resolveBaseBackground(theme);
1348
1142
  const t = ctx.lastComponent ?? new TextComponent("", 0, 0);
1349
1143
  const status = getFrameStatus(ctx);
1144
+ const frameTheme = asTheme(theme);
1145
+ const frameWidth = termW();
1350
1146
  const state = ctx.state;
1351
1147
 
1352
1148
  // Live timer: tick every second while still streaming, freeze on final/error.
@@ -1413,9 +1209,9 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1413
1209
  const body = out.join("\n");
1414
1210
 
1415
1211
  if (label) {
1416
- t.setText(frameResultWithBottomLabel(body, label, status));
1212
+ t.setText(frameResultWithBottomLabel(body, label, status, frameTheme, frameWidth));
1417
1213
  } else {
1418
- t.setText(frameResult(body, status));
1214
+ t.setText(frameResult(body, status, frameTheme, frameWidth));
1419
1215
  }
1420
1216
  return t;
1421
1217
  },
@@ -1461,7 +1257,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1461
1257
  const fp = args.path ?? ".";
1462
1258
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1463
1259
  const title = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`;
1464
- text.setText(frameTop(title, getFrameStatus(ctx)));
1260
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1465
1261
  return text;
1466
1262
  },
1467
1263
 
@@ -1469,9 +1265,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1469
1265
  resolveBaseBackground(theme);
1470
1266
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1471
1267
  const status = getFrameStatus(ctx);
1268
+ const t = asTheme(theme);
1269
+ const w = termW();
1472
1270
 
1473
1271
  if (ctx.isError) {
1474
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1272
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1475
1273
  return text;
1476
1274
  }
1477
1275
 
@@ -1479,13 +1277,13 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1479
1277
  if (d?._type === "lsResult" && d.text) {
1480
1278
  const tree = renderTree(d.text, d.path);
1481
1279
  const info = `${FG_DIM}${d.entryCount} entries${RST}`;
1482
- text.setText(frameResultWithBottomLabel(tree, info, status));
1280
+ text.setText(frameResultWithBottomLabel(tree, info, status, t, w));
1483
1281
  return text;
1484
1282
  }
1485
1283
 
1486
1284
  const fallback = result.content?.[0];
1487
1285
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "listed";
1488
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1286
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1489
1287
  return text;
1490
1288
  },
1491
1289
  });
@@ -1530,7 +1328,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1530
1328
  const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1531
1329
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1532
1330
  const title = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`;
1533
- text.setText(frameTop(title, getFrameStatus(ctx)));
1331
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1534
1332
  return text;
1535
1333
  },
1536
1334
 
@@ -1543,9 +1341,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1543
1341
  resolveBaseBackground(theme);
1544
1342
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1545
1343
  const status = getFrameStatus(ctx);
1344
+ const t = asTheme(theme);
1345
+ const w = termW();
1546
1346
 
1547
1347
  if (ctx.isError) {
1548
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1348
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1549
1349
  return text;
1550
1350
  }
1551
1351
 
@@ -1553,13 +1353,13 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1553
1353
  if (d?._type === "findResult" && d.text) {
1554
1354
  const rendered = renderFindResults(d.text);
1555
1355
  const info = `${FG_DIM}${d.matchCount} files${RST}`;
1556
- text.setText(frameResultWithBottomLabel(rendered, info, status));
1356
+ text.setText(frameResultWithBottomLabel(rendered, info, status, t, w));
1557
1357
  return text;
1558
1358
  }
1559
1359
 
1560
1360
  const fallback = result.content?.[0];
1561
1361
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "found";
1562
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1362
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1563
1363
  return text;
1564
1364
  },
1565
1365
  });
@@ -1615,7 +1415,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1615
1415
  const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1616
1416
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1617
1417
  const title = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`;
1618
- text.setText(frameTop(title, getFrameStatus(ctx)));
1418
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1619
1419
  return text;
1620
1420
  },
1621
1421
 
@@ -1628,37 +1428,42 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1628
1428
  resolveBaseBackground(theme);
1629
1429
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1630
1430
  const status = getFrameStatus(ctx);
1431
+ const t = asTheme(theme);
1432
+ const w = termW();
1631
1433
 
1632
1434
  if (ctx.isError) {
1633
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1435
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1634
1436
  return text;
1635
1437
  }
1636
1438
 
1637
1439
  const d = result.details;
1638
1440
  if (d?._type === "grepResult" && d.text) {
1639
- const key = `grep:${d.pattern}:${d.matchCount}:${termW()}:${status}`;
1441
+ const key = `grep:${d.pattern}:${d.matchCount}:${w}:${status}`;
1640
1442
  if (ctx.state._gk !== key) {
1641
1443
  ctx.state._gk = key;
1642
1444
  const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1643
- ctx.state._gt = frameResultWithBottomLabel("", info, status);
1445
+ ctx.state._gt = frameResultWithBottomLabel("", info, status, t, w);
1644
1446
 
1645
1447
  renderGrepResults(d.text, d.pattern)
1646
1448
  .then((rendered: string) => {
1647
1449
  if (ctx.state._gk !== key) return;
1648
- ctx.state._gt = frameResultWithBottomLabel(rendered, info, status);
1450
+ ctx.state._gt = frameResultWithBottomLabel(rendered, info, status, t, w);
1649
1451
  ctx.invalidate();
1650
1452
  })
1651
- .catch(() => {});
1453
+ .catch((err: unknown) => {
1454
+ const msg = err instanceof Error ? err.message : String(err);
1455
+ console.error(`pi-facelift: grep render failed: ${msg}`);
1456
+ });
1652
1457
  }
1653
1458
  text.setText(
1654
- ctx.state._gt ?? frameResultWithBottomLabel("", `${FG_DIM}${d.matchCount} matches${RST}`, status),
1459
+ ctx.state._gt ?? frameResultWithBottomLabel("", `${FG_DIM}${d.matchCount} matches${RST}`, status, t, w),
1655
1460
  );
1656
1461
  return text;
1657
1462
  }
1658
1463
 
1659
1464
  const fallback = result.content?.[0];
1660
1465
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
1661
- text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
1466
+ text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status, t, w));
1662
1467
  return text;
1663
1468
  },
1664
1469
  });
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.0",
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
+ });