@wierdbytes/pi-facelift 0.1.0 → 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
@@ -166,4 +166,169 @@ describe("bash renderCall expansion", () => {
166
166
  }
167
167
  });
168
168
  });
169
+
170
+ it("keeps trailing dashes on the first line for multi-line commands", () => {
171
+ withStdoutColumns(120, () => {
172
+ const bashTool = loadBashTool();
173
+ // User-typed multi-line command (line continuation with `\`)
174
+ const command = `cd /tmp && \\\n echo "hello"`;
175
+
176
+ const rendered = bashTool.renderCall({ command }, mockTheme, {
177
+ lastComponent: new MockText(),
178
+ isError: false,
179
+ state: {},
180
+ expanded: true,
181
+ invalidate: () => {},
182
+ });
183
+
184
+ const lines = rendered.getText().split("\n");
185
+ expect(lines.length).toBeGreaterThanOrEqual(2);
186
+
187
+ // First line: top border with `╭── bash cd /tmp && \` and trailing dashes.
188
+ expect(lines[0]).toMatch(/^.*╭──.*bash.*cd \/tmp && \\.*─/);
189
+ expect(lines[0]).toContain("\\");
190
+ expect(lines[0]).toMatch(/─{2,}/); // trailing dashes survive on first line
191
+
192
+ // Continuation line: outer rail + sub-tree connector + content (no trailing dashes).
193
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
194
+ const plainCont = stripAnsi(lines[1]);
195
+ expect(plainCont).toMatch(/^│/);
196
+ expect(plainCont).toMatch(/[│╰] /); // sub-tree connector before content
197
+ expect(plainCont).toContain("echo");
198
+ expect(plainCont).not.toMatch(/─{2,}/);
199
+ });
200
+ });
201
+
202
+ it("renders multi-line commands as a sub-tree with `│`/`╰` connectors", () => {
203
+ withStdoutColumns(200, () => {
204
+ const bashTool = loadBashTool();
205
+ const command = `cd /tmp && \\\n echo "line 1" \\\n echo "line 2"`;
206
+
207
+ const rendered = bashTool.renderCall({ command }, mockTheme, {
208
+ lastComponent: new MockText(),
209
+ isError: false,
210
+ state: {},
211
+ expanded: true,
212
+ invalidate: () => {},
213
+ });
214
+
215
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
216
+ const plain = rendered.getText().split("\n").map(stripAnsi);
217
+
218
+ expect(plain.length).toBe(3);
219
+ // Non-last continuation row uses `│`.
220
+ expect(plain[1]).toMatch(/^│ +│ echo "line 1" \\$/);
221
+ // Last continuation row uses `╰`.
222
+ expect(plain[2]).toMatch(/^│ +╰ echo "line 2"$/);
223
+ });
224
+ });
225
+
226
+ it("aligns sub-tree continuations under the first arg, ignoring heredoc indent", () => {
227
+ withStdoutColumns(120, () => {
228
+ const bashTool = loadBashTool();
229
+ // Continuation has 4 leading spaces — those should be stripped so the
230
+ // sub-tree connector aligns the content under the first row's first arg.
231
+ const command = "cd /tmp && \\\n echo hi";
232
+
233
+ const rendered = bashTool.renderCall({ command }, mockTheme, {
234
+ lastComponent: new MockText(),
235
+ isError: false,
236
+ state: {},
237
+ expanded: true,
238
+ invalidate: () => {},
239
+ });
240
+
241
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
242
+ const [firstLine, secondLine] = rendered.getText().split("\n").map(stripAnsi);
243
+
244
+ // `╭── bash ` is 9 chars, so `c` of `cd` is at col 10 (0-indexed: 9).
245
+ const firstArgCol = firstLine.indexOf("cd /tmp");
246
+ expect(firstArgCol).toBe(9);
247
+
248
+ // Continuation: `│` (col 0) + 6 spaces + connector (col 7) + space (col 8) + content (col 9).
249
+ // Content col must equal the first row's first arg col, regardless of
250
+ // the user's heredoc indent (4 leading spaces above are stripped).
251
+ const echoCol = secondLine.indexOf("echo");
252
+ expect(echoCol).toBe(firstArgCol);
253
+ });
254
+ });
255
+
256
+ it("colors continuation lines with the same accent as the first arg", () => {
257
+ withStdoutColumns(120, () => {
258
+ const bashTool = loadBashTool();
259
+ const accentTheme = {
260
+ fg: (key: string, text: string) => (key === "accent" ? `\x1b[36m${text}\x1b[0m` : `\x1b[31m${text}\x1b[0m`),
261
+ bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
262
+ };
263
+ const command = "cd /tmp && \\\n echo hi";
264
+
265
+ const rendered = bashTool.renderCall({ command }, accentTheme, {
266
+ lastComponent: new MockText(),
267
+ isError: false,
268
+ state: {},
269
+ expanded: true,
270
+ invalidate: () => {},
271
+ });
272
+
273
+ const lines = rendered.getText().split("\n");
274
+ // First-row args are wrapped in the accent open code (`\x1b[36m`); the
275
+ // continuation row must independently carry the same accent wrap so the
276
+ // rendered text doesn't fall back to the terminal's default fg.
277
+ expect(lines[0]).toContain("\x1b[36mcd /tmp");
278
+ expect(lines[1]).toContain("\x1b[36mecho hi");
279
+ });
280
+ });
281
+ });
282
+
283
+ describe("bash renderResult body rails", () => {
284
+ it("keeps the left rail on lines that contain a `\\r` (terminal-style overwrite)", () => {
285
+ withStdoutColumns(120, () => {
286
+ const bashTool = loadBashTool();
287
+
288
+ // Simulates `git rebase` output, which prints `Rebasing (1/1)\rSuccessfully …`
289
+ const text = [
290
+ "=== before ===",
291
+ "abc1234 some change",
292
+ "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/master.",
293
+ "=== after ===",
294
+ ].join("\n");
295
+
296
+ const result = {
297
+ content: [{ type: "text", text }],
298
+ details: { _type: "bashResult", text, exitCode: 0, command: "git pull --rebase" },
299
+ };
300
+
301
+ const rendered = bashTool.renderResult(
302
+ result as any,
303
+ { isPartial: false } as any,
304
+ mockTheme,
305
+ {
306
+ lastComponent: new MockText(),
307
+ isError: false,
308
+ state: {},
309
+ expanded: true,
310
+ invalidate: () => {},
311
+ } as any,
312
+ );
313
+
314
+ const out = rendered.getText() as string;
315
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
316
+ const lines = out.split("\n").map(stripAnsi);
317
+
318
+ // Find the line that ends with the success message — it must be rail-prefixed,
319
+ // not raw `Successfully…` (which would mean the rail got clobbered).
320
+ const successLine = lines.find((l) => l.includes("Successfully rebased"));
321
+ expect(successLine).toBeDefined();
322
+ expect(successLine!.startsWith("│")).toBe(true);
323
+ // `\r` and the discarded progress prefix are stripped from the rendered line.
324
+ expect(successLine!).not.toContain("\r");
325
+ expect(successLine!).not.toContain("Rebasing (1/1)");
326
+
327
+ // Sanity check: every body line keeps a rail prefix.
328
+ const bodyLines = lines.filter((l) => !l.startsWith("╭") && !l.startsWith("╰") && l.length > 0);
329
+ for (const line of bodyLines) {
330
+ expect(line.startsWith("│")).toBe(true);
331
+ }
332
+ });
333
+ });
169
334
  });
@@ -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,133 +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
- * Render the top border of the frame: `╭── <title> ─────`.
255
- * The title may contain ANSI codes; visible width is measured with pi-tui.
256
- * Truncated with `…` if it would overflow the terminal width.
257
- */
258
- function frameTop(titleAnsi: string, status: FrameStatus): string {
259
- const color = getFrameColor(status);
260
- const w = termW();
261
- // Layout: `╭` (1) + `──` (2) + ` ` (1) + title + ` ` (1) + trailing dashes (≥1)
262
- const minTrailing = 1;
263
- const fixed = 1 + 2 + 1 + 1 + minTrailing;
264
- const maxTitleW = Math.max(0, w - fixed);
265
-
266
- let title = titleAnsi;
267
- let tw = visibleWidth(titleAnsi);
268
- if (tw > maxTitleW) {
269
- title = truncateToWidth(titleAnsi, maxTitleW, "…");
270
- tw = visibleWidth(title);
271
- }
272
- const trailing = Math.max(minTrailing, w - 1 - 2 - 1 - tw - 1);
273
- return `${color}╭──${RST} ${title} ${color}${"─".repeat(trailing)}${RST}`;
274
- }
275
-
276
- /** Render the bottom border of the frame: `╰─────────────`. */
277
- function frameBottom(status: FrameStatus): string {
278
- const color = getFrameColor(status);
279
- const w = termW();
280
- return `${color}╰${"─".repeat(Math.max(1, w - 1))}${RST}`;
281
- }
282
-
283
- /**
284
- * Render the bottom border with an inline label, mirroring `frameTop`:
285
- * `╰── <label> ──────`. Used by the bash renderer to keep the exit
286
- * summary attached to the frame instead of as the first body line.
287
- */
288
- function frameBottomWithLabel(labelAnsi: string, status: FrameStatus): string {
289
- const color = getFrameColor(status);
290
- const w = termW();
291
- const minTrailing = 1;
292
- const fixed = 1 + 2 + 1 + 1 + minTrailing;
293
- const maxLabelW = Math.max(0, w - fixed);
294
-
295
- let label = labelAnsi;
296
- let lw = visibleWidth(labelAnsi);
297
- if (lw > maxLabelW) {
298
- label = truncateToWidth(labelAnsi, maxLabelW, "…");
299
- lw = visibleWidth(label);
300
- }
301
- const trailing = Math.max(minTrailing, w - 1 - 2 - 1 - lw - 1);
302
- return `${color}╰──${RST} ${label} ${color}${"─".repeat(trailing)}${RST}`;
303
- }
304
-
305
- /**
306
- * Prefix every line in `text` with the frame's left rail (`│`).
307
- * Each logical line is right-truncated to `bodyW()` so the visible width
308
- * including the rail never exceeds `termW()`.
309
- */
310
- function frameBodyLines(text: string, status: FrameStatus): string {
311
- const color = getFrameColor(status);
312
- const rail = `${color}│${RST}`;
313
- const w = bodyW();
314
- return text
315
- .split("\n")
316
- .map((line) => {
317
- const fitted = line && visibleWidth(line) > w ? truncateToWidth(line, w, "") : line;
318
- return `${rail}${fitted}`;
319
- })
320
- .join("\n");
321
- }
322
-
323
- /** Wrap `body` as the result-side of the frame: rail-prefixed lines + bottom border.
324
- * An empty `body` collapses to just the bottom border, mirroring
325
- * `frameResultWithBottomLabel` so the frame doesn't show an empty rail row. */
326
- function frameResult(body: string, status: FrameStatus): string {
327
- const body_ = body ? `${frameBodyLines(body, status)}\n` : "";
328
- return `${body_}${frameBottom(status)}`;
329
- }
330
-
331
- /** Same as `frameResult` but embeds `label` inline in the bottom border. */
332
- function frameResultWithBottomLabel(body: string, labelAnsi: string, status: FrameStatus): string {
333
- const body_ = body ? `${frameBodyLines(body, status)}\n` : "";
334
- return `${body_}${frameBottomWithLabel(labelAnsi, status)}`;
335
- }
336
-
337
224
  // ---------------------------------------------------------------------------
338
225
  // Language detection
339
226
  // ---------------------------------------------------------------------------
@@ -548,21 +435,6 @@ export const __imageInternals = {
548
435
  },
549
436
  };
550
437
 
551
- /**
552
- * Compact human-readable duration: `3.3s`, `1m3s`, `2h5m`.
553
- * Mirrors the style requested by the user for the bash bottom-border label.
554
- */
555
- function formatDuration(ms: number): string {
556
- const totalSec = Math.max(0, ms) / 1000;
557
- if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
558
- const totalMin = Math.floor(totalSec / 60);
559
- const sec = Math.floor(totalSec % 60);
560
- if (totalMin < 60) return sec > 0 ? `${totalMin}m${sec}s` : `${totalMin}m`;
561
- const hours = Math.floor(totalMin / 60);
562
- const min = totalMin % 60;
563
- return min > 0 ? `${hours}h${min}m` : `${hours}h`;
564
- }
565
-
566
438
  /**
567
439
  * Get human-readable file size
568
440
  */
@@ -741,8 +613,9 @@ async function renderFileContent(
741
613
  const lg = lang(filePath);
742
614
  const hl = await hlBlock(show.join("\n"), lg);
743
615
 
744
- // Reserve 2 cols for the outer frame's `│ ` rail — rendered by frameBodyLines.
745
- 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);
746
619
  const startLine = offset;
747
620
  const endLine = startLine + show.length - 1;
748
621
  const nw = Math.max(3, String(endLine).length);
@@ -899,6 +772,15 @@ type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
899
772
  type TextComponentLike = { setText(value: string): void; getText?: () => string };
900
773
  type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
901
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;
902
784
  type RenderContextLike<TState extends Record<string, unknown> = Record<string, unknown>> = {
903
785
  lastComponent?: TextComponentLike;
904
786
  state: TState;
@@ -1112,7 +994,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1112
994
  const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
1113
995
  const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
1114
996
  const title = `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`;
1115
- text.setText(frameTop(title, getFrameStatus(ctx)));
997
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1116
998
  return text;
1117
999
  },
1118
1000
 
@@ -1125,9 +1007,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1125
1007
  resolveBaseBackground(theme);
1126
1008
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1127
1009
  const status = getFrameStatus(ctx);
1010
+ const t = asTheme(theme);
1011
+ const w = termW();
1128
1012
 
1129
1013
  if (ctx.isError) {
1130
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1014
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1131
1015
  return text;
1132
1016
  }
1133
1017
 
@@ -1141,35 +1025,41 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1141
1025
  const sizeStr = humanSize(byteSize);
1142
1026
  const mimeStr = d.mimeType ?? "image";
1143
1027
 
1144
- 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));
1145
1029
  return text;
1146
1030
  }
1147
1031
 
1148
1032
  if (d?._type === "readFile" && d.content) {
1149
- const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}:${status}`;
1033
+ const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${w}:${status}`;
1150
1034
  if (ctx.state._rk !== key) {
1151
1035
  ctx.state._rk = key;
1152
1036
  // Initial render: just the frame chrome until shiki resolves.
1153
1037
  // Once `renderFileContent` returns we re-cache with the highlighted body.
1154
- ctx.state._rt = frameResult("", status);
1038
+ ctx.state._rt = frameResult("", status, t, w);
1155
1039
 
1156
1040
  const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
1157
1041
  renderFileContent(d.content, d.filePath, d.offset, maxShow)
1158
1042
  .then((rendered: string) => {
1159
1043
  if (ctx.state._rk !== key) return;
1160
- ctx.state._rt = frameResult(rendered, status);
1044
+ ctx.state._rt = frameResult(rendered, status, t, w);
1161
1045
  ctx.invalidate();
1162
1046
  })
1163
- .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
+ });
1164
1054
  }
1165
- text.setText(ctx.state._rt ?? frameResult("", status));
1055
+ text.setText(ctx.state._rt ?? frameResult("", status, t, w));
1166
1056
  return text;
1167
1057
  }
1168
1058
 
1169
1059
  // Fallback
1170
1060
  const fallback = result.content?.[0];
1171
1061
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "read";
1172
- 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));
1173
1063
  return text;
1174
1064
  },
1175
1065
  });
@@ -1226,8 +1116,19 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1226
1116
  }
1227
1117
  const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1228
1118
  const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
1229
- const title = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`;
1230
- t.setText(frameTop(title, getFrameStatus(ctx)));
1119
+
1120
+ // Multi-line bash commands (shell line continuations with `\`, here-docs,
1121
+ // or embedded newlines) need each line wrapped in the accent color
1122
+ // separately so the color survives line splitting in `frameTop`. Leading
1123
+ // whitespace on continuation lines is dropped — the sub-tree connector
1124
+ // already provides visual indentation, so the user's heredoc indent
1125
+ // would just push content right and misalign the tree.
1126
+ const cmdLines = displayCmd.split("\n");
1127
+ const firstCmd = cmdLines[0];
1128
+ const restCmd = cmdLines.slice(1).map((line) => line.replace(/^\s+/, ""));
1129
+ const firstTitle = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", firstCmd)}${timeout}`;
1130
+ const title = [firstTitle, ...restCmd.map((line) => theme.fg("accent", line))].join("\n");
1131
+ t.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1231
1132
  return t;
1232
1133
  },
1233
1134
 
@@ -1240,6 +1141,8 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1240
1141
  resolveBaseBackground(theme);
1241
1142
  const t = ctx.lastComponent ?? new TextComponent("", 0, 0);
1242
1143
  const status = getFrameStatus(ctx);
1144
+ const frameTheme = asTheme(theme);
1145
+ const frameWidth = termW();
1243
1146
  const state = ctx.state;
1244
1147
 
1245
1148
  // Live timer: tick every second while still streaming, freeze on final/error.
@@ -1306,9 +1209,9 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1306
1209
  const body = out.join("\n");
1307
1210
 
1308
1211
  if (label) {
1309
- t.setText(frameResultWithBottomLabel(body, label, status));
1212
+ t.setText(frameResultWithBottomLabel(body, label, status, frameTheme, frameWidth));
1310
1213
  } else {
1311
- t.setText(frameResult(body, status));
1214
+ t.setText(frameResult(body, status, frameTheme, frameWidth));
1312
1215
  }
1313
1216
  return t;
1314
1217
  },
@@ -1354,7 +1257,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1354
1257
  const fp = args.path ?? ".";
1355
1258
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1356
1259
  const title = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`;
1357
- text.setText(frameTop(title, getFrameStatus(ctx)));
1260
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1358
1261
  return text;
1359
1262
  },
1360
1263
 
@@ -1362,9 +1265,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1362
1265
  resolveBaseBackground(theme);
1363
1266
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1364
1267
  const status = getFrameStatus(ctx);
1268
+ const t = asTheme(theme);
1269
+ const w = termW();
1365
1270
 
1366
1271
  if (ctx.isError) {
1367
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1272
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1368
1273
  return text;
1369
1274
  }
1370
1275
 
@@ -1372,13 +1277,13 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1372
1277
  if (d?._type === "lsResult" && d.text) {
1373
1278
  const tree = renderTree(d.text, d.path);
1374
1279
  const info = `${FG_DIM}${d.entryCount} entries${RST}`;
1375
- text.setText(frameResultWithBottomLabel(tree, info, status));
1280
+ text.setText(frameResultWithBottomLabel(tree, info, status, t, w));
1376
1281
  return text;
1377
1282
  }
1378
1283
 
1379
1284
  const fallback = result.content?.[0];
1380
1285
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "listed";
1381
- 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));
1382
1287
  return text;
1383
1288
  },
1384
1289
  });
@@ -1423,7 +1328,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1423
1328
  const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1424
1329
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1425
1330
  const title = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`;
1426
- text.setText(frameTop(title, getFrameStatus(ctx)));
1331
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1427
1332
  return text;
1428
1333
  },
1429
1334
 
@@ -1436,9 +1341,11 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1436
1341
  resolveBaseBackground(theme);
1437
1342
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1438
1343
  const status = getFrameStatus(ctx);
1344
+ const t = asTheme(theme);
1345
+ const w = termW();
1439
1346
 
1440
1347
  if (ctx.isError) {
1441
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1348
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1442
1349
  return text;
1443
1350
  }
1444
1351
 
@@ -1446,13 +1353,13 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1446
1353
  if (d?._type === "findResult" && d.text) {
1447
1354
  const rendered = renderFindResults(d.text);
1448
1355
  const info = `${FG_DIM}${d.matchCount} files${RST}`;
1449
- text.setText(frameResultWithBottomLabel(rendered, info, status));
1356
+ text.setText(frameResultWithBottomLabel(rendered, info, status, t, w));
1450
1357
  return text;
1451
1358
  }
1452
1359
 
1453
1360
  const fallback = result.content?.[0];
1454
1361
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "found";
1455
- 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));
1456
1363
  return text;
1457
1364
  },
1458
1365
  });
@@ -1508,7 +1415,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1508
1415
  const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1509
1416
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1510
1417
  const title = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`;
1511
- text.setText(frameTop(title, getFrameStatus(ctx)));
1418
+ text.setText(frameTop(title, getFrameStatus(ctx), asTheme(theme), termW()));
1512
1419
  return text;
1513
1420
  },
1514
1421
 
@@ -1521,37 +1428,42 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
1521
1428
  resolveBaseBackground(theme);
1522
1429
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1523
1430
  const status = getFrameStatus(ctx);
1431
+ const t = asTheme(theme);
1432
+ const w = termW();
1524
1433
 
1525
1434
  if (ctx.isError) {
1526
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1435
+ text.setText(renderToolError(getTextContent(result) || "Error", t, w));
1527
1436
  return text;
1528
1437
  }
1529
1438
 
1530
1439
  const d = result.details;
1531
1440
  if (d?._type === "grepResult" && d.text) {
1532
- const key = `grep:${d.pattern}:${d.matchCount}:${termW()}:${status}`;
1441
+ const key = `grep:${d.pattern}:${d.matchCount}:${w}:${status}`;
1533
1442
  if (ctx.state._gk !== key) {
1534
1443
  ctx.state._gk = key;
1535
1444
  const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1536
- ctx.state._gt = frameResultWithBottomLabel("", info, status);
1445
+ ctx.state._gt = frameResultWithBottomLabel("", info, status, t, w);
1537
1446
 
1538
1447
  renderGrepResults(d.text, d.pattern)
1539
1448
  .then((rendered: string) => {
1540
1449
  if (ctx.state._gk !== key) return;
1541
- ctx.state._gt = frameResultWithBottomLabel(rendered, info, status);
1450
+ ctx.state._gt = frameResultWithBottomLabel(rendered, info, status, t, w);
1542
1451
  ctx.invalidate();
1543
1452
  })
1544
- .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
+ });
1545
1457
  }
1546
1458
  text.setText(
1547
- 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),
1548
1460
  );
1549
1461
  return text;
1550
1462
  }
1551
1463
 
1552
1464
  const fallback = result.content?.[0];
1553
1465
  const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
1554
- 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));
1555
1467
  return text;
1556
1468
  },
1557
1469
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wierdbytes/pi-facelift",
3
- "version": "0.1.0",
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
+ });
package/scripts/demo.ts CHANGED
@@ -260,6 +260,56 @@ async function demoBashTimeout(): Promise<void> {
260
260
  );
261
261
  }
262
262
 
263
+ async function demoBashMultiline(): Promise<void> {
264
+ header("bash multi-line command — sub-tree title + \\r-safe body rails");
265
+ // Multi-line shell command (line continuations with `\`). The title
266
+ // renders as a sub-tree with `│`/`╰` connectors aligned under the first
267
+ // arg of the first row; continuation lines stay in the same accent
268
+ // color as the first row.
269
+ const command = [
270
+ "cd /Users/mentor/me/dev/pi-wierd-stuff && \\",
271
+ ' echo "=== before ===" && \\',
272
+ " git log --oneline -3 origin/master && \\",
273
+ " git pull --rebase && \\",
274
+ ' echo "=== after ==="',
275
+ ].join("\n");
276
+
277
+ // Body intentionally contains `Rebasing (1/1)\rSuccessfully rebased…`,
278
+ // the exact pattern `git rebase` writes to stderr. Without terminal-style
279
+ // `\r` handling, the embedded carriage return would clobber the `│` rail
280
+ // when the terminal honors `\r` as cursor-reset.
281
+ const bodyText = [
282
+ "=== before ===",
283
+ "981c12d facelift: add @wierdbytes/pi-facelift v0.1.0",
284
+ "4517d9c README: fix stale npm package names",
285
+ "046a5c2 web: rename slash command from /wierd-web to /web, bump to 0.3.1",
286
+ "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/master.",
287
+ "=== after ===",
288
+ "8770a5e facelift: add @wierdbytes/pi-facelift v0.1.0",
289
+ "046a5c2 web: rename slash command from /wierd-web to /web, bump to 0.3.1",
290
+ "f87cbcf voice: rename slash command from /wierd-voice to /voice, bump to 0.4.1",
291
+ ].join("\n");
292
+
293
+ const tools = loadTools({
294
+ bash: async () => ({
295
+ content: [{ type: "text", text: bodyText }],
296
+ details: { _type: "bashResult", text: bodyText, exitCode: 0, command },
297
+ }),
298
+ });
299
+ const bash = tools.get("bash");
300
+ const state: { startedAt?: number; endedAt?: number } = { startedAt: Date.now() - 1200 };
301
+
302
+ const callCtx = makeCtx({ state, expanded: true });
303
+ console.log(bash.renderCall({ command }, theme, callCtx).getText());
304
+
305
+ const result = await bash.execute("t1", { command }, undefined, undefined, {});
306
+ state.endedAt = Date.now();
307
+ const resCtx = makeCtx({ state, expanded: true });
308
+ console.log(
309
+ bash.renderResult(result, { isPartial: false, expanded: true }, theme, resCtx).getText(),
310
+ );
311
+ }
312
+
263
313
  async function demoLs(): Promise<void> {
264
314
  header("ls — tree view with Nerd Font icons");
265
315
  const tools = loadTools({
@@ -344,6 +394,7 @@ async function main(): Promise<void> {
344
394
  await demoBashError();
345
395
  await demoBashStreaming();
346
396
  await demoBashTimeout();
397
+ await demoBashMultiline();
347
398
  await demoLs();
348
399
  await demoFind();
349
400
  await demoGrep();