@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.
@@ -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 only wraps the selection in reverse video (\x1b[7m), which
7
- * is unreadable in some terminals/themes. Replaced with explicit colors
8
- * (bg selectedBg + fg text) so text always stays visible.
9
- * 3. Compaction message: pi's built-in is label + blank line + text (3 lines);
10
- * we squeeze it into one line: `[compaction] Compacted from N tokens (ctrl+o to expand)`.
11
- * 4. Custom `pi-jev-eye-review` message: pi adds a `[pi-jev-eye-review]` label +
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
- export { ansiBgOpen };
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). Switched to accent bg + darkened canvas text (contrast 9.6:1, WCAG AA).
89
- // Note: pi only has 7 background tokens (selectedBg/userMessageBg/customMessageBg/
90
- // toolPendingBg/toolSuccessBg/toolErrorBg/searchMatchBg) — `accent` is a text
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 tak ada di tema -> biarkan bawaan pi
108
- return `${accentBg}${inkFg}${text.replace(/\x1b\[[0-9;]*m/g, "")}\x1b[39m\x1b[49m`;
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 = ((ctx as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme) ?? 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", expandKey())}${th.fg(
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
- const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
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 reload terdeteksi");
341
- assert(shouldHideReloadBox(["hello", "world"]) === false, "baris biasa lolos");
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: buka dgn fg+bg, tutup dgn reset fg+bg",
328
+ "recolorSelection: open with fg+bg, close with fg+bg reset",
347
329
  );
348
- assert(recolorSelection("plain", "<F>", "<B>") === "plain", "tanpa reverse video: tak berubah");
349
- assert(ansiFgOpen(null) === "", "tanpa tema: fgOpen kosong");
350
- assert(ansiFgOpen({ fg: (_c, t) => `\x1b[38;2;1;2;3m${t}\x1b[39m` }) === "\x1b[38;2;1;2;3m", "fgOpen terambil dari probe tema");
351
- assert(ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;9;9;9m${t}\x1b[49m` }, "selectedBg") === "\x1b[48;2;9;9;9m", "bgOpen selectedBg terambil");
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 temaJson = JSON.parse(readFileSync(new URL("../themes/arnative.json", import.meta.url), "utf8")) as {
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) => temaJson.vars[temaJson.colors[tok] ?? tok];
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 fgPalet = Object.fromEntries(Object.entries(temaJson.colors).map(([k, v]) => [k, temaJson.vars[v] ?? v]));
364
- const thPil = new Theme(fgPalet, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
365
- const pilAsli = thPil.bg("selectedBg", thPil.fg("text", " ↓ Jump to latest message · End "));
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(pilAsli, thPil) ===
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
- "pil: bg aksen + teks kanvas, ANSI lama dibuang",
352
+ "pill: accent bg + canvas text, old ANSI dropped",
371
353
  );
372
- let ditolak = false;
354
+ let rejected = false;
373
355
  try {
374
- thPil.bg("accent", "x");
356
+ thPill.bg("accent", "x");
375
357
  } catch {
376
- ditolak = true;
358
+ rejected = true;
377
359
  }
378
- assert(ditolak, "pi memang menolak bg('accent') -> arah SGR harus ditukar");
379
- assert(accentPill("teks lain", thPil) === "teks lain", "pil: teks non-pil tak disentuh");
380
- assert(accentPill(pilAsli, null) === pilAsli, "pil: tanpa tema -> apa adanya");
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 dirTema = new URL("../themes/", import.meta.url);
388
- const wajib = Object.keys(temaJson.colors);
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 bukanTeks = new Set([
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 sekunder = new Set(["muted", "dim", "mdQuote", "mdHr", "mdLinkUrl", "toolOutput", "toolDiffContext", "thinkingText", "syntaxComment"]);
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 luminansi = (h: string) => {
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 kontras = (a: string, b: string) => {
408
- const [x, y] = [luminansi(a), luminansi(b)].sort((m, n) => n - m);
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(dirTema).filter((n) => n.endsWith(".json")).sort()) {
412
- const d = JSON.parse(readFileSync(new URL(f, dirTema), "utf8")) as {
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$/, ""), `tema ${f}: name = nama berkas`);
418
- const kurang = wajib.filter((k) => !(k in d.colors));
419
- assert(kurang.length === 0, `tema ${f}: warna wajib lengkap (kurang ${kurang.join(",")})`);
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, nilai] of Object.entries(d.colors)) {
423
- if (typeof nilai !== "string") continue;
424
- if (nilai === "" || /^#[0-9a-f]{6}$/i.test(nilai) || nilai in d.vars) continue;
425
- assert(false, `tema ${f}: colors.${tok} = "${nilai}" bukan hex/var`);
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)), `tema ${f}: ${tok} hex (${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(kontras(hex("accent"), hex("toolPendingBg")) >= 4.5, `tema ${f}: kontras accent vs toolPendingBg >= 4.5`);
433
- assert(/^#[0-9a-f]{6}$/i.test(d.vars.softCyan ?? ""), `tema ${f}: vars.softCyan (tint) hex`);
434
- const kanvas = hex("userMessageBg");
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 (bukanTeks.has(tok) || tok === "thinkingMax") continue;
437
- const warna = hex(tok);
438
- if (!/^#[0-9a-f]{6}$/i.test(warna)) continue; // indeks 256 warna: biarkan pi yang menilai
439
- const batas = ramp[tok] ?? (sekunder.has(tok) ? 2.0 : 3.0);
440
- const c = kontras(warna, kanvas);
441
- assert(c >= batas, `tema ${f}: kontras ${tok} vs kanvas ${c.toFixed(2)} < ${batas}`);
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 = kontras(hex(fg), hex(bg));
451
- assert(c >= 4.5, `tema ${f}: kontras ${fg} di ${bg} ${c.toFixed(2)} < 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] = [luminansi(hex("toolErrorBg")), luminansi(hex("toolSuccessBg"))];
455
- assert(Math.abs(lErr - lOk) / Math.max(lErr, lOk) <= 0.25, `tema ${f}: bobot toolErrorBg vs toolSuccessBg (${lErr.toFixed(3)} vs ${lOk.toFixed(3)})`);
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"), `tema ${f}: scrollbarThumb masih sama dengan scrollbarTrack`);
458
- assert(hex("searchMatchBg") !== hex("selectedBg"), `tema ${f}: searchMatchBg masih sama dengan 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 thKosong = new Theme(
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(pilAsli, thKosong) === pilAsli, "pil: token absen -> apa adanya");
466
- const hostPil: { scrollToEndIndicator?: () => string } = { scrollToEndIndicator: () => pilAsli };
467
- const dalamPil = withAccentPill(hostPil, () => hostPil.scrollToEndIndicator?.(), thPil);
468
- assert(dalamPil.includes("\x1b[48;2;0;215;255m"), "pil: callback instance disisipi bg aksen");
469
- assert(hostPil.scrollToEndIndicator?.() === pilAsli, "pil: callback asli dipulihkan setelah render");
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").replace(/\x1b\[[0-9;]*m/g, "") ===
455
+ stripAnsi(compactionLine({ fg: (_c, t) => t }, "112,247")) ===
474
456
  "[compaction] Compacted from 112,247 tokens (ctrl+o to expand)",
475
- "compaction: label + teks + petunjuk expand dalam satu baris (tanpa baris kosong)",
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- Bahasa ikut user") ===
481
- "[pi-jev-eye] Reviewed turn contract:\n- Bahasa ikut user",
482
- "isi pesan custom utuh",
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
- "blok non-teks dibuang",
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 + tema internal pi (di sesi nyata sudah aktif)
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: "**Ringkasan** panjang\n\n- satu" },
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: tepat satu baris",
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("Ringkasan"),
532
- "compaction expanded: label + ringkasan markdown tetap ada",
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- Bahasa ikut user" },
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
- "pesan kontrak collapsed: kotak border dengan teks [click to expand]",
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-dua"], {
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("╭"), "kotak pembaruan: bingkai bulat + warna warning");
551
- assert(upd[upd.length - 1]!.includes("╰"), "kotak pembaruan: bingkai bawah");
552
- assert(upd[1]!.includes("Package Updates Available"), "judul pembaruan di dalam kotak");
553
- assert(upd[1]!.includes("\uf449"), "ikon \uf449 pada judul pembaruan");
554
- assert(upd.some((l: string) => l.includes("- github.com/bismawy/pi-arnative")), "daftar paket di dalam kotak");
555
- assert(upd.every((l: string) => visibleWidth(l) === 60), "kotak pembaruan selebar 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, "klik kiri handled");
561
- assert(contractCollapsed.isExpanded === true, "klik kiri toggle expanded");
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("Bahasa ikut user")), "setelah klik: isi lengkap tampil");
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- Bahasa ikut user" },
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("Bahasa ikut user")),
573
- "pesan kontrak expanded: kotak border dengan isi lengkap",
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
  }
@@ -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; the old API is still re-exported here (compatibility).
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: "Dashboard penggunaan token & model Arnative",
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
- const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
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 mendeteksi providers");
285
- assert(summary.totals.sessions > 0, "collectUsageSummary mendeteksi total sessions");
286
- assert(summary.totals.msgs > 0, "collectUsageSummary mendeteksi total msgs");
287
- assert(summary.totals.cost >= 0, "collectUsageSummary menghitung cost");
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 mengambil input tokens gemini-3.8-flash");
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 modal menghasilkan baris-baris tabel");
299
- assert(lines[0]!.includes("╭") && lines[0]!.includes("╮"), "border atas membulat");
300
- assert(visibleWidth(lines[0]!) === 100, "border kotak full width (100 kolom)");
301
- assert(lines[1]!.includes("LLM Usage"), "judul LLM Usage di dalam kotak");
302
- assert(lines[2]!.includes("sessions"), "deskripsi di bawah judul");
303
- assert(lines[lines.length - 1]!.includes("╰") && lines[lines.length - 1]!.includes("╯"), "border bawah membulat");
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
- "semua baris muat dalam lebar 100",
298
+ "every line fits within width 100",
307
299
  );
308
300
  assert(
309
301
  lines.every((l) => ["╭", "│", "├", "╰"].some((c) => l.includes(c))),
310
- "tidak ada baris tanpa sisi kotak (garis aksen hilang)",
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 (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"))) {
15
- const assert = (cond: boolean, msg: string) => {
16
- if (!cond) {
17
- console.error(`FAIL: ${msg}`);
18
- process.exit(1);
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 (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"))) {
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, ["halo", "dunia yang panjang sekali"]);
53
- assert(plain[0]!.startsWith("╭") && plain[plain.length - 1]!.startsWith("╰"), "bingkai atas/bawah utuh");
54
- assert(plain.every((l) => visibleWidth(l) === 24), "semua baris selebar 24");
55
- assert(plain[1] === "│ halo │", "baris isi dipad rata");
56
- assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "baris panjang di-wrap");
57
- assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg diabaikan saat tema tanpa 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
+ }