@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 +24 -0
- package/README.md +13 -4
- package/bash-rendering.test.ts +165 -0
- package/image-rendering.test.ts +10 -1
- package/index.ts +87 -175
- package/package.json +3 -2
- package/read-rendering.test.ts +209 -0
- package/scripts/demo.ts +51 -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/bash-rendering.test.ts
CHANGED
|
@@ -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
|
});
|
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,133 +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
|
-
* 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
|
|
745
|
-
|
|
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",
|
|
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}:${
|
|
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
|
-
|
|
1230
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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}:${
|
|
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.
|
|
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();
|