@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 +24 -0
- package/README.md +13 -4
- package/image-rendering.test.ts +10 -1
- package/index.ts +75 -270
- package/package.json +3 -2
- package/read-rendering.test.ts +209 -0
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
|
|
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,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
|
|
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.
|
|
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
|
|
841
|
-
|
|
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",
|
|
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}:${
|
|
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",
|
|
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",
|
|
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",
|
|
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}:${
|
|
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.
|
|
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
|
+
});
|