@bismawy/pi-arnative 0.2.3 → 0.2.5
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 +88 -0
- package/extensions/footer.ts +48 -67
- package/extensions/section-headers.ts +87 -112
- package/extensions/timestamps.ts +75 -31
- package/extensions/tools.ts +157 -147
- package/extensions/ui-render-tweaks.ts +101 -119
- package/extensions/usage.ts +17 -25
- package/lib/ansi.ts +22 -9
- package/lib/box.ts +8 -13
- package/lib/check.ts +12 -0
- package/lib/format.ts +33 -0
- package/lib/usage-store.ts +3 -1
- package/package.json +2 -1
- package/themes/gen-themes.mjs +38 -38
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
* untouched):
|
|
4
4
|
* 1. Hide pi's built-in "Reloading keybindings, extensions, skills..." box
|
|
5
5
|
* (hardcoded in handleReloadCommand, no auto-hide).
|
|
6
|
-
* 2. Drag selection: pi
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* blank line inside a purple box; our renderer shows the content as-is.
|
|
6
|
+
* 2. Drag selection: pi wraps it in reverse video (\x1b[7m), unreadable in some
|
|
7
|
+
* terminals/themes -> explicit colors (bg selectedBg + fg text).
|
|
8
|
+
* 3. Compaction message: pi's 3 lines (label, blank, text) squeezed into one:
|
|
9
|
+
* `[compaction] Compacted from N tokens (ctrl+o to expand)`.
|
|
10
|
+
* 4. Custom `pi-jev-eye-review` message: pi adds a label + blank line inside a purple
|
|
11
|
+
* box; our renderer shows the content as-is.
|
|
13
12
|
* 5. `↓ Jump to latest message` pill (fullscreen): bg selectedBg (grey) -> accent,
|
|
14
13
|
* text darkened (theme canvas) so contrast stays high (9.6:1).
|
|
15
14
|
*/
|
|
@@ -38,8 +37,9 @@ import {
|
|
|
38
37
|
} from "@earendil-works/pi-tui";
|
|
39
38
|
import { readdirSync, readFileSync } from "node:fs";
|
|
40
39
|
import { renderBoxLines } from "../lib/box.ts";
|
|
41
|
-
import { ansiBgOpen } from "../lib/ansi.ts";
|
|
42
|
-
|
|
40
|
+
import { ansiBgOpen, stripAnsi, themeOf } from "../lib/ansi.ts";
|
|
41
|
+
import { assert, isMain } from "../lib/check.ts";
|
|
42
|
+
import { expandKeyName } from "../lib/format.ts";
|
|
43
43
|
|
|
44
44
|
let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
|
|
45
45
|
|
|
@@ -85,11 +85,9 @@ export function ansiFgOpen(th: { fg?(c: string, t: string): string } | null, col
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// `↓ Jump to latest message` pill (fullscreen): pi colors it with bg `selectedBg`
|
|
88
|
-
// (grey)
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// token, so the SGR escapes are swapped. `bg("accent")` would throw "Unknown theme
|
|
92
|
-
// background color" and the pill would silently stay unchanged.
|
|
88
|
+
// (grey) -> accent bg + darkened canvas text (contrast 9.6:1, WCAG AA). pi has no bg
|
|
89
|
+
// token for `accent` (7 exist, see PILL_INK), so the SGR escapes are swapped;
|
|
90
|
+
// `bg("accent")` would throw "Unknown theme background color" and stay unchanged.
|
|
93
91
|
const PILL_KEY = Symbol.for("pi-arnative.accentPill");
|
|
94
92
|
const PILL_MARK = "↓ Jump to latest message";
|
|
95
93
|
const PILL_INK = "toolPendingBg";
|
|
@@ -104,8 +102,8 @@ export function accentPill(
|
|
|
104
102
|
if (!text.includes(PILL_MARK) || !th?.fg || !th?.bg) return text;
|
|
105
103
|
const accentBg = asBg(ansiFgOpen(th, "accent"));
|
|
106
104
|
const inkFg = asFg(ansiBgOpen(th, PILL_INK));
|
|
107
|
-
if (!accentBg || !inkFg) return text; // token
|
|
108
|
-
return `${accentBg}${inkFg}${text
|
|
105
|
+
if (!accentBg || !inkFg) return text; // token missing from the theme -> leave pi's default
|
|
106
|
+
return `${accentBg}${inkFg}${stripAnsi(text)}\x1b[39m\x1b[49m`;
|
|
109
107
|
}
|
|
110
108
|
|
|
111
109
|
// TuiAltScreen builds the pill via the per-instance `scrollToEndIndicator` callback
|
|
@@ -160,7 +158,7 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[PILL_KEY
|
|
|
160
158
|
export default function uiRenderTweaks(pi: ExtensionAPI) {
|
|
161
159
|
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
162
160
|
pi.on("session_start", async (_event, ctx) => {
|
|
163
|
-
activeThemeProxy =
|
|
161
|
+
activeThemeProxy = themeOf<typeof activeThemeProxy>(ctx) ?? activeThemeProxy;
|
|
164
162
|
});
|
|
165
163
|
|
|
166
164
|
// 4. `pi-jev-eye-review` contract message: use our own border box.
|
|
@@ -264,21 +262,12 @@ export class JevReviewBoxComponent implements Component {
|
|
|
264
262
|
// proxy yet) the built-in renderer keeps working.
|
|
265
263
|
export function compactionLine(th: { fg(c: string, t: string): string }, tokenStr: string): string {
|
|
266
264
|
const label = th.fg("customMessageLabel", "\x1b[1m[compaction]\x1b[22m");
|
|
267
|
-
return `${label} ${th.fg("customMessageText", `Compacted from ${tokenStr} tokens (`)}${th.fg("dim",
|
|
265
|
+
return `${label} ${th.fg("customMessageText", `Compacted from ${tokenStr} tokens (`)}${th.fg("dim", expandKeyName())}${th.fg(
|
|
268
266
|
"customMessageText",
|
|
269
267
|
" to expand)",
|
|
270
268
|
)}`;
|
|
271
269
|
}
|
|
272
270
|
|
|
273
|
-
// Button text follows the active keybinding; outside a pi session keyText() is empty.
|
|
274
|
-
function expandKey(): string {
|
|
275
|
-
try {
|
|
276
|
-
return keyText("app.tools.expand") || "ctrl+o";
|
|
277
|
-
} catch {
|
|
278
|
-
return "ctrl+o";
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
271
|
// Custom message content: a plain string, or concatenated text blocks (image blocks dropped).
|
|
283
272
|
export function customMessageText(content: unknown): string {
|
|
284
273
|
if (typeof content === "string") return content;
|
|
@@ -328,117 +317,110 @@ if (CompactionSummaryMessageComponent?.prototype && !(globalThis as Record<symbo
|
|
|
328
317
|
}
|
|
329
318
|
|
|
330
319
|
// Self-check: `node extensions/ui-render-tweaks.ts`
|
|
331
|
-
|
|
332
|
-
if (isMain) {
|
|
333
|
-
const assert = (cond: boolean, msg: string) => {
|
|
334
|
-
if (!cond) {
|
|
335
|
-
console.error(`FAIL: ${msg}`);
|
|
336
|
-
process.exit(1);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
320
|
+
if (isMain(import.meta.url)) {
|
|
339
321
|
// reload box fully hidden (not line by line)
|
|
340
|
-
assert(shouldHideReloadBox(["╭──╮", "│ Reloading keybindings, extensions, skills... │"]) === true, "box
|
|
341
|
-
assert(shouldHideReloadBox(["hello", "world"]) === false, "
|
|
322
|
+
assert(shouldHideReloadBox(["╭──╮", "│ Reloading keybindings, extensions, skills... │"]) === true, "reload box detected");
|
|
323
|
+
assert(shouldHideReloadBox(["hello", "world"]) === false, "normal lines pass through");
|
|
342
324
|
|
|
343
325
|
// selection: reverse video replaced with explicit colors (bg selectedBg + fg text)
|
|
344
326
|
assert(
|
|
345
327
|
recolorSelection("\x1b[7mhi\x1b[27m", "<F>", "<B>") === "<F><B>hi\x1b[39m\x1b[49m",
|
|
346
|
-
"recolorSelection:
|
|
328
|
+
"recolorSelection: open with fg+bg, close with fg+bg reset",
|
|
347
329
|
);
|
|
348
|
-
assert(recolorSelection("plain", "<F>", "<B>") === "plain", "
|
|
349
|
-
assert(ansiFgOpen(null) === "", "
|
|
350
|
-
assert(ansiFgOpen({ fg: (_c, t) => `\x1b[38;2;1;2;3m${t}\x1b[39m` }) === "\x1b[38;2;1;2;3m", "fgOpen
|
|
351
|
-
assert(ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;9;9;9m${t}\x1b[49m` }, "selectedBg") === "\x1b[48;2;9;9;9m", "bgOpen selectedBg
|
|
330
|
+
assert(recolorSelection("plain", "<F>", "<B>") === "plain", "no reverse video: unchanged");
|
|
331
|
+
assert(ansiFgOpen(null) === "", "no theme: empty fgOpen");
|
|
332
|
+
assert(ansiFgOpen({ fg: (_c, t) => `\x1b[38;2;1;2;3m${t}\x1b[39m` }) === "\x1b[38;2;1;2;3m", "fgOpen taken from the theme probe");
|
|
333
|
+
assert(ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;9;9;9m${t}\x1b[49m` }, "selectedBg") === "\x1b[48;2;9;9;9m", "bgOpen selectedBg taken");
|
|
352
334
|
|
|
353
335
|
// "Jump to latest message" pill: accent bg + canvas text (not grey selectedBg).
|
|
354
336
|
// Use the REAL theme: the permissive stub would let `bg("accent")` through,
|
|
355
337
|
// which pi throws on.
|
|
356
|
-
const
|
|
338
|
+
const themeJson = JSON.parse(readFileSync(new URL("../themes/arnative.json", import.meta.url), "utf8")) as {
|
|
357
339
|
vars: Record<string, string>;
|
|
358
340
|
colors: Record<string, string>;
|
|
359
341
|
};
|
|
360
|
-
const hex = (tok: string) =>
|
|
342
|
+
const hex = (tok: string) => themeJson.vars[themeJson.colors[tok] ?? tok];
|
|
361
343
|
// The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
|
|
362
344
|
// so the fg map must be complete; take the whole palette like pi's loader does.
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
const
|
|
345
|
+
const fgPalette = Object.fromEntries(Object.entries(themeJson.colors).map(([k, v]) => [k, themeJson.vars[v] ?? v]));
|
|
346
|
+
const thPill = new Theme(fgPalette, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
|
|
347
|
+
const pillOriginal = thPill.bg("selectedBg", thPill.fg("text", " ↓ Jump to latest message · End "));
|
|
366
348
|
const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
|
|
367
349
|
assert(
|
|
368
|
-
accentPill(
|
|
350
|
+
accentPill(pillOriginal, thPill) ===
|
|
369
351
|
`\x1b[48;2;${rgb(hex("accent"))}m\x1b[38;2;${rgb(hex("toolPendingBg"))}m ↓ Jump to latest message · End \x1b[39m\x1b[49m`,
|
|
370
|
-
"
|
|
352
|
+
"pill: accent bg + canvas text, old ANSI dropped",
|
|
371
353
|
);
|
|
372
|
-
let
|
|
354
|
+
let rejected = false;
|
|
373
355
|
try {
|
|
374
|
-
|
|
356
|
+
thPill.bg("accent", "x");
|
|
375
357
|
} catch {
|
|
376
|
-
|
|
358
|
+
rejected = true;
|
|
377
359
|
}
|
|
378
|
-
assert(
|
|
379
|
-
assert(accentPill("
|
|
380
|
-
assert(accentPill(
|
|
360
|
+
assert(rejected, "pi really rejects bg('accent') -> the SGR direction must be swapped");
|
|
361
|
+
assert(accentPill("other text", thPill) === "other text", "pill: non-pill text untouched");
|
|
362
|
+
assert(accentPill(pillOriginal, null) === pillOriginal, "pill: no theme -> passed through");
|
|
381
363
|
|
|
382
364
|
// Theme variants: every themes/*.json must be valid — name = file name, all
|
|
383
365
|
// required colors present, valid hex, and contrast measured against that theme's
|
|
384
366
|
// own canvas (userMessageBg): content >= 3.0, secondary text >= 2.0, thinking
|
|
385
367
|
// ramp 1.3-4.0, accent pill >= 4.5 — the thresholds that already pass in the
|
|
386
368
|
// base `arnative` (not invented numbers).
|
|
387
|
-
const
|
|
388
|
-
const
|
|
369
|
+
const themesDir = new URL("../themes/", import.meta.url);
|
|
370
|
+
const required = Object.keys(themeJson.colors);
|
|
389
371
|
// Non-text keys (backgrounds/borders/scrollbar) — contrast is not measured.
|
|
390
|
-
const
|
|
372
|
+
const nonText = new Set([
|
|
391
373
|
"selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg",
|
|
392
374
|
"searchMatchBg", "border", "borderAccent", "borderMuted", "mdCodeBlockBorder", "mdQuoteBorder",
|
|
393
375
|
"scrollbarTrack", "scrollbarThumb",
|
|
394
376
|
]);
|
|
395
|
-
const
|
|
377
|
+
const secondary = new Set(["muted", "dim", "mdQuote", "mdHr", "mdLinkUrl", "toolOutput", "toolDiffContext", "thinkingText", "syntaxComment"]);
|
|
396
378
|
// Thinking ramp: the higher the more readable; thinkingMax = accent (measured on the pill path).
|
|
397
379
|
const ramp: Record<string, number> = {
|
|
398
380
|
thinkingOff: 1.3, thinkingMinimal: 1.3, thinkingLow: 2.0,
|
|
399
381
|
thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
|
|
400
382
|
};
|
|
401
|
-
const
|
|
383
|
+
const luminance = (h: string) => {
|
|
402
384
|
const ch = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) =>
|
|
403
385
|
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
|
|
404
386
|
);
|
|
405
387
|
return 0.2126 * ch[0]! + 0.7152 * ch[1]! + 0.0722 * ch[2]!;
|
|
406
388
|
};
|
|
407
|
-
const
|
|
408
|
-
const [x, y] = [
|
|
389
|
+
const contrast = (a: string, b: string) => {
|
|
390
|
+
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
|
|
409
391
|
return (x! + 0.05) / (y! + 0.05);
|
|
410
392
|
};
|
|
411
|
-
for (const f of readdirSync(
|
|
412
|
-
const d = JSON.parse(readFileSync(new URL(f,
|
|
393
|
+
for (const f of readdirSync(themesDir).filter((n) => n.endsWith(".json")).sort()) {
|
|
394
|
+
const d = JSON.parse(readFileSync(new URL(f, themesDir), "utf8")) as {
|
|
413
395
|
name: string;
|
|
414
396
|
vars: Record<string, string>;
|
|
415
397
|
colors: Record<string, string>;
|
|
416
398
|
};
|
|
417
|
-
assert(d.name === f.replace(/\.json$/, ""), `
|
|
418
|
-
const
|
|
419
|
-
assert(
|
|
399
|
+
assert(d.name === f.replace(/\.json$/, ""), `theme ${f}: name = file name`);
|
|
400
|
+
const missing = required.filter((k) => !(k in d.colors));
|
|
401
|
+
assert(missing.length === 0, `theme ${f}: required colors complete (missing ${missing.join(",")})`);
|
|
420
402
|
// colors values may only be: hex, 256-index, empty, or a REAL variable name —
|
|
421
403
|
// a bare color name (e.g. "muted") makes pi reject the theme with no clear reason.
|
|
422
|
-
for (const [tok,
|
|
423
|
-
if (typeof
|
|
424
|
-
if (
|
|
425
|
-
assert(false, `
|
|
404
|
+
for (const [tok, value] of Object.entries(d.colors)) {
|
|
405
|
+
if (typeof value !== "string") continue;
|
|
406
|
+
if (value === "" || /^#[0-9a-f]{6}$/i.test(value) || value in d.vars) continue;
|
|
407
|
+
assert(false, `theme ${f}: colors.${tok} = "${value}" is not hex/var`);
|
|
426
408
|
}
|
|
427
409
|
const hex = (tok: string) => d.vars[d.colors[tok] ?? tok] ?? d.colors[tok];
|
|
428
410
|
for (const tok of ["accent", "text", "selectedBg", "toolPendingBg"]) {
|
|
429
|
-
assert(/^#[0-9a-f]{6}$/i.test(hex(tok)), `
|
|
411
|
+
assert(/^#[0-9a-f]{6}$/i.test(hex(tok)), `theme ${f}: ${tok} is hex (${hex(tok)})`);
|
|
430
412
|
}
|
|
431
413
|
// Accent pill: accent bg + toolPendingBg colored text.
|
|
432
|
-
assert(
|
|
433
|
-
assert(/^#[0-9a-f]{6}$/i.test(d.vars.softCyan ?? ""), `
|
|
434
|
-
const
|
|
414
|
+
assert(contrast(hex("accent"), hex("toolPendingBg")) >= 4.5, `theme ${f}: accent vs toolPendingBg contrast >= 4.5`);
|
|
415
|
+
assert(/^#[0-9a-f]{6}$/i.test(d.vars.softCyan ?? ""), `theme ${f}: vars.softCyan (tint) is hex`);
|
|
416
|
+
const canvas = hex("userMessageBg");
|
|
435
417
|
for (const tok of Object.keys(d.colors)) {
|
|
436
|
-
if (
|
|
437
|
-
const
|
|
438
|
-
if (!/^#[0-9a-f]{6}$/i.test(
|
|
439
|
-
const
|
|
440
|
-
const c =
|
|
441
|
-
assert(c >=
|
|
418
|
+
if (nonText.has(tok) || tok === "thinkingMax") continue;
|
|
419
|
+
const color = hex(tok);
|
|
420
|
+
if (!/^#[0-9a-f]{6}$/i.test(color)) continue; // 256-color index: let pi judge it
|
|
421
|
+
const limit = ramp[tok] ?? (secondary.has(tok) ? 2.0 : 3.0);
|
|
422
|
+
const c = contrast(color, canvas);
|
|
423
|
+
assert(c >= limit, `theme ${f}: ${tok} vs canvas contrast ${c.toFixed(2)} < ${limit}`);
|
|
442
424
|
}
|
|
443
425
|
// Tool box text must be readable on the success background AND in errors, and
|
|
444
426
|
// the error text in the error box — three pairs that used to pass (3.48-4.46)
|
|
@@ -447,39 +429,39 @@ if (isMain) {
|
|
|
447
429
|
["toolOutput", "toolSuccessBg"], ["toolOutput", "toolErrorBg"],
|
|
448
430
|
["error", "toolErrorBg"], ["toolDiffRemoved", "toolErrorBg"],
|
|
449
431
|
] as const) {
|
|
450
|
-
const c =
|
|
451
|
-
assert(c >= 4.5, `
|
|
432
|
+
const c = contrast(hex(fg), hex(bg));
|
|
433
|
+
assert(c >= 4.5, `theme ${f}: ${fg} on ${bg} contrast ${c.toFixed(2)} < 4.5`);
|
|
452
434
|
}
|
|
453
435
|
// The error box must not be heavier than the success box (matching background luminance).
|
|
454
|
-
const [lErr, lOk] = [
|
|
455
|
-
assert(Math.abs(lErr - lOk) / Math.max(lErr, lOk) <= 0.25, `
|
|
436
|
+
const [lErr, lOk] = [luminance(hex("toolErrorBg")), luminance(hex("toolSuccessBg"))];
|
|
437
|
+
assert(Math.abs(lErr - lOk) / Math.max(lErr, lOk) <= 0.25, `theme ${f}: toolErrorBg vs toolSuccessBg weight (${lErr.toFixed(3)} vs ${lOk.toFixed(3)})`);
|
|
456
438
|
// Scrollbar thumb & highlight must be distinguishable from track/selection (they used to be identical).
|
|
457
|
-
assert(hex("scrollbarThumb") !== hex("scrollbarTrack"), `
|
|
458
|
-
assert(hex("searchMatchBg") !== hex("selectedBg"), `
|
|
439
|
+
assert(hex("scrollbarThumb") !== hex("scrollbarTrack"), `theme ${f}: scrollbarThumb still equals scrollbarTrack`);
|
|
440
|
+
assert(hex("searchMatchBg") !== hex("selectedBg"), `theme ${f}: searchMatchBg still equals selectedBg`);
|
|
459
441
|
}
|
|
460
|
-
const
|
|
442
|
+
const thEmpty = new Theme(
|
|
461
443
|
{ muted: "#808080", text: "#d4d4d4", thinkingXhigh: "#20caee" },
|
|
462
444
|
{ selectedBg: "#3a3a4a" },
|
|
463
445
|
"truecolor",
|
|
464
446
|
);
|
|
465
|
-
assert(accentPill(
|
|
466
|
-
const hostPil: { scrollToEndIndicator?: () => string } = { scrollToEndIndicator: () =>
|
|
467
|
-
const
|
|
468
|
-
assert(
|
|
469
|
-
assert(hostPil.scrollToEndIndicator?.() ===
|
|
447
|
+
assert(accentPill(pillOriginal, thEmpty) === pillOriginal, "pill: token absent -> passed through");
|
|
448
|
+
const hostPil: { scrollToEndIndicator?: () => string } = { scrollToEndIndicator: () => pillOriginal };
|
|
449
|
+
const inPill = withAccentPill(hostPil, () => hostPil.scrollToEndIndicator?.(), thPill);
|
|
450
|
+
assert(inPill.includes("\x1b[48;2;0;215;255m"), "pill: instance callback injected with accent bg");
|
|
451
|
+
assert(hostPil.scrollToEndIndicator?.() === pillOriginal, "pill: original callback restored after render");
|
|
470
452
|
|
|
471
453
|
// compaction: pi's three lines -> one
|
|
472
454
|
assert(
|
|
473
|
-
compactionLine({ fg: (_c, t) => t }, "112,247")
|
|
455
|
+
stripAnsi(compactionLine({ fg: (_c, t) => t }, "112,247")) ===
|
|
474
456
|
"[compaction] Compacted from 112,247 tokens (ctrl+o to expand)",
|
|
475
|
-
"compaction: label +
|
|
457
|
+
"compaction: label + text + expand hint on one line (no blank line)",
|
|
476
458
|
);
|
|
477
459
|
|
|
478
460
|
// custom message: content as-is, no extra label/lines
|
|
479
461
|
assert(
|
|
480
|
-
customMessageText("[pi-jev-eye] Reviewed turn contract:\n-
|
|
481
|
-
"[pi-jev-eye] Reviewed turn contract:\n-
|
|
482
|
-
"
|
|
462
|
+
customMessageText("[pi-jev-eye] Reviewed turn contract:\n- Language follows the user") ===
|
|
463
|
+
"[pi-jev-eye] Reviewed turn contract:\n- Language follows the user",
|
|
464
|
+
"custom message content intact",
|
|
483
465
|
);
|
|
484
466
|
assert(
|
|
485
467
|
customMessageText([
|
|
@@ -487,7 +469,7 @@ if (isMain) {
|
|
|
487
469
|
{ type: "image", data: "x" },
|
|
488
470
|
{ type: "text", text: "b" },
|
|
489
471
|
]) === "a\nb",
|
|
490
|
-
"
|
|
472
|
+
"non-text blocks dropped",
|
|
491
473
|
);
|
|
492
474
|
|
|
493
475
|
// Integration: run the factory like pi, then call updateDisplay / the original renderer
|
|
@@ -502,14 +484,14 @@ if (isMain) {
|
|
|
502
484
|
this.renderers[t] = r;
|
|
503
485
|
},
|
|
504
486
|
};
|
|
505
|
-
initTheme(); // markdown +
|
|
487
|
+
initTheme(); // markdown + pi's internal theme (already active in a real session)
|
|
506
488
|
uiRenderTweaks(fakePi as any);
|
|
507
489
|
await fakePi.handlers[0]({}, { ui: { theme: { fg: (_c: string, t: string) => t } } });
|
|
508
490
|
|
|
509
491
|
const kids: any[] = [];
|
|
510
492
|
const fake = {
|
|
511
493
|
expanded: false,
|
|
512
|
-
message: { tokensBefore: 112247, summary: "**
|
|
494
|
+
message: { tokensBefore: 112247, summary: "**Summary** long\n\n- one" },
|
|
513
495
|
clear() {
|
|
514
496
|
kids.length = 0;
|
|
515
497
|
},
|
|
@@ -521,56 +503,56 @@ if (isMain) {
|
|
|
521
503
|
const collapsed = kids[0].render(120);
|
|
522
504
|
assert(
|
|
523
505
|
collapsed.length === 1 && collapsed[0].trimEnd().endsWith("Compacted from 112,247 tokens (ctrl+o to expand)"),
|
|
524
|
-
"compaction collapsed:
|
|
506
|
+
"compaction collapsed: exactly one line",
|
|
525
507
|
);
|
|
526
508
|
|
|
527
509
|
fake.expanded = true;
|
|
528
510
|
CompactionSummaryMessageComponent.prototype.updateDisplay.call(fake as any);
|
|
529
511
|
const expanded = kids[0].render(120);
|
|
530
512
|
assert(
|
|
531
|
-
expanded.length > 1 && expanded.some((l: string) => l.includes("[compaction]")) && expanded.join(" ").includes("
|
|
532
|
-
"compaction expanded: label +
|
|
513
|
+
expanded.length > 1 && expanded.some((l: string) => l.includes("[compaction]")) && expanded.join(" ").includes("Summary"),
|
|
514
|
+
"compaction expanded: label + markdown summary still there",
|
|
533
515
|
);
|
|
534
516
|
|
|
535
517
|
const contractCollapsed = fakePi.renderers["pi-jev-eye-review"](
|
|
536
|
-
{ customType: "pi-jev-eye-review", content: "[pi-jev-eye] Reviewed turn contract:\n-
|
|
518
|
+
{ customType: "pi-jev-eye-review", content: "[pi-jev-eye] Reviewed turn contract:\n- Language follows the user" },
|
|
537
519
|
{ expanded: false, outputPad: 1 },
|
|
538
520
|
{ fg: (_c: string, t: string) => t },
|
|
539
521
|
);
|
|
540
522
|
const cLines = contractCollapsed.render(120).map((l: string) => l.trimEnd());
|
|
541
523
|
assert(
|
|
542
524
|
cLines[0].startsWith("╭") && cLines[cLines.length - 1].startsWith("╰") && cLines[1].includes("[click to expand]"),
|
|
543
|
-
"
|
|
525
|
+
"contract message collapsed: border box with [click to expand]",
|
|
544
526
|
);
|
|
545
527
|
|
|
546
528
|
// Package update notification: arnative box, body and yellow kept
|
|
547
|
-
const upd = new PackageUpdateBoxComponent(["github.com/bismawy/pi-arnative", "pkg-
|
|
529
|
+
const upd = new PackageUpdateBoxComponent(["github.com/bismawy/pi-arnative", "pkg-two"], {
|
|
548
530
|
fg: (c: string, t: string) => (c === "warning" ? `\x1b[33m${t}\x1b[39m` : t),
|
|
549
531
|
}).render(60);
|
|
550
|
-
assert(upd[0]!.includes("\x1b[33m") && upd[0]!.includes("╭"), "
|
|
551
|
-
assert(upd[upd.length - 1]!.includes("╰"), "
|
|
552
|
-
assert(upd[1]!.includes("Package Updates Available"), "
|
|
553
|
-
assert(upd[1]!.includes("\uf449"), "
|
|
554
|
-
assert(upd.some((l: string) => l.includes("- github.com/bismawy/pi-arnative")), "
|
|
555
|
-
assert(upd.every((l: string) => visibleWidth(l) === 60), "
|
|
532
|
+
assert(upd[0]!.includes("\x1b[33m") && upd[0]!.includes("╭"), "update box: round frame + warning color");
|
|
533
|
+
assert(upd[upd.length - 1]!.includes("╰"), "update box: bottom frame");
|
|
534
|
+
assert(upd[1]!.includes("Package Updates Available"), "update title inside the box");
|
|
535
|
+
assert(upd[1]!.includes("\uf449"), "\uf449 icon on the update title");
|
|
536
|
+
assert(upd.some((l: string) => l.includes("- github.com/bismawy/pi-arnative")), "package list inside the box");
|
|
537
|
+
assert(upd.every((l: string) => visibleWidth(l) === 60), "update box is 60 wide");
|
|
556
538
|
|
|
557
539
|
// Test invalidate and the handleMouse click toggle
|
|
558
540
|
contractCollapsed.invalidate();
|
|
559
541
|
const mouseRes = contractCollapsed.handleMouse({ type: "click", button: "left" });
|
|
560
|
-
assert(mouseRes?.handled === true, "
|
|
561
|
-
assert(contractCollapsed.isExpanded === true, "
|
|
542
|
+
assert(mouseRes?.handled === true, "left click handled");
|
|
543
|
+
assert(contractCollapsed.isExpanded === true, "left click toggles expanded");
|
|
562
544
|
const toggledLines = contractCollapsed.render(120).map((l: string) => l.trimEnd());
|
|
563
|
-
assert(toggledLines.some((l: string) => l.includes("
|
|
545
|
+
assert(toggledLines.some((l: string) => l.includes("Language follows the user")), "after the click: full content shown");
|
|
564
546
|
|
|
565
547
|
const contractExpanded = fakePi.renderers["pi-jev-eye-review"](
|
|
566
|
-
{ customType: "pi-jev-eye-review", content: "[pi-jev-eye] Reviewed turn contract:\n-
|
|
548
|
+
{ customType: "pi-jev-eye-review", content: "[pi-jev-eye] Reviewed turn contract:\n- Language follows the user" },
|
|
567
549
|
{ expanded: true, outputPad: 1 },
|
|
568
550
|
{ fg: (_c: string, t: string) => t },
|
|
569
551
|
);
|
|
570
552
|
const expLines = contractExpanded.render(120).map((l: string) => l.trimEnd());
|
|
571
553
|
assert(
|
|
572
|
-
expLines[0].startsWith("╭") && expLines[expLines.length - 1].startsWith("╰") && expLines.some((l: string) => l.includes("
|
|
573
|
-
"
|
|
554
|
+
expLines[0].startsWith("╭") && expLines[expLines.length - 1].startsWith("╰") && expLines.some((l: string) => l.includes("Language follows the user")),
|
|
555
|
+
"contract message expanded: border box with the full content",
|
|
574
556
|
);
|
|
575
557
|
console.log("ui-render-tweaks.ts self-check OK");
|
|
576
558
|
}
|
package/extensions/usage.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Arnative Usage Dashboard (/usage): table UI + the /usage command.
|
|
3
3
|
* Data layer (session scan + cache + aggregation) lives in lib/usage-store.ts —
|
|
4
|
-
* shared with the Model tab
|
|
4
|
+
* shared with the Model tab.
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
8
8
|
import { boxEdge, boxRow } from "../lib/box.ts";
|
|
9
|
+
import { assert, isMain } from "../lib/check.ts";
|
|
9
10
|
import { collectUsageSummary, formatTokens, getModelAllTimeUsage, type UsageSummary } from "../lib/usage-store.ts";
|
|
10
|
-
export { collectUsageSummary, formatTokens, getModelAllTimeUsage };
|
|
11
|
-
export type { UsageEntry, UsageModelStats, UsageProviderStats, UsageSummary } from "../lib/usage-store.ts";
|
|
12
11
|
|
|
13
12
|
export function formatCost(n: number): string {
|
|
14
13
|
if (!n || n <= 0) return "-";
|
|
@@ -250,7 +249,7 @@ export class UsageModalComponent implements Component {
|
|
|
250
249
|
|
|
251
250
|
export default function (pi: ExtensionAPI) {
|
|
252
251
|
pi.registerCommand("usage", {
|
|
253
|
-
description: "
|
|
252
|
+
description: "Arnative token & model usage dashboard",
|
|
254
253
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
255
254
|
if (!ctx.hasUI) return;
|
|
256
255
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
@@ -261,14 +260,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
261
260
|
}
|
|
262
261
|
|
|
263
262
|
// Self-check: `node extensions/usage.ts`
|
|
264
|
-
|
|
265
|
-
if (isMain) {
|
|
266
|
-
const assert = (cond: boolean, msg: string) => {
|
|
267
|
-
if (!cond) {
|
|
268
|
-
console.error(`FAIL: ${msg}`);
|
|
269
|
-
process.exit(1);
|
|
270
|
-
}
|
|
271
|
-
};
|
|
263
|
+
if (isMain(import.meta.url)) {
|
|
272
264
|
|
|
273
265
|
assert(formatTokens(0) === "-", "formatTokens 0 = -");
|
|
274
266
|
assert(formatTokens(500) === "500", "formatTokens 500 = 500");
|
|
@@ -281,13 +273,13 @@ if (isMain) {
|
|
|
281
273
|
assert(formatCost(1234.5) === "$1.2k", "formatCost 1234.5 = $1.2k");
|
|
282
274
|
|
|
283
275
|
const summary = collectUsageSummary();
|
|
284
|
-
assert(summary.providers.length > 0, "collectUsageSummary
|
|
285
|
-
assert(summary.totals.sessions > 0, "collectUsageSummary
|
|
286
|
-
assert(summary.totals.msgs > 0, "collectUsageSummary
|
|
287
|
-
assert(summary.totals.cost >= 0, "collectUsageSummary
|
|
276
|
+
assert(summary.providers.length > 0, "collectUsageSummary detects providers");
|
|
277
|
+
assert(summary.totals.sessions > 0, "collectUsageSummary detects total sessions");
|
|
278
|
+
assert(summary.totals.msgs > 0, "collectUsageSummary detects total msgs");
|
|
279
|
+
assert(summary.totals.cost >= 0, "collectUsageSummary counts cost");
|
|
288
280
|
|
|
289
281
|
const modelUsage = getModelAllTimeUsage("gemini-3.8-flash");
|
|
290
|
-
assert(modelUsage.input > 0, "getModelAllTimeUsage
|
|
282
|
+
assert(modelUsage.input > 0, "getModelAllTimeUsage reads input tokens for gemini-3.8-flash");
|
|
291
283
|
|
|
292
284
|
const fakeTheme = {
|
|
293
285
|
fg: (_c: string, t: string) => `\x1b[38;2;100;100;100m${t}\x1b[39m`,
|
|
@@ -295,19 +287,19 @@ if (isMain) {
|
|
|
295
287
|
};
|
|
296
288
|
const modal = new UsageModalComponent(fakeTheme, () => {}, () => {});
|
|
297
289
|
const lines = modal.render(100);
|
|
298
|
-
assert(lines.length >= 10, "render
|
|
299
|
-
assert(lines[0]!.includes("╭") && lines[0]!.includes("╮"), "
|
|
300
|
-
assert(visibleWidth(lines[0]!) === 100, "border
|
|
301
|
-
assert(lines[1]!.includes("LLM Usage"), "
|
|
302
|
-
assert(lines[2]!.includes("sessions"), "
|
|
303
|
-
assert(lines[lines.length - 1]!.includes("╰") && lines[lines.length - 1]!.includes("╯"), "
|
|
290
|
+
assert(lines.length >= 10, "modal render produces table lines");
|
|
291
|
+
assert(lines[0]!.includes("╭") && lines[0]!.includes("╮"), "round top border");
|
|
292
|
+
assert(visibleWidth(lines[0]!) === 100, "box border full width (100 columns)");
|
|
293
|
+
assert(lines[1]!.includes("LLM Usage"), "LLM Usage title inside the box");
|
|
294
|
+
assert(lines[2]!.includes("sessions"), "description below the title");
|
|
295
|
+
assert(lines[lines.length - 1]!.includes("╰") && lines[lines.length - 1]!.includes("╯"), "round bottom border");
|
|
304
296
|
assert(
|
|
305
297
|
lines.every((l) => visibleWidth(l) <= 100),
|
|
306
|
-
"
|
|
298
|
+
"every line fits within width 100",
|
|
307
299
|
);
|
|
308
300
|
assert(
|
|
309
301
|
lines.every((l) => ["╭", "│", "├", "╰"].some((c) => l.includes(c))),
|
|
310
|
-
"
|
|
302
|
+
"no line missing a box side (accent line gone)",
|
|
311
303
|
);
|
|
312
304
|
|
|
313
305
|
console.log("usage.ts self-check OK");
|
package/lib/ansi.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assert, isMain } from "./check.ts";
|
|
2
|
+
|
|
1
3
|
/** Theme bg open-code, stripped of its trailing \x1b[49m reset. */
|
|
2
4
|
export function ansiBgOpen(th: { bg?(color: string, text: string): string } | null, color: string): string {
|
|
3
5
|
if (!th?.bg) return "";
|
|
@@ -10,15 +12,26 @@ export function ansiBgOpen(th: { bg?(color: string, text: string): string } | nu
|
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
// --- shared ANSI + theme helpers (single definition, all files use these) ---
|
|
16
|
+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
17
|
+
|
|
18
|
+
/** Drop every ANSI CSI sequence (colors and cursor moves), not just SGR colors. */
|
|
19
|
+
export function stripAnsi(s: string): string {
|
|
20
|
+
return s.replace(ANSI_RE, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The active pi theme out of an event ctx (`ctx.ui.theme`), null when absent. */
|
|
24
|
+
export function themeOf<T>(ctx: unknown): T | null {
|
|
25
|
+
return (ctx as { ui?: { theme?: T } } | null | undefined)?.ui?.theme ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
// Self-check: `node lib/ansi.ts`
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
assert(ansiBgOpen(null, "selectedBg") === "", "tanpa tema: kosong");
|
|
22
|
-
assert(ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;9;9;9m${t}\x1b[49m` }, "selectedBg") === "\x1b[48;2;9;9;9m", "probe bg tanpa reset");
|
|
29
|
+
if (isMain(import.meta.url)) {
|
|
30
|
+
assert(ansiBgOpen(null, "selectedBg") === "", "no theme: empty");
|
|
31
|
+
assert(ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;9;9;9m${t}\x1b[49m` }, "selectedBg") === "\x1b[48;2;9;9;9m", "bg probe without reset");
|
|
32
|
+
assert(stripAnsi("\x1b[2mA\x1b[22m") === "A", "stripAnsi removes SGR colors");
|
|
33
|
+
assert(stripAnsi("x\x1b[K") === "x", "stripAnsi removes non-SGR CSI too");
|
|
34
|
+
assert(themeOf({ ui: { theme: "T" } }) === "T", "themeOf reads ctx.ui.theme");
|
|
35
|
+
assert(themeOf(null) === null, "themeOf tolerates a missing ctx");
|
|
23
36
|
console.log("lib/ansi.ts OK");
|
|
24
37
|
}
|
package/lib/box.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* never loads it as an extension.
|
|
5
5
|
*/
|
|
6
6
|
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
import { assert, isMain } from "./check.ts";
|
|
7
8
|
|
|
8
9
|
export type BoxTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
|
|
9
10
|
export type Dim = (s: string) => string;
|
|
@@ -41,19 +42,13 @@ export function renderBoxLines(theme: BoxTheme, width: number, rows: string[], b
|
|
|
41
42
|
return lines;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
if (
|
|
45
|
-
const assert = (cond: boolean, msg: string) => {
|
|
46
|
-
if (!cond) {
|
|
47
|
-
console.error(`FAIL: ${msg}`);
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
};
|
|
45
|
+
if (isMain(import.meta.url)) {
|
|
51
46
|
const th: BoxTheme = { fg: (_c, t) => t };
|
|
52
|
-
const plain = renderBoxLines(th, 24, ["
|
|
53
|
-
assert(plain[0]!.startsWith("╭") && plain[plain.length - 1]!.startsWith("╰"), "
|
|
54
|
-
assert(plain.every((l) => visibleWidth(l) === 24), "
|
|
55
|
-
assert(plain[1] === "│
|
|
56
|
-
assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "
|
|
57
|
-
assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg
|
|
47
|
+
const plain = renderBoxLines(th, 24, ["hello", "a rather long world"]);
|
|
48
|
+
assert(plain[0]!.startsWith("╭") && plain[plain.length - 1]!.startsWith("╰"), "top/bottom frame intact");
|
|
49
|
+
assert(plain.every((l) => visibleWidth(l) === 24), "every line is 24 wide");
|
|
50
|
+
assert(plain[1] === "│ hello │", "content line padded flush");
|
|
51
|
+
assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "long line wrapped");
|
|
52
|
+
assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg ignored when the theme has no bg");
|
|
58
53
|
console.log("lib/box.ts OK");
|
|
59
54
|
}
|
package/lib/check.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Self-check helpers shared by every module's `node <file>` block.
|
|
2
|
+
// Lives outside `extensions/*.ts` so pi never loads it as an extension.
|
|
3
|
+
export function assert(cond: boolean, msg: string): asserts cond {
|
|
4
|
+
if (!cond) {
|
|
5
|
+
console.error(`FAIL: ${msg}`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isMain(url: string): boolean {
|
|
11
|
+
return Boolean(process.argv[1] && url.endsWith(process.argv[1].split("\\").join("/")));
|
|
12
|
+
}
|