@xynogen/pix-pretty 1.12.0 → 1.14.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/package.json +1 -1
- package/src/modal-frame.ts +4 -0
- package/src/utils.test.ts +36 -4
- package/src/utils.ts +31 -12
- package/src/widget-format.test.ts +13 -0
- package/src/widget-format.ts +17 -0
package/package.json
CHANGED
package/src/modal-frame.ts
CHANGED
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
wrapTextWithAnsi,
|
|
18
18
|
} from "@earendil-works/pi-tui";
|
|
19
19
|
|
|
20
|
+
export { truncateToWidth, visibleWidth, wrapTextWithAnsi };
|
|
21
|
+
|
|
22
|
+
// ponytail: re-export ANSI-safe wrapping via pix-pretty so consumers dedupe local wrapText (pix-mcp)
|
|
23
|
+
|
|
20
24
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
21
25
|
|
|
22
26
|
const MIN_WIDTH = 40;
|
package/src/utils.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
3
3
|
import { MAX_PREVIEW_LINES } from "./config.js";
|
|
4
4
|
import type { FgTheme } from "./types.js";
|
|
5
5
|
import {
|
|
6
|
+
dotJoin,
|
|
6
7
|
formatCollapsedToolRow,
|
|
7
8
|
formatJson,
|
|
8
9
|
hideCollapsedToolCall,
|
|
@@ -35,6 +36,28 @@ describe("ruleFrame", () => {
|
|
|
35
36
|
expect(plain(out[0]!)).toBe("────");
|
|
36
37
|
expect(plain(out.at(-1)!)).toBe("────");
|
|
37
38
|
});
|
|
39
|
+
|
|
40
|
+
it("paints both rules via the supplied paint fn, neutral by default", () => {
|
|
41
|
+
const green = (s: string) => `<G>${s}</G>`;
|
|
42
|
+
const ok = ruleFrame(["x"], [], 4, green);
|
|
43
|
+
expect(ok[0]).toBe("<G>────</G>");
|
|
44
|
+
expect(ok.at(-1)).toBe("<G>────</G>"); // both top and bottom rule painted
|
|
45
|
+
const neutral = ruleFrame(["x"], [], 4);
|
|
46
|
+
expect(neutral[0]).toContain("50;50;50"); // FG_RULE default tint
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("dotJoin", () => {
|
|
51
|
+
it("joins non-empty parts with a middot, dropping falsy pieces", () => {
|
|
52
|
+
expect(dotJoin(["a", "b", "c"])).toBe("a · b · c");
|
|
53
|
+
expect(dotJoin(["a", "", null, undefined, false, "b"])).toBe("a · b");
|
|
54
|
+
expect(dotJoin(["only"])).toBe("only");
|
|
55
|
+
expect(dotJoin([])).toBe("");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("paints the separator when a paint fn is supplied", () => {
|
|
59
|
+
expect(dotJoin(["a", "b"], (s) => `<${s}>`)).toBe("a< · >b");
|
|
60
|
+
});
|
|
38
61
|
});
|
|
39
62
|
|
|
40
63
|
// Minimal theme: fg() passes text through untouched.
|
|
@@ -202,15 +225,24 @@ describe("renderDimPreview", () => {
|
|
|
202
225
|
expect(out).toContain("body");
|
|
203
226
|
});
|
|
204
227
|
|
|
205
|
-
it("frames the body with a rule top and bottom,
|
|
228
|
+
it("frames the body with a rule top and bottom, dropping the redundant header", () => {
|
|
206
229
|
const out = plain(renderDimPreview("a\nb", theme, { frame: true, header: "2 files" }));
|
|
207
230
|
const lines = out.split("\n");
|
|
208
|
-
// header
|
|
209
|
-
expect(
|
|
210
|
-
expect(lines[
|
|
231
|
+
// No floating header in framed mode — the collapsed row carries the count.
|
|
232
|
+
expect(out).not.toContain("2 files");
|
|
233
|
+
expect(lines[0]).toMatch(/^─+$/); // top rule is the first line
|
|
211
234
|
expect(lines.at(-1)).toMatch(/^─+$/); // bottom rule closes the block
|
|
212
235
|
});
|
|
213
236
|
|
|
237
|
+
it("paints the frame rules when a paint fn is given", () => {
|
|
238
|
+
const tag: FgTheme = { fg: (k, v) => `<${k}>${v}` };
|
|
239
|
+
const out = renderDimPreview("a\nb", tag, {
|
|
240
|
+
frame: true,
|
|
241
|
+
paint: (s: string) => tag.fg("success", s),
|
|
242
|
+
});
|
|
243
|
+
expect(out).toContain("<success>─");
|
|
244
|
+
});
|
|
245
|
+
|
|
214
246
|
it("frames overflow footer below the bottom rule", () => {
|
|
215
247
|
const body = Array.from({ length: MAX_PREVIEW_LINES + 2 }, (_, i) => `L${i}`);
|
|
216
248
|
const out = plain(renderDimPreview(body.join("\n"), theme, { frame: true }));
|
package/src/utils.ts
CHANGED
|
@@ -156,6 +156,19 @@ export function padIcon(glyph: string, width = 2): string {
|
|
|
156
156
|
return pad > 0 ? glyph + " ".repeat(pad) : glyph;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Join non-empty parts with a middot separator: `dotJoin(["a", "", "b"])` → `"a · b"`.
|
|
161
|
+
* Empty/nullish parts are dropped so callers can pass conditional pieces inline.
|
|
162
|
+
* Pass `paint` (e.g. `(s) => theme.fg("dim", s)`) to tint the separator to theme.
|
|
163
|
+
*/
|
|
164
|
+
export function dotJoin(
|
|
165
|
+
parts: Array<string | false | null | undefined>,
|
|
166
|
+
paint?: RulePaint,
|
|
167
|
+
): string {
|
|
168
|
+
const sep = paint ? paint(" · ") : " · ";
|
|
169
|
+
return parts.filter((p): p is string => Boolean(p)).join(sep);
|
|
170
|
+
}
|
|
171
|
+
|
|
159
172
|
type CollapsedToolTheme = {
|
|
160
173
|
fg: (
|
|
161
174
|
key: "success" | "error" | "warning" | "toolTitle" | "muted" | "dim",
|
|
@@ -205,16 +218,16 @@ export function hideCollapsedToolCall(
|
|
|
205
218
|
|
|
206
219
|
export type DimPreviewOptions = {
|
|
207
220
|
maxLines?: number;
|
|
208
|
-
/** Header line shown above the body
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* never disagree. Do not recount the body: notices/blank lines make a body
|
|
212
|
-
* line-count diverge from the semantic count. */
|
|
221
|
+
/** Header line shown above the body. NON-FRAMED mode only — in framed mode
|
|
222
|
+
* the header is intentionally dropped (the collapsed row already carries the
|
|
223
|
+
* count, so a floating header above the frame is redundant). */
|
|
213
224
|
header?: string;
|
|
214
225
|
/** Pattern whose matches are highlighted (green bold) inside dim lines. */
|
|
215
226
|
highlight?: string;
|
|
216
|
-
/** Wrap the body in a top/bottom rule frame (
|
|
227
|
+
/** Wrap the body in a top/bottom rule frame (overflow below), like bash/ls/mcp. */
|
|
217
228
|
frame?: boolean;
|
|
229
|
+
/** Tint the frame rules (green ok, red error) — same status color as bash/ls. */
|
|
230
|
+
paint?: RulePaint;
|
|
218
231
|
};
|
|
219
232
|
|
|
220
233
|
function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string): string {
|
|
@@ -255,9 +268,9 @@ export function renderDimPreview(
|
|
|
255
268
|
: undefined;
|
|
256
269
|
|
|
257
270
|
if (opts.frame) {
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
const out =
|
|
271
|
+
// One shape regardless of line count: a single framed box, no floating
|
|
272
|
+
// header (the collapsed row carries the count). Rules follow status color.
|
|
273
|
+
const out = ruleFrame(body, overflow ? [overflow] : [], undefined, opts.paint);
|
|
261
274
|
return fillToolBackground(out.join("\n"));
|
|
262
275
|
}
|
|
263
276
|
|
|
@@ -336,8 +349,13 @@ export function shortPath(cwd: string, home: string, p: string): string {
|
|
|
336
349
|
return p.replace(home, "~");
|
|
337
350
|
}
|
|
338
351
|
|
|
339
|
-
|
|
340
|
-
|
|
352
|
+
/** Paints a rule line. Callers pass `(s) => theme.fg(status, s)` so the frame
|
|
353
|
+
* tint follows the active theme (success/error/warning). Default = neutral FG_RULE. */
|
|
354
|
+
export type RulePaint = (glyphs: string) => string;
|
|
355
|
+
|
|
356
|
+
export function rule(w: number, paint?: RulePaint): string {
|
|
357
|
+
const glyphs = "─".repeat(w);
|
|
358
|
+
return paint ? paint(glyphs) : `${FG_RULE}${glyphs}${RST}`;
|
|
341
359
|
}
|
|
342
360
|
|
|
343
361
|
/**
|
|
@@ -350,8 +368,9 @@ export function ruleFrame(
|
|
|
350
368
|
bodyLines: string[],
|
|
351
369
|
footerLines: string[] = [],
|
|
352
370
|
width?: number,
|
|
371
|
+
paint?: RulePaint,
|
|
353
372
|
): string[] {
|
|
354
|
-
const r = rule(width ?? termW());
|
|
373
|
+
const r = rule(width ?? termW(), paint);
|
|
355
374
|
return [r, ...bodyLines, r, ...footerLines];
|
|
356
375
|
}
|
|
357
376
|
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
describeActivity,
|
|
4
4
|
fmtTokenCount,
|
|
5
5
|
formatContext,
|
|
6
|
+
formatDuration,
|
|
6
7
|
formatMs,
|
|
7
8
|
formatSpeed,
|
|
8
9
|
formatTokens,
|
|
@@ -37,6 +38,18 @@ describe("widget formatters", () => {
|
|
|
37
38
|
expect(formatMs(2_100)).toBe("2.1s");
|
|
38
39
|
});
|
|
39
40
|
|
|
41
|
+
test("formatDuration keeps 3 presentations via style param", () => {
|
|
42
|
+
expect(formatDuration(420, "bash")).toBe("420ms");
|
|
43
|
+
expect(formatDuration(2_450, "bash")).toBe("2.5s");
|
|
44
|
+
expect(formatDuration(12_400, "bash")).toBe("12s");
|
|
45
|
+
expect(formatDuration(450, "btw")).toBe("450ms");
|
|
46
|
+
expect(formatDuration(2_100, "btw")).toBe("2.1s");
|
|
47
|
+
expect(formatDuration(12_400, "btw")).toBe("12s");
|
|
48
|
+
expect(formatDuration(65_000, "btw")).toBe("1m 5s");
|
|
49
|
+
expect(formatDuration(2_100)).toBe("2.1s");
|
|
50
|
+
expect(formatDuration(2_100, "ms")).toBe(formatMs(2_100));
|
|
51
|
+
});
|
|
52
|
+
|
|
40
53
|
test("formatSpeed returns empty when there is no work", () => {
|
|
41
54
|
expect(formatSpeed(0, 1_000)).toBe("");
|
|
42
55
|
expect(formatSpeed(100, 0)).toBe("");
|
package/src/widget-format.ts
CHANGED
|
@@ -109,6 +109,23 @@ export function formatMs(ms: number): string {
|
|
|
109
109
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** ponytail: unified duration - keep 3 presentations via style param. */
|
|
113
|
+
export type DurationStyle = "bash" | "btw" | "ms"; // bash=ms/<10s 1dp/>10s int; btw=ms/s/m+s; ms=always s 1dp
|
|
114
|
+
export function formatDuration(ms: number, style: DurationStyle = "ms"): string {
|
|
115
|
+
const n = Math.max(0, Math.floor(ms));
|
|
116
|
+
if (style === "bash") {
|
|
117
|
+
if (n < 1_000) return `${n}ms`;
|
|
118
|
+
if (n < 10_000) return `${(n / 1_000).toFixed(1)}s`;
|
|
119
|
+
return `${Math.round(n / 1_000)}s`;
|
|
120
|
+
}
|
|
121
|
+
if (style === "btw") {
|
|
122
|
+
if (n < 1_000) return `${n}ms`;
|
|
123
|
+
if (n < 60_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}s`;
|
|
124
|
+
return `${Math.floor(n / 60_000)}m ${Math.round((n % 60_000) / 1_000)}s`;
|
|
125
|
+
}
|
|
126
|
+
return formatMs(n);
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
/**
|
|
113
130
|
* Output tokens per second over a duration. "" when either input is
|
|
114
131
|
* non-positive (no work / zero elapsed) so callers can skip the segment.
|