@bismawy/pi-arnative 0.2.12 → 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.
@@ -101,9 +101,8 @@ export function placeTimeAtRight(
101
101
  const leftPart = sliceByColumn(line, 0, targetCol, true);
102
102
  const leftW = visibleWidth(leftPart);
103
103
  const pad = " ".repeat(Math.max(0, targetCol - leftW));
104
- const bg = bgOpen ? bgOpen : "";
105
104
  const reset = bgOpen ? "\x1b[49m" : "";
106
- return `${leftPart}${bg}${pad}${timeBadge} ${reset}`;
105
+ return `${leftPart}${bgOpen}${pad}${timeBadge} ${reset}`;
107
106
  }
108
107
 
109
108
  if (!tsState.patched) {
@@ -211,6 +210,9 @@ export default function (pi: ExtensionAPI) {
211
210
  }
212
211
  });
213
212
 
213
+ // The map is cleared per session (it is refilled from the branch right below),
214
+ // so it is bounded by the current session instead of growing for the process lifetime.
215
+ USER_TIMESTAMPS_MAP.clear();
214
216
  // The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
215
217
  // History for old user messages is read HERE, never at render time: the ctx is only
216
218
  // valid inside a handler. A ctx captured in the module throws after session
@@ -285,6 +287,7 @@ if (isMain(import.meta.url)) {
285
287
  },
286
288
  );
287
289
  assert(USER_TIMESTAMPS_MAP.get("after reload") === 4242, "reloaded module prefills the shared map read by the patched render");
290
+ assert(!USER_TIMESTAMPS_MAP.has("old question"), "session_start clears the map first (bounded growth, current session only)");
288
291
  assert(tsState.theme === theme, "reloaded module shares the theme read by the patched render");
289
292
  console.log("timestamps.ts self-check OK");
290
293
  }
@@ -11,7 +11,7 @@
11
11
  * Collapsed summary: bash/write `󱞩 first line`; grep/find/read numeric
12
12
  * (→ N matches / → N files / N lines); edit `󱞩 +N / -M`. Expanded plain output
13
13
  * gets no summary line (no duplicated first line).
14
- * Colors: tool name accent, paths/links tint, box lines + 󱞩 dim. Execution is a
14
+ * Colors: tool name accent, paths/links soft, box lines + 󱞩 dim. Execution is a
15
15
  * pure delegate (spread of the built-in tools).
16
16
  * "has a result" lives in context.state, read at render() -> restore/reload safe,
17
17
  * no stale 󰔟 box. Third-party tools with no renderer of their own (memory_write,
@@ -32,14 +32,14 @@ import {
32
32
  import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
33
33
  import { homedir } from "node:os";
34
34
  import { renderBoxLines as box } from "../lib/box.ts";
35
- import { stripAnsi } from "../lib/ansi.ts";
35
+ import { stripAnsi, accentSoftOf } from "../lib/ansi.ts";
36
36
  import { assert, isMain } from "../lib/check.ts";
37
37
  import { expandKeyName } from "../lib/format.ts";
38
38
 
39
39
  type Theme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
40
40
  type TResult = { content: Array<{ type: string; text?: string }>; isError?: boolean; details?: any };
41
41
  type TCtx = { isError?: boolean; toolCallId?: string; args?: any; state?: Record<string, unknown> };
42
- type Res = (r: TResult, th: Theme, tint: (s: string) => string, isErr: boolean, expanded: boolean) => string;
42
+ type Res = (r: TResult, th: Theme, soft: (s: string) => string, isErr: boolean, expanded: boolean) => string;
43
43
 
44
44
  const cwd = process.cwd();
45
45
  const TIMINGS = new Map<string, number>(); // duration ms per tool call (one run)
@@ -97,20 +97,10 @@ const countLines = (s: string): number => (s ? s.split("\n").filter(Boolean).len
97
97
 
98
98
  // Tint folder paths / URLs; everything else default.
99
99
  export const LINK_RE = /(?:https?:\/\/[^\s"'`)}\]]+|(?:~|\.{1,2})?\/[\w.+@~%/-]+|[\w.+@~-]+(?:\/[\w.+@~%/-]+)+)/g;
100
- export function paintLinks(text: string, tint: (s: string) => string): string {
101
- return text.replace(LINK_RE, (m) => tint(m));
100
+ export function paintLinks(text: string, soft: (s: string) => string): string {
101
+ return text.replace(LINK_RE, (m) => soft(m));
102
102
  }
103
103
 
104
- // "tint" only exists in arnative themes; other themes fall back to accent (probed per render)
105
- const tintOf = (theme: Theme): ((s: string) => string) => {
106
- try {
107
- theme.fg("tint", "");
108
- return (s) => theme.fg("tint", s);
109
- } catch {
110
- return (s) => theme.fg("accent", s);
111
- }
112
- };
113
-
114
104
  // Word-limited smart title: `maxWords` words + ellipsis; full text when expanded.
115
105
  export function smartTitle(cmd: string, maxWords = 6): string {
116
106
  const words = cmd.replace(/\r?\n/g, " ").trim().split(/\s+/).filter(Boolean);
@@ -150,17 +140,17 @@ export function titleRow(
150
140
 
151
141
  // Default summary: collapsed `󱞩 first output line`, empty when expanded (the
152
142
  // full output is right below — no duplicate first line).
153
- export const resText: Res = (r, th, tint, isErr, expanded) => {
143
+ export const resText: Res = (r, th, soft, isErr, expanded) => {
154
144
  if (expanded && !isErr) return "";
155
145
  const f = firstLine(textOf(r));
156
- return f ? `${resHead(th)} ${paintLinks(f, tint)}` : resHead(th);
146
+ return f ? `${resHead(th)} ${paintLinks(f, soft)}` : resHead(th);
157
147
  };
158
148
 
159
149
  // Numeric summary `󱞩 → N matches`; errors fall back to the first line (most important).
160
150
  export const numRes =
161
151
  (fmt: (n: number) => string): Res =>
162
- (r, th, tint, isErr, expanded) => {
163
- if (isErr) return resText(r, th, tint, isErr, false);
152
+ (r, th, soft, isErr, expanded) => {
153
+ if (isErr) return resText(r, th, soft, isErr, false);
164
154
  if (expanded) return "";
165
155
  return `${resHead(th)} ${fmt(countLines(textOf(r)))}`;
166
156
  };
@@ -394,7 +384,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
394
384
  ? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
395
385
  : null;
396
386
  return new Lines((width) => {
397
- const tint = tintOf(th);
387
+ const soft = accentSoftOf(th);
398
388
  const inner = Math.max(8, width - 4);
399
389
  const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
400
390
  const boxed2 = componentLines(theirs, width - 6);
@@ -422,7 +412,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
422
412
  const summary = toolSummary(name, text, ctx.args);
423
413
  const more = countLines(text) > 1;
424
414
  const hint = more && !opts.expanded ? ` ${expandHint(th)}` : "";
425
- rows.push(`${resHead(th)}${summary ? ` ${paintLinks(summary, tint)}` : ""}${hint}`);
415
+ rows.push(`${resHead(th)}${summary ? ` ${paintLinks(summary, soft)}` : ""}${hint}`);
426
416
  if (opts.expanded && more) {
427
417
  // MCP args are gone once the call box is replaced by the result.
428
418
  if (MCP_TOOL(name)) rows.push(th.fg("dim", `args ${JSON.stringify(ctx.args ?? {})}`));
@@ -460,8 +450,8 @@ const formatTodoRows = (lines: string[], th: Theme): string[] => {
460
450
  function minimal(
461
451
  pi: ExtensionAPI,
462
452
  tool: { execute: (...a: any[]) => Promise<any> } & Record<string, unknown>,
463
- name: (th: Theme, tint: (s: string) => string) => string,
464
- call: (a: any, th: Theme, tint: (s: string) => string, expanded: boolean) => string,
453
+ name: (th: Theme, soft: (s: string) => string) => string,
454
+ call: (a: any, th: Theme, soft: (s: string) => string, expanded: boolean) => string,
465
455
  res: Res = resText,
466
456
  full: (r: TResult, th: Theme) => string[] = fullText,
467
457
  ): void {
@@ -485,13 +475,13 @@ function minimal(
485
475
  return new Lines((width) => {
486
476
  // Read context.state live (never capture): renderResult may create it after renderCall.
487
477
  if (context.state?.hasResult) return [];
488
- const tint = tintOf(theme);
478
+ const soft = accentSoftOf(theme);
489
479
  const inner = Math.max(8, width - 4);
490
480
  const rows = titleRow(
491
481
  theme,
492
482
  theme.fg("warning", spinIcon()),
493
- name(theme, tint),
494
- call(args, theme, tint, false),
483
+ name(theme, soft),
484
+ call(args, theme, soft, false),
495
485
  inner,
496
486
  "",
497
487
  false,
@@ -513,18 +503,18 @@ function minimal(
513
503
  const dur = ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : "";
514
504
  const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
515
505
  return new Lines((width) => {
516
- const tint = tintOf(theme);
506
+ const soft = accentSoftOf(theme);
517
507
  const inner = Math.max(8, width - 4);
518
508
  const rows = titleRow(
519
509
  theme,
520
510
  icon,
521
- name(theme, tint),
522
- call(context.args ?? {}, theme, tint, expanded),
511
+ name(theme, soft),
512
+ call(context.args ?? {}, theme, soft, expanded),
523
513
  inner,
524
514
  dur,
525
515
  expanded,
526
516
  );
527
- const resLine = res(result, theme, tint, isErr, expanded);
517
+ const resLine = res(result, theme, soft, isErr, expanded);
528
518
  if (resLine) rows.push(resLine);
529
519
  if (expanded) rows.push(...full(result, theme));
530
520
  return box(theme, width, rows);
@@ -559,9 +549,9 @@ function arnativeTools(pi: ExtensionAPI) {
559
549
  pi,
560
550
  createBashTool(cwd),
561
551
  (th) => th.fg("accent", "$"),
562
- (a, _th, tint, expanded) => {
552
+ (a, _th, soft, expanded) => {
563
553
  const cmd = (a.command ?? "").replace(/\r?\n/g, " ").trim();
564
- return paintLinks(expanded ? cmd : smartTitle(cmd), tint);
554
+ return paintLinks(expanded ? cmd : smartTitle(cmd), soft);
565
555
  },
566
556
  );
567
557
 
@@ -570,10 +560,10 @@ function arnativeTools(pi: ExtensionAPI) {
570
560
  pi,
571
561
  createReadTool(cwd),
572
562
  (th) => th.fg("accent", "read"),
573
- (a, _th, tint) => {
563
+ (a, _th, soft) => {
574
564
  const range =
575
565
  a.offset || a.limit ? `:${a.offset ?? 1}${a.limit ? `-${(a.offset ?? 1) + a.limit - 1}` : ""}` : "";
576
- return `${tint(shortPath(a.path ?? ""))}${range}`;
566
+ return `${soft(shortPath(a.path ?? ""))}${range}`;
577
567
  },
578
568
  numRes((n) => `${n} lines`),
579
569
  );
@@ -583,8 +573,8 @@ function arnativeTools(pi: ExtensionAPI) {
583
573
  pi,
584
574
  createGrepTool(cwd),
585
575
  (th) => th.fg("accent", "grep"),
586
- (a, _th, tint) =>
587
- `/${a.pattern ?? ""}/ in ${tint(shortPath(a.path ?? "."))}${a.glob ? ` (${a.glob})` : ""}`,
576
+ (a, _th, soft) =>
577
+ `/${a.pattern ?? ""}/ in ${soft(shortPath(a.path ?? "."))}${a.glob ? ` (${a.glob})` : ""}`,
588
578
  numRes((n) => `→ ${n} matches`),
589
579
  );
590
580
 
@@ -593,7 +583,7 @@ function arnativeTools(pi: ExtensionAPI) {
593
583
  pi,
594
584
  createFindTool(cwd),
595
585
  (th) => th.fg("accent", "find"),
596
- (a, _th, tint) => `${a.pattern ?? ""} in ${tint(shortPath(a.path ?? "."))}`,
586
+ (a, _th, soft) => `${a.pattern ?? ""} in ${soft(shortPath(a.path ?? "."))}`,
597
587
  numRes((n) => `→ ${n} files`),
598
588
  );
599
589
 
@@ -602,7 +592,7 @@ function arnativeTools(pi: ExtensionAPI) {
602
592
  pi,
603
593
  createWriteTool(cwd),
604
594
  (th) => th.fg("accent", "write"),
605
- (a, _th, tint) => tint(shortPath(a.path ?? "")),
595
+ (a, _th, soft) => soft(shortPath(a.path ?? "")),
606
596
  );
607
597
 
608
598
  // edit: `edit path`, `󱞩 +N / -M`, expand = diff toolDiff*
@@ -610,8 +600,8 @@ function arnativeTools(pi: ExtensionAPI) {
610
600
  pi,
611
601
  createEditTool(cwd),
612
602
  (th) => th.fg("accent", "edit"),
613
- (a, _th, tint) => tint(shortPath(a.path ?? "")),
614
- (r, th, _tint, isErr, expanded) => {
603
+ (a, _th, soft) => soft(shortPath(a.path ?? "")),
604
+ (r, th, _soft, isErr, expanded) => {
615
605
  if (isErr) return resText(r, th, (s) => s, isErr, false);
616
606
  const diff: string = r.details?.diff ?? "";
617
607
  let add = 0;
@@ -11,10 +11,13 @@
11
11
  * box; our renderer shows the content as-is.
12
12
  * 5. `↓ Jump to latest message` pill (fullscreen): bg selectedBg (grey) -> accent,
13
13
  * text darkened (theme canvas) so contrast stays high (9.6:1).
14
+ * 6. `ctx.ui.select` rows: pi paints the whole selected row accent -> keep accent on
15
+ * the `→` marker and `·` separators, selected text in soft.
14
16
  */
15
17
  import {
16
18
  CompactionSummaryMessageComponent,
17
19
  CustomMessageComponent,
20
+ ExtensionSelectorComponent,
18
21
  getMarkdownTheme,
19
22
  initTheme,
20
23
  InteractiveMode,
@@ -37,8 +40,9 @@ import {
37
40
  } from "@earendil-works/pi-tui";
38
41
  import { readdirSync, readFileSync } from "node:fs";
39
42
  import { renderBoxLines } from "../lib/box.ts";
40
- import { ansiBgOpen, stripAnsi, themeOf } from "../lib/ansi.ts";
43
+ import { ansiBgOpen, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
41
44
  import { assert, isMain } from "../lib/check.ts";
45
+ import { contrast, parseOklch, toHex } from "../lib/color.ts";
42
46
  import { expandKeyName } from "../lib/format.ts";
43
47
 
44
48
  let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
@@ -155,6 +159,43 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[PILL_KEY
155
159
  }
156
160
  }
157
161
 
162
+ // 6. Select menus (ctx.ui.select -> ExtensionSelectorComponent.updateList): pi colors the
163
+ // whole selected row accent. Arnative keeps accent on the `→` marker and `·` separators
164
+ // and moves the selected text to soft (accent on themes without the token).
165
+ const SELECT_ROW_KEY = Symbol.for("pi-arnative.selectRowColors");
166
+
167
+ /** Selected row body: `·` separators accent, the text around them soft. */
168
+ export function selectRowText(row: string, th: { fg(c: string, t: string): string }): string {
169
+ const soft = accentSoftOf(th);
170
+ return row
171
+ .split("·")
172
+ .map((part) => soft(part))
173
+ .join(th.fg("accent", "·"));
174
+ }
175
+
176
+ if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, boolean>)[SELECT_ROW_KEY]) {
177
+ (globalThis as Record<symbol, boolean>)[SELECT_ROW_KEY] = true;
178
+ const proto = ExtensionSelectorComponent.prototype as unknown as {
179
+ options: string[];
180
+ selectedIndex: number;
181
+ listContainer: { clear(): void; addChild(c: unknown): void };
182
+ updateList(): void;
183
+ };
184
+ const origUpdateList = proto.updateList;
185
+ proto.updateList = function (this: typeof proto) {
186
+ const th = activeThemeProxy;
187
+ if (!th) return origUpdateList.call(this); // no theme yet -> pi's default
188
+ this.listContainer.clear();
189
+ for (let i = 0; i < this.options.length; i++) {
190
+ const text =
191
+ i === this.selectedIndex
192
+ ? th.fg("accent", "→ ") + selectRowText(this.options[i]!, th)
193
+ : ` ${th.fg("text", this.options[i]!)}`;
194
+ this.listContainer.addChild(new Text(text, 1, 0));
195
+ }
196
+ };
197
+ }
198
+
158
199
  export default function uiRenderTweaks(pi: ExtensionAPI) {
159
200
  // The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
160
201
  pi.on("session_start", async (_event, ctx) => {
@@ -339,10 +380,13 @@ if (isMain(import.meta.url)) {
339
380
  vars: Record<string, string>;
340
381
  colors: Record<string, string>;
341
382
  };
342
- const hex = (tok: string) => themeJson.vars[themeJson.colors[tok] ?? tok];
383
+ // Themes store OKLCH; the Theme class shipped in node_modules (0.87.1) only parses
384
+ // hex, so resolve every token through lib/color before constructing it.
385
+ const resolve = (value: string) => toHex(themeJson.vars[value] ?? value);
386
+ const hex = (tok: string) => resolve(themeJson.colors[tok] ?? tok);
343
387
  // The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
344
388
  // so the fg map must be complete; take the whole palette like pi's loader does.
345
- const fgPalette = Object.fromEntries(Object.entries(themeJson.colors).map(([k, v]) => [k, themeJson.vars[v] ?? v]));
389
+ const fgPalette = Object.fromEntries(Object.entries(themeJson.colors).map(([k, v]) => [k, resolve(v)]));
346
390
  const thPill = new Theme(fgPalette, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
347
391
  const pillOriginal = thPill.bg("selectedBg", thPill.fg("text", " ↓ Jump to latest message · End "));
348
392
  const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
@@ -361,6 +405,14 @@ if (isMain(import.meta.url)) {
361
405
  assert(accentPill("other text", thPill) === "other text", "pill: non-pill text untouched");
362
406
  assert(accentPill(pillOriginal, null) === pillOriginal, "pill: no theme -> passed through");
363
407
 
408
+ // select rows: `→` + `·` stay accent, selected text moves to accentSoft
409
+ const tagFg = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
410
+ assert(
411
+ selectRowText("Routing · off", tagFg) === "<accentSoft>Routing </accentSoft><accent>·</accent><accentSoft> off</accentSoft>",
412
+ "select row: separators accent, text accentSoft",
413
+ );
414
+ assert(selectRowText("plain", tagFg) === "<accentSoft>plain</accentSoft>", "select row without separators: all accentSoft");
415
+
364
416
  // Theme variants: every themes/*.json must be valid — name = file name, all
365
417
  // required colors present, valid hex, and contrast measured against that theme's
366
418
  // own canvas (userMessageBg): content >= 3.0, secondary text >= 2.0, thinking
@@ -380,16 +432,7 @@ if (isMain(import.meta.url)) {
380
432
  thinkingOff: 1.3, thinkingMinimal: 1.3, thinkingLow: 2.0,
381
433
  thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
382
434
  };
383
- const luminance = (h: string) => {
384
- const ch = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) =>
385
- c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
386
- );
387
- return 0.2126 * ch[0]! + 0.7152 * ch[1]! + 0.0722 * ch[2]!;
388
- };
389
- const contrast = (a: string, b: string) => {
390
- const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
391
- return (x! + 0.05) / (y! + 0.05);
392
- };
435
+ const isColor = (v: string) => /^#[0-9a-f]{6}$/i.test(v) || parseOklch(v) !== null;
393
436
  for (const f of readdirSync(themesDir).filter((n) => n.endsWith(".json")).sort()) {
394
437
  const d = JSON.parse(readFileSync(new URL(f, themesDir), "utf8")) as {
395
438
  name: string;
@@ -399,29 +442,45 @@ if (isMain(import.meta.url)) {
399
442
  assert(d.name === f.replace(/\.json$/, ""), `theme ${f}: name = file name`);
400
443
  const missing = required.filter((k) => !(k in d.colors));
401
444
  assert(missing.length === 0, `theme ${f}: required colors complete (missing ${missing.join(",")})`);
402
- // colors values may only be: hex, 256-index, empty, or a REAL variable name —
445
+ // colors values may only be: hex, oklch, 256-index, empty, or a REAL variable name —
403
446
  // a bare color name (e.g. "muted") makes pi reject the theme with no clear reason.
404
447
  for (const [tok, value] of Object.entries(d.colors)) {
405
448
  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`);
449
+ if (value === "" || isColor(value) || value in d.vars) continue;
450
+ assert(false, `theme ${f}: colors.${tok} = "${value}" is not hex/oklch/var`);
451
+ }
452
+ // Every var must itself be a real color, and no two vars may resolve to the
453
+ // same value — near-identical hand-written hex was the original problem.
454
+ for (const [name, value] of Object.entries(d.vars)) {
455
+ assert(isColor(value), `theme ${f}: vars.${name} is a color (${value})`);
408
456
  }
409
- const hex = (tok: string) => d.vars[d.colors[tok] ?? tok] ?? d.colors[tok];
457
+ const seenVar = new Map<string, string>();
458
+ for (const [name, value] of Object.entries(d.vars)) {
459
+ const h = toHex(value);
460
+ assert(!seenVar.has(h), `theme ${f}: vars.${name} duplicates vars.${seenVar.get(h)} (${h})`);
461
+ seenVar.set(h, name);
462
+ }
463
+ const hex = (tok: string) => toHex(d.vars[d.colors[tok] ?? tok] ?? d.colors[tok]!);
410
464
  for (const tok of ["accent", "text", "selectedBg", "toolPendingBg"]) {
411
- assert(/^#[0-9a-f]{6}$/i.test(hex(tok)), `theme ${f}: ${tok} is hex (${hex(tok)})`);
465
+ assert(isColor(d.vars[d.colors[tok] ?? tok] ?? d.colors[tok]!), `theme ${f}: ${tok} is a color (${hex(tok)})`);
412
466
  }
413
467
  // Accent pill: accent bg + toolPendingBg colored text.
414
468
  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`);
469
+ assert(isColor(d.vars.accentSoft ?? ""), `theme ${f}: vars.accentSoft is a color`);
416
470
  const canvas = hex("userMessageBg");
417
471
  for (const tok of Object.keys(d.colors)) {
418
472
  if (nonText.has(tok) || tok === "thinkingMax") continue;
419
473
  const color = hex(tok);
420
- if (!/^#[0-9a-f]{6}$/i.test(color)) continue; // 256-color index: let pi judge it
421
474
  const limit = ramp[tok] ?? (secondary.has(tok) ? 2.0 : 3.0);
422
475
  const c = contrast(color, canvas);
423
476
  assert(c >= limit, `theme ${f}: ${tok} vs canvas contrast ${c.toFixed(2)} < ${limit}`);
424
477
  }
478
+ // Thinking ramp must rise monotonically (Off < Minimal < … < Xhigh).
479
+ const rampKeys = ["thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh"];
480
+ for (let i = 1; i < rampKeys.length; i++) {
481
+ const [lo, hi] = [hex(rampKeys[i - 1]!), hex(rampKeys[i]!)];
482
+ assert(contrast(lo, canvas) < contrast(hi, canvas), `theme ${f}: ${rampKeys[i]} is brighter than ${rampKeys[i - 1]}`);
483
+ }
425
484
  // Tool box text must be readable on the success background AND in errors, and
426
485
  // the error text in the error box — three pairs that used to pass (3.48-4.46)
427
486
  // without being caught.
@@ -433,8 +492,13 @@ if (isMain(import.meta.url)) {
433
492
  assert(c >= 4.5, `theme ${f}: ${fg} on ${bg} contrast ${c.toFixed(2)} < 4.5`);
434
493
  }
435
494
  // The error box must not be heavier than the success box (matching background luminance).
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)})`);
495
+ const [lErr, lOk] = [hex("toolErrorBg"), hex("toolSuccessBg")].map((h) => {
496
+ const ch = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) =>
497
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
498
+ );
499
+ return 0.2126 * ch[0]! + 0.7152 * ch[1]! + 0.0722 * ch[2]!;
500
+ });
501
+ assert(Math.abs(lErr! - lOk!) / Math.max(lErr!, lOk!) <= 0.25, `theme ${f}: toolErrorBg vs toolSuccessBg weight (${lErr!.toFixed(3)} vs ${lOk!.toFixed(3)})`);
438
502
  // Scrollbar thumb & highlight must be distinguishable from track/selection (they used to be identical).
439
503
  assert(hex("scrollbarThumb") !== hex("scrollbarTrack"), `theme ${f}: scrollbarThumb still equals scrollbarTrack`);
440
504
  assert(hex("searchMatchBg") !== hex("selectedBg"), `theme ${f}: searchMatchBg still equals selectedBg`);
@@ -447,7 +511,7 @@ if (isMain(import.meta.url)) {
447
511
  assert(accentPill(pillOriginal, thEmpty) === pillOriginal, "pill: token absent -> passed through");
448
512
  const hostPil: { scrollToEndIndicator?: () => string } = { scrollToEndIndicator: () => pillOriginal };
449
513
  const inPill = withAccentPill(hostPil, () => hostPil.scrollToEndIndicator?.(), thPill);
450
- assert(inPill.includes("\x1b[48;2;0;215;255m"), "pill: instance callback injected with accent bg");
514
+ assert(inPill.includes(`\x1b[48;2;${rgb(hex("accent"))}m`), "pill: instance callback injected with accent bg");
451
515
  assert(hostPil.scrollToEndIndicator?.() === pillOriginal, "pill: original callback restored after render");
452
516
 
453
517
  // compaction: pi's three lines -> one
@@ -4,6 +4,9 @@
4
4
  * shared with the Model tab.
5
5
  */
6
6
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
7
10
  import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
8
11
  import { boxEdge, boxRow } from "../lib/box.ts";
9
12
  import { assert, isMain } from "../lib/check.ts";
@@ -12,7 +15,11 @@ import { collectUsageSummary, formatTokens, getModelAllTimeUsage, type UsageSumm
12
15
  export function formatCost(n: number): string {
13
16
  if (!n || n <= 0) return "-";
14
17
  if (n < 0.01) return "<$0.01";
15
- if (n < 1000) return `$${n.toFixed(2)}`;
18
+ if (n < 1000) {
19
+ // Rounding at the boundary used to print "$1000.00" instead of "$1.0k".
20
+ const s = n.toFixed(2);
21
+ return s === "1000.00" ? "$1.0k" : `$${s}`;
22
+ }
16
23
  return `$${(n / 1000).toFixed(1)}k`;
17
24
  }
18
25
 
@@ -147,9 +154,9 @@ export class UsageModalComponent implements Component {
147
154
  // arnative chat-column box wraps title + table + hint, full width.
148
155
  // Round ends (╭─╮ / ╰─╯); sides + divider from lib/box.ts (single definition).
149
156
  const boxW = width;
150
- const tint = (s: string) => th.fg("tint", s);
151
- const hline = (l: string, r: string) => boxEdge(l, r, boxW, tint);
152
- const row = (text: string) => boxRow(text, boxW, tint);
157
+ const soft = (s: string) => th.fg("accentSoft", s);
158
+ const hline = (l: string, r: string) => boxEdge(l, r, boxW, soft);
159
+ const row = (text: string) => boxRow(text, boxW, soft);
153
160
  const rule = () => hline("├", "┤");
154
161
 
155
162
  const rows = this.buildFlatRows();
@@ -208,7 +215,7 @@ export class UsageModalComponent implements Component {
208
215
  const actualIdx = startIdx + i;
209
216
  const isSelected = actualIdx === this.selectedIndex;
210
217
 
211
- const nameColor = isSelected ? "accent" : item.type === "provider" ? "tint" : "text";
218
+ const nameColor = isSelected ? "accent" : item.type === "provider" ? "accentSoft" : "text";
212
219
  const valColor = isSelected ? "accent" : "dim";
213
220
 
214
221
  const cName = formatCol(th.fg(nameColor, isSelected ? bold(item.label) : item.label), colW.name, false);
@@ -228,14 +235,14 @@ export class UsageModalComponent implements Component {
228
235
 
229
236
  // Total row
230
237
  const t = this.summary.totals;
231
- const totalName = formatCol(th.fg("tint", bold("Total")), colW.name, false);
232
- const totalSess = formatCol(th.fg("tint", bold(formatCount(t.sessions))), colW.sessions);
233
- const totalMsgs = formatCol(th.fg("tint", bold(formatCount(t.msgs))), colW.msgs);
234
- const totalToks = formatCol(th.fg("tint", bold(formatTokens(t.totalTokens))), colW.tokens);
235
- const totalInp = formatCol(th.fg("tint", bold(formatTokens(t.input))), colW.inp);
236
- const totalOut = formatCol(th.fg("tint", bold(formatTokens(t.output))), colW.out);
237
- const totalCache = formatCol(th.fg("tint", bold(formatTokens(t.cacheRead))), colW.cache);
238
- const totalCost = formatCol(th.fg("tint", bold(formatCost(t.cost))), colW.cost);
238
+ const totalName = formatCol(th.fg("accentSoft", bold("Total")), colW.name, false);
239
+ const totalSess = formatCol(th.fg("accentSoft", bold(formatCount(t.sessions))), colW.sessions);
240
+ const totalMsgs = formatCol(th.fg("accentSoft", bold(formatCount(t.msgs))), colW.msgs);
241
+ const totalToks = formatCol(th.fg("accentSoft", bold(formatTokens(t.totalTokens))), colW.tokens);
242
+ const totalInp = formatCol(th.fg("accentSoft", bold(formatTokens(t.input))), colW.inp);
243
+ const totalOut = formatCol(th.fg("accentSoft", bold(formatTokens(t.output))), colW.out);
244
+ const totalCache = formatCol(th.fg("accentSoft", bold(formatTokens(t.cacheRead))), colW.cache);
245
+ const totalCost = formatCol(th.fg("accentSoft", bold(formatCost(t.cost))), colW.cost);
239
246
 
240
247
  out.push(row(" " + totalName + totalSess + totalMsgs + totalToks + totalInp + totalOut + totalCache + totalCost));
241
248
 
@@ -271,6 +278,12 @@ if (isMain(import.meta.url)) {
271
278
  assert(formatCost(0.004) === "<$0.01", "formatCost 0.004 = <$0.01");
272
279
  assert(formatCost(12.345) === "$12.35", "formatCost 12.345 = $12.35");
273
280
  assert(formatCost(1234.5) === "$1.2k", "formatCost 1234.5 = $1.2k");
281
+ // Boundary regressions: rounding used to print "1000k" / "$1000.00".
282
+ assert(formatTokens(999_500) === "999.5k", "formatTokens stays k below 1M");
283
+ assert(formatTokens(999_999) === "1M", "formatTokens promotes a k value that rounds to 1000");
284
+ assert(formatTokens(999_999_999) === "1B", "formatTokens promotes an M value that rounds to 1000");
285
+ assert(formatTokens(9_999) === "10k", "formatTokens strips a .0 k tail");
286
+ assert(formatCost(999.996) === "$1.0k", "formatCost rolls over at $1000");
274
287
 
275
288
  const summary = collectUsageSummary();
276
289
  assert(summary.providers.length > 0, "collectUsageSummary detects providers");
@@ -280,6 +293,17 @@ if (isMain(import.meta.url)) {
280
293
 
281
294
  const modelUsage = getModelAllTimeUsage("gemini-3.8-flash");
282
295
  assert(modelUsage.input > 0, "getModelAllTimeUsage reads input tokens for gemini-3.8-flash");
296
+ assert(
297
+ getModelAllTimeUsage(undefined).input === 0 && getModelAllTimeUsage(undefined).output === 0,
298
+ "getModelAllTimeUsage without a model id matches nothing (no grand total)",
299
+ );
300
+
301
+ // Disk cache pruning: no cache key may point at a session file that is gone.
302
+ const cacheFile = join(getAgentDir(), "arnative-usage-cache.json");
303
+ if (existsSync(cacheFile)) {
304
+ const raw = JSON.parse(readFileSync(cacheFile, "utf8")) as Record<string, unknown>;
305
+ assert(Object.keys(raw).every((k) => existsSync(k)), "usage disk cache prunes deleted session files");
306
+ }
283
307
 
284
308
  const fakeTheme = {
285
309
  fg: (_c: string, t: string) => `\x1b[38;2;100;100;100m${t}\x1b[39m`,
package/lib/ansi.ts CHANGED
@@ -25,6 +25,18 @@ export function themeOf<T>(ctx: unknown): T | null {
25
25
  return (ctx as { ui?: { theme?: T } } | null | undefined)?.ui?.theme ?? null;
26
26
  }
27
27
 
28
+ /** Painter for the `accentSoft` token, which only arnative themes define; other themes fall back to accent (probed once). */
29
+ export function accentSoftOf(th: { fg?(c: string, t: string): string } | null): (s: string) => string {
30
+ const fg = th?.fg?.bind(th);
31
+ if (!fg) return (s) => s;
32
+ try {
33
+ fg("accentSoft", "");
34
+ return (s) => fg("accentSoft", s);
35
+ } catch {
36
+ return (s) => fg("accent", s);
37
+ }
38
+ }
39
+
28
40
  // Self-check: `node lib/ansi.ts`
29
41
  if (isMain(import.meta.url)) {
30
42
  assert(ansiBgOpen(null, "selectedBg") === "", "no theme: empty");
@@ -33,5 +45,16 @@ if (isMain(import.meta.url)) {
33
45
  assert(stripAnsi("x\x1b[K") === "x", "stripAnsi removes non-SGR CSI too");
34
46
  assert(themeOf({ ui: { theme: "T" } }) === "T", "themeOf reads ctx.ui.theme");
35
47
  assert(themeOf(null) === null, "themeOf tolerates a missing ctx");
48
+
49
+ const tag = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
50
+ assert(accentSoftOf(tag)("x") === "<accentSoft>x</accentSoft>", "accentSoftOf uses accentSoft when the theme defines it");
51
+ const noSoft = {
52
+ fg: (c: string, t: string) => {
53
+ if (c === "accentSoft") throw new Error("Unknown theme color: accentSoft");
54
+ return `<${c}>${t}</${c}>`;
55
+ },
56
+ };
57
+ assert(accentSoftOf(noSoft)("x") === "<accent>x</accent>", "accentSoftOf falls back to accent without the token");
58
+ assert(accentSoftOf(null)("x") === "x", "accentSoftOf without a theme is identity");
36
59
  console.log("lib/ansi.ts OK");
37
60
  }