@bismawy/pi-arnative 0.3.9 → 0.4.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.
@@ -7,10 +7,13 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
7
7
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
- import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
11
- import { boxEdge, boxRow } from "../lib/box.ts";
10
+ import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
11
+ import { renderScreen, screenTitle } from "../lib/box.ts";
12
12
  import { assert, isMain } from "../lib/check.ts";
13
- import { collectUsageSummary, formatTokens, getModelAllTimeUsage, type UsageSummary } from "../lib/usage-store.ts";
13
+ import { capitalize } from "../lib/format.ts";
14
+ import { thinkingColorOf } from "../lib/ansi.ts";
15
+ import { legendUsage, matchesBack } from "../lib/shortcuts.ts";
16
+ import { collectUsageSummary, estimateCost, formatTokens, getModelAllTimeUsage, hasPrice, loadPriceTable, type UsageSummary } from "../lib/usage-store.ts";
14
17
 
15
18
  export function formatCost(n: number): string {
16
19
  if (!n || n <= 0) return "-";
@@ -42,6 +45,8 @@ export interface RowItem {
42
45
  output: number;
43
46
  cacheRead: number;
44
47
  cost: number;
48
+ /** Cost reconstructed from the price table when `cost` is 0 — shown with a `~`. */
49
+ estimatedCost: number;
45
50
  expanded?: boolean;
46
51
  }
47
52
 
@@ -51,17 +56,25 @@ export class UsageModalComponent implements Component {
51
56
  private summary: UsageSummary;
52
57
 
53
58
  private theme: { fg(c: string, t: string): string; bold?(t: string): string };
54
- private onDone: () => void;
59
+ private onDone: (back: boolean) => void;
55
60
  private requestRender: () => void;
61
+ /** Painter for the box border's thinking slot, probed once so a theme without
62
+ * `thinking*` falls back to dim. */
63
+ private edgeOf: (s: string) => string;
64
+ /** Row layout of the last render: which table row sits on which screen line. */
65
+ private rowHitboxes: { line: number; index: number }[] = [];
56
66
 
57
67
  constructor(
58
68
  theme: { fg(c: string, t: string): string; bold?(t: string): string },
59
- onDone: () => void,
69
+ /** Leave the dashboard. `true` when the user asked to go back (`←`), false for a plain close. */
70
+ onDone: (back: boolean) => void,
60
71
  requestRender: () => void,
72
+ thinkingLevel?: string,
61
73
  ) {
62
74
  this.theme = theme;
63
75
  this.onDone = onDone;
64
76
  this.requestRender = requestRender;
77
+ this.edgeOf = thinkingColorOf(theme, thinkingLevel);
65
78
  this.summary = collectUsageSummary(true);
66
79
  // Default: expand the first provider when there is one
67
80
  if (this.summary.providers.length > 0) {
@@ -80,7 +93,10 @@ export class UsageModalComponent implements Component {
80
93
  rows.push({
81
94
  type: "provider",
82
95
  providerName: p.name,
83
- label: `${isExpanded ? "▾" : "▸"} ${p.name}`,
96
+ // No marker in the label: the row gutter draws the expand arrow, so a selected
97
+ // provider does not end up with two arrows on one line. Capitalized to match the
98
+ // detail line and the model rows beneath it.
99
+ label: capitalize(p.name),
84
100
  sessions: p.sessions,
85
101
  msgs: p.msgs,
86
102
  tokens: p.totalTokens,
@@ -88,6 +104,7 @@ export class UsageModalComponent implements Component {
88
104
  output: p.output,
89
105
  cacheRead: p.cacheRead,
90
106
  cost: p.cost,
107
+ estimatedCost: p.estimatedCost,
91
108
  expanded: isExpanded,
92
109
  });
93
110
 
@@ -97,7 +114,7 @@ export class UsageModalComponent implements Component {
97
114
  type: "model",
98
115
  providerName: p.name,
99
116
  modelName: m.name,
100
- label: ` ${m.name}`,
117
+ label: m.name,
101
118
  sessions: m.sessions,
102
119
  msgs: m.msgs,
103
120
  tokens: m.totalTokens,
@@ -105,6 +122,7 @@ export class UsageModalComponent implements Component {
105
122
  output: m.output,
106
123
  cacheRead: m.cacheRead,
107
124
  cost: m.cost,
125
+ estimatedCost: m.estimatedCost,
108
126
  });
109
127
  }
110
128
  }
@@ -112,10 +130,21 @@ export class UsageModalComponent implements Component {
112
130
  return rows;
113
131
  }
114
132
 
133
+ private toggleProvider(row: RowItem | undefined): boolean {
134
+ if (!row || row.type !== "provider") return false;
135
+ if (this.expandedProviders.has(row.providerName)) this.expandedProviders.delete(row.providerName);
136
+ else this.expandedProviders.add(row.providerName);
137
+ return true;
138
+ }
139
+
115
140
  handleInput(keyData: string): void {
116
141
  const rows = this.buildFlatRows();
142
+ if (matchesBack(keyData)) {
143
+ this.onDone(true);
144
+ return;
145
+ }
117
146
  if (matchesKey(keyData, "q") || matchesKey(keyData, "Q") || matchesKey(keyData, "escape")) {
118
- this.onDone();
147
+ this.onDone(false);
119
148
  return;
120
149
  }
121
150
  if (matchesKey(keyData, "up") || matchesKey(keyData, "k")) {
@@ -135,46 +164,57 @@ export class UsageModalComponent implements Component {
135
164
  return;
136
165
  }
137
166
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "space")) {
138
- // Toggle expand provider
139
- const row = rows[this.selectedIndex];
140
- if (row && row.type === "provider") {
141
- if (this.expandedProviders.has(row.providerName)) {
142
- this.expandedProviders.delete(row.providerName);
143
- } else {
144
- this.expandedProviders.add(row.providerName);
145
- }
146
- this.requestRender();
147
- }
167
+ if (this.toggleProvider(rows[this.selectedIndex])) this.requestRender();
148
168
  }
149
169
  }
150
170
 
171
+ /** A click on a provider row toggles it, using the hitboxes of the last render so a
172
+ * scrolled-out row is not clickable. Hover is not tracked: nothing reads it. */
173
+ handleMouse(event: TuiMouseEvent): { handled: boolean; render?: boolean } | undefined {
174
+ if (event.type === "wheel" || event.type === "move" || event.type === "drag" || event.type === "release") {
175
+ return undefined;
176
+ }
177
+ const hit = this.rowHitboxes.find((h) => h.line === event.y);
178
+ if (!hit) {
179
+ // A click outside the table is not ours to consume: let it fall through (text
180
+ // selection, scrollback).
181
+ return undefined;
182
+ }
183
+ if (event.type === "click") {
184
+ this.selectedIndex = hit.index;
185
+ this.toggleProvider(this.buildFlatRows()[hit.index]);
186
+ return { handled: true, render: true };
187
+ }
188
+ // Press: claim the row so the terminal tracks it for the follow-up click.
189
+ return { handled: true, render: false };
190
+ }
191
+
151
192
  render(width: number): string[] {
152
193
  const th = this.theme;
153
194
  const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
154
- // arnative chat-column box wraps title + table + hint, full width.
155
- // Round ends (╭─╮ / ╰─╯); sides + divider from lib/box.ts (single definition).
195
+ // The box accent follows the session's thinking level (theme `thinking*` slot), so the
196
+ // dashboard reads as belonging to the model that produced the usage.
197
+ const edge = (t: string) => this.edgeOf(t);
156
198
  const boxW = width;
157
199
  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);
160
- const rule = () => hline("├", "┤");
161
200
 
162
201
  const rows = this.buildFlatRows();
163
202
  if (this.selectedIndex >= rows.length) {
164
203
  this.selectedIndex = Math.max(0, rows.length - 1);
165
204
  }
166
205
 
167
- // Layout columns (character widths) — 89 characters of table content in total
168
- const colW = {
169
- name: 28,
170
- sessions: 8,
171
- msgs: 8,
172
- tokens: 9,
173
- inp: 9,
174
- out: 8,
175
- cache: 8,
176
- cost: 9,
177
- };
206
+ // Columns: full header words. `colW.name` is the header's own name column; a data row
207
+ // spends the first two cells on the expand arrow, so its name field is that much
208
+ // shorter and the numeric columns still start on the header's grid.
209
+ const gutter = 2;
210
+ const contentW = boxW - 4;
211
+ const colW = { name: 30, session: 9, messages: 10, tokens: 9, cost: 10 };
212
+ const allNumeric = colW.session + colW.messages + colW.tokens + colW.cost;
213
+ // A very narrow terminal drops Session/Messages rather than truncating Tokens or Cost.
214
+ const narrow = contentW - allNumeric < 18;
215
+ const numericW = narrow ? colW.tokens + colW.cost : allNumeric;
216
+ colW.name = Math.max(8, Math.min(colW.name, contentW - numericW));
217
+ const rowNameW = Math.max(4, colW.name - gutter);
178
218
 
179
219
  const formatCol = (text: string, w: number, alignRight = true) => {
180
220
  const str = truncateToWidth(text, w);
@@ -182,25 +222,25 @@ export class UsageModalComponent implements Component {
182
222
  return alignRight ? " ".repeat(diff) + str : str + " ".repeat(diff);
183
223
  };
184
224
 
185
- // Table header
225
+ const sessionW = narrow ? 0 : colW.session;
226
+ const messagesW = narrow ? 0 : colW.messages;
227
+ // No leading gutter: the header is content, so it starts on the same left edge as the
228
+ // box title, the detail line and the Total row. The rows' numeric columns are measured
229
+ // to land on this grid despite their arrow.
186
230
  const headerText =
187
- " " +
188
231
  formatCol(th.fg("dim", "Provider / Model"), colW.name, false) +
189
- formatCol(th.fg("dim", "Sessions"), colW.sessions) +
190
- formatCol(th.fg("dim", "Msgs"), colW.msgs) +
232
+ formatCol(th.fg("dim", "Session"), sessionW) +
233
+ formatCol(th.fg("dim", "Messages"), messagesW) +
191
234
  formatCol(th.fg("dim", "Tokens"), colW.tokens) +
192
- formatCol(th.fg("dim", "↑In"), colW.inp) +
193
- formatCol(th.fg("dim", "↓Out"), colW.out) +
194
- formatCol(th.fg("dim", "Cache"), colW.cache) +
195
235
  formatCol(th.fg("dim", "Cost"), colW.cost);
196
236
 
197
- const out: string[] = [];
198
- out.push(hline("╭", "╮"));
199
- out.push(row(th.fg("accent", bold("LLM Usage"))));
200
- out.push(row(th.fg("muted", "Token, message & cache usage across all local Pi sessions.")));
201
- out.push(row(""));
202
- out.push(row(headerText));
203
- out.push(rule());
237
+ // Body rows only — the frame comes from `renderScreen`, so this dashboard and the
238
+ // /arnative menus cannot drift. No inner `├─┤` rule (skill arnative-pi rule 7).
239
+ const body: string[] = [];
240
+ /** Rows the chrome draws above the body: top edge, title, description, blank. */
241
+ const CHROME_ROWS = 4;
242
+ this.rowHitboxes = [];
243
+ body.push(headerText);
204
244
 
205
245
  // Data rows (with a display height cap so it cannot overflow)
206
246
  const maxVisibleRows = 16;
@@ -218,50 +258,99 @@ export class UsageModalComponent implements Component {
218
258
  const nameColor = isSelected ? "accent" : item.type === "provider" ? "accentSoft" : "text";
219
259
  const valColor = isSelected ? "accent" : "dim";
220
260
 
221
- const cName = formatCol(th.fg(nameColor, isSelected ? bold(item.label) : item.label), colW.name, false);
222
- const cSess = formatCol(th.fg(valColor, formatCount(item.sessions)), colW.sessions);
223
- const cMsgs = formatCol(th.fg(valColor, formatCount(item.msgs)), colW.msgs);
261
+ const cName = formatCol(th.fg(nameColor, isSelected ? bold(item.label) : item.label), rowNameW, false);
262
+ const cSess = formatCol(th.fg(valColor, formatCount(item.sessions)), sessionW);
263
+ const cMsgs = formatCol(th.fg(valColor, formatCount(item.msgs)), messagesW);
224
264
  const cToks = formatCol(th.fg(valColor, formatTokens(item.tokens)), colW.tokens);
225
- const cInp = formatCol(th.fg(valColor, formatTokens(item.input)), colW.inp);
226
- const cOut = formatCol(th.fg(valColor, formatTokens(item.output)), colW.out);
227
- const cCache = formatCol(th.fg(valColor, formatTokens(item.cacheRead)), colW.cache);
228
- const cCost = formatCol(th.fg(valColor, formatCost(item.cost)), colW.cost);
229
-
230
- const prefix = isSelected ? th.fg("accent", "▸ ") : " ";
231
- out.push(row(prefix + cName + cSess + cMsgs + cToks + cInp + cOut + cCache + cCost));
265
+ // A row with no reported cost but a rate shows `~$x` in place of `-`, so the guess is
266
+ // visible exactly where it is one; the report line still keeps it out of the headline.
267
+ const costCell = item.cost > 0 ? formatCost(item.cost) : item.estimatedCost > 0 ? `~${formatCost(item.estimatedCost)}` : "-";
268
+ const cCost = formatCol(th.fg(valColor, costCell), colW.cost);
269
+
270
+ // Gutter: expand arrow for provider or blank indent for model.
271
+ const gutterText =
272
+ item.type === "provider" ? th.fg(item.expanded ? "accentSoft" : "dim", item.expanded ? "▾" : "▸") : " ";
273
+ const line = gutterText + " " + cName + cSess + cMsgs + cToks + cCost;
274
+ this.rowHitboxes.push({ line: CHROME_ROWS + body.length, index: actualIdx });
275
+ body.push(line);
232
276
  }
233
277
 
234
- out.push(rule());
278
+ // The selected row's detail sits under the table, where the eye lands after scanning it.
279
+ body.push("");
280
+ body.push(this.selectedDetail(rows[this.selectedIndex], (t) => th.fg("accent", t), (t) => th.fg("accentSoft", t)));
281
+
282
+ // Table-width separator above Total band (skill arnative-pi §6 rule 7).
283
+ const tableW = colW.name + sessionW + messagesW + colW.tokens + colW.cost;
284
+ body.push(th.fg("dim", "─".repeat(Math.max(0, tableW))));
235
285
 
236
286
  // Total row
237
287
  const t = this.summary.totals;
238
288
  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);
289
+ const totalSess = formatCol(th.fg("accentSoft", bold(formatCount(t.sessions))), sessionW);
290
+ const totalMsgs = formatCol(th.fg("accentSoft", bold(formatCount(t.msgs))), messagesW);
241
291
  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
292
  const totalCost = formatCol(th.fg("accentSoft", bold(formatCost(t.cost))), colW.cost);
246
293
 
247
- out.push(row(" " + totalName + totalSess + totalMsgs + totalToks + totalInp + totalOut + totalCache + totalCost));
294
+ // Same left edge as the header: one table, three bands, one column grid.
295
+ body.push(totalName + totalSess + totalMsgs + totalToks + totalCost);
296
+
297
+ // Unreported cost estimate displayed separately from verified cost total.
298
+ const est = t.estimatedCost;
299
+ if (est > 0) body.push(th.fg("muted", `~${formatCost(est)} estimated (models.json)`));
300
+
301
+ return renderScreen(
302
+ th,
303
+ width,
304
+ {
305
+ title: screenTitle("Usage"),
306
+ description: width >= 70 ? "Token, message & cache usage across all local Pi sessions." : "Usage across all local sessions.",
307
+ legend: this.hintText(width),
308
+ },
309
+ body,
310
+ edge,
311
+ );
312
+ }
248
313
 
249
- out.push(row(""));
250
- out.push(row(th.fg("dim", "[↑↓] Navigation [Enter] Open/close [q/Esc] Exit")));
251
- out.push(hline("╰", "╯"));
314
+ /** Detail for the selected row: icon in `accent`, text and `·` dividers in `accentSoft`. */
315
+ private selectedDetail(
316
+ row: RowItem | undefined,
317
+ accent: (t: string) => string,
318
+ soft: (t: string) => string,
319
+ ): string {
320
+ if (!row) return soft("No usage recorded yet.");
321
+ const icon = `${accent("\uf4bc")} `;
322
+ const title = row.type === "provider" ? row.providerName : `${row.providerName} · ${row.modelName}`;
323
+ const parts = [
324
+ `↑${formatTokens(row.input)}`,
325
+ `↓${formatTokens(row.output)}`,
326
+ `cache ${formatTokens(row.cacheRead)}`,
327
+ ];
328
+ if (row.estimatedCost > 0) parts.push(`~${formatCost(row.estimatedCost)} est.`);
329
+ const divider = soft(" · ");
330
+ return `${icon}${soft(capitalize(title))}${divider}${soft(parts.join(" · "))}`;
331
+ }
252
332
 
253
- return out.map((line) => truncateToWidth(line, width, ""));
333
+ private hintText(width: number): string {
334
+ return legendUsage(width);
254
335
  }
255
336
  }
256
337
 
338
+ /** Open the dashboard. Shared by `/usage` and `/arnative` → Usage, so the two cannot drift. */
339
+ export async function openUsage(ctx: ExtensionCommandContext): Promise<boolean> {
340
+ if (!ctx.hasUI) return false;
341
+ // The thinking level colors the box, so read it when the dashboard opens.
342
+ const thinkingLevel = ctx.thinkingLevel;
343
+ const back = await ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
344
+ return new UsageModalComponent(theme, (wantsBack) => done(wantsBack), () => tui.requestRender(), thinkingLevel);
345
+ });
346
+ return back === true;
347
+ }
348
+
257
349
  export default function (pi: ExtensionAPI) {
258
350
  pi.registerCommand("usage", {
259
351
  description: "Arnative token & model usage dashboard",
260
352
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
261
- if (!ctx.hasUI) return;
262
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
263
- return new UsageModalComponent(theme, () => done(), () => tui.requestRender());
264
- });
353
+ await openUsage(ctx);
265
354
  },
266
355
  });
267
356
  }
@@ -347,26 +436,327 @@ if (isMain(import.meta.url)) {
347
436
  assert(Object.keys(raw).length === 0, "usage disk cache prunes deleted session files");
348
437
  rmSync(fixture, { recursive: true, force: true });
349
438
 
439
+ // UI checks need rows of their own: the scratch above ends with an empty, deleted file.
440
+ // These use a provider that reports NO cost (cost.total = 0), which is the case the estimate
441
+ // exists for; `asst` above reports 0.02 per message and would be priced by the provider.
442
+ mkdirSync(join(fixture, "sessions", "ui"), { recursive: true });
443
+ const freeAsst = (model: string, input: number) =>
444
+ JSON.stringify({ message: { role: "assistant", provider: "wally", model, usage: { input, output: 5, cacheRead: 1, cost: { total: 0 } } } });
445
+ writeFileSync(
446
+ join(fixture, "sessions", "ui", "rows.jsonl"),
447
+ [freeAsst("gemini-3.8-flash", 100), freeAsst("other-model", 7)].join(String.fromCharCode(10)),
448
+ );
449
+ collectUsageSummary(true);
450
+
451
+ // Cost estimation: unpriced pairs use rates declared automatically in models.json.
452
+ assert(loadPriceTable() !== null && Object.keys(loadPriceTable()).length === 0, "no models.json rates means an empty table");
453
+ assert(!hasPrice("wally", "gemini-3.8-flash"), "a pair absent from models.json has no price");
454
+ assert(estimateCost(1000, 100, 0, "gemini-3.8-flash", "wally") === null, "an unknown pair estimates nothing");
455
+ assert(collectUsageSummary(true).totals.estimatedCost === 0, "nothing is estimated without rates");
456
+ assert(estimateCost(1000, 100, 0, "other-model", "wally") === null, "a second unpriced pair estimates nothing either");
457
+
458
+ writeFileSync(
459
+ join(fixture, "models.json"),
460
+ JSON.stringify({
461
+ providers: {
462
+ wally: { models: [{ id: "gemini-3.8-flash", cost: { input: 1, output: 2, cacheRead: 0.1 } }] },
463
+ yarz: { models: [{ id: "gemini-3.8-flash", cost: { input: 10, output: 20 } }] },
464
+ },
465
+ }),
466
+ );
467
+ assert(hasPrice("wally", "gemini-3.8-flash"), "a priced provider+model is found in the table");
468
+ assert(!hasPrice("enclave", "gemini-3.8-flash"), "another provider with no entry is not priced");
469
+ assert(estimateCost(1_000_000, 0, 0, "gemini-3.8-flash", "wally") === 1, "1M input tokens at wally's $1/M costs $1");
470
+ assert(
471
+ estimateCost(1_000_000, 0, 0, "gemini-3.8-flash", "yarz") === 10,
472
+ "the SAME model at yarz costs 10x (rates are per provider, not per model)",
473
+ );
474
+ assert(estimateCost(0, 1_000_000, 0, "gemini-3.8-flash", "wally") === 2, "1M output tokens at $2/M costs $2");
475
+ assert(Math.abs(estimateCost(0, 0, 1_000_000, "gemini-3.8-flash", "wally")! - 0.1) < 1e-9, "cache reads use their own rate");
476
+ assert(estimateCost(1_000_000, 0, 0, "vendor/gemini-3.8-flash", "wally") === 1, "a vendor/my-model id matches the model rate");
477
+ const withPrices = collectUsageSummary(true);
478
+ assert(withPrices.totals.estimatedCost > 0, "models.json rates produce an estimate");
479
+ assert(withPrices.totals.reportedCost === 0, "the reported total stays at the provider's own number");
480
+ assert(withPrices.totals.cost === withPrices.totals.reportedCost, "the headline cost never absorbs the estimate");
481
+ assert(estimateCost(1_000_000, 0, 0, "other-model", "wally") === null, "a pair with no rate stays unpriced even when others are priced");
482
+
350
483
  const fakeTheme = {
351
484
  fg: (_c: string, t: string) => `\x1b[38;2;100;100;100m${t}\x1b[39m`,
352
485
  bold: (t: string) => `\x1b[1m${t}\x1b[22m`,
353
486
  };
354
487
  const modal = new UsageModalComponent(fakeTheme, () => {}, () => {});
355
488
  const lines = modal.render(100);
356
- assert(lines.length >= 10, "modal render produces table lines");
489
+ assert(lines.length >= 8, "modal render produces table lines");
357
490
  assert(lines[0]!.includes("╭") && lines[0]!.includes("╮"), "round top border");
358
491
  assert(visibleWidth(lines[0]!) === 100, "box border full width (100 columns)");
359
- assert(lines[1]!.includes("LLM Usage"), "LLM Usage title inside the box");
360
- assert(lines[2]!.includes("sessions"), "description below the title");
492
+ // The title follows the one arnative shape, `<setting> - <extension>⠙` — the same helper the
493
+ // menus use, so this screen cannot keep an old title of its own.
494
+ assert(lines[1]!.includes(screenTitle("Usage")), "the dashboard title uses the shared <setting> - <extension> shape");
495
+ assert(lines[1]!.includes("Usage - Arnative\u2819"), "the dashboard title carries the arnative brand");
496
+ assert(!lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "")).join("\n").includes("LLM Usage"), "the old one-off title is gone");
361
497
  assert(lines[lines.length - 1]!.includes("╰") && lines[lines.length - 1]!.includes("╯"), "round bottom border");
362
498
  assert(
363
499
  lines.every((l) => visibleWidth(l) <= 100),
364
500
  "every line fits within width 100",
365
501
  );
366
502
  assert(
367
- lines.every((l) => ["╭", "│", "├", "╰"].some((c) => l.includes(c))),
368
- "no line missing a box side (accent line gone)",
503
+ lines.every((l) => ["╭", "│", "╰"].some((c) => l.includes(c))),
504
+ "no line missing a box side",
505
+ );
506
+
507
+ // Compact layout: the four noisy columns (↑In/↓Out/Cache) are gone from the table; the
508
+ // selected row states them in the detail line instead.
509
+ const plain = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
510
+ const body = lines.map(plain).join("\n");
511
+ assert(body.includes("Tokens"), "compact table keeps the Tokens column");
512
+ assert(body.includes("Cost"), "compact table keeps the Cost column");
513
+ assert(!lines.some((l) => plain(l).includes("↑In")), "compact table drops the ↑In column");
514
+ assert(!lines.some((l) => plain(l).includes("↓Out")), "compact table drops the ↓Out column");
515
+ assert(lines.some((l) => plain(l).includes("↑")), "the selected row still shows its in/out detail");
516
+
517
+ // Layout contract: full header words, the detail line UNDER the table (not above it),
518
+ // and the total below the rule that closes the rows.
519
+ assert(body.includes("Session"), "the header spells Session in full");
520
+ assert(body.includes("Messages"), "the header spells Messages in full");
521
+ const at = (needle: string) => lines.findIndex((l) => plain(l).includes(needle));
522
+ const firstRow = lines.findIndex((l) => plain(l).includes("▾") || plain(l).includes("▸"));
523
+ const detailAt = lines.findIndex((l) => plain(l).includes("cache ") && plain(l).includes("↑"));
524
+ assert(detailAt > firstRow, "the detail line sits below the table, not above it");
525
+ assert(at("Total") > detailAt, "the total sits below the detail line");
526
+ assert(at("Provider / Model") < firstRow, "the header sits above the first row");
527
+ // The chrome is the whole chrome: no inner `├─┤` rule (skill arnative-pi rule 7), the sections
528
+ // are separated by blank rows the way every /arnative menu separates them.
529
+ assert(!lines.some((l) => /[├┤]/.test(l)), "the dashboard draws no inner rule");
530
+ assert(
531
+ lines.findIndex((l) => plain(l).includes("across all local Pi sessions")) < firstRow,
532
+ "the description sits above the table",
533
+ );
534
+ assert(plain(lines[detailAt]!).includes("\uf4bc"), "the detail line carries its icon");
535
+ // No rule between the header and its first row (the header labels the rows below it),
536
+ // while the rules after the description and before Total stay.
537
+ const headerAt = lines.findIndex((l) => plain(l).includes("Provider / Model"));
538
+ assert(
539
+ !lines.slice(headerAt + 1, firstRow).some((l) => l.includes("├")),
540
+ "no rule sits between the header and the first row",
541
+ );
542
+ assert(plain(lines[headerAt - 1]!).slice(1, -1).trim() === "", "a blank row separates the description from the header");
543
+ // The header is content: it sits flush with the title, and each column title ends on the
544
+ // same cell its own numbers end on.
545
+ const headerLine = plain(lines[headerAt]!);
546
+ // The Total row always carries a value in every column, so it is the stable ruler to
547
+ // measure the grid against (a provider row can show `-`).
548
+ const dataLine = plain(lines[at("Total")]!);
549
+ // Total sits on the header's grid, one table-width rule above it.
550
+ const totalLine = plain(lines[at("Total")]!);
551
+ assert(
552
+ totalLine.indexOf("Total") === headerLine.indexOf("Provider / Model"),
553
+ `Total is on the header grid (header ${headerLine.indexOf("Provider / Model")}, total ${totalLine.indexOf("Total")})`,
554
+ );
555
+ const totalAt = at("Total");
556
+ const ruleLine = plain(lines[totalAt - 1]!);
557
+ assert(/─{10,}/.test(ruleLine), "a table rule separates the table from Total");
558
+ assert(!/[├┤]/.test(ruleLine), "the table rule is not a full-width frame junction");
559
+ const ruleStart = ruleLine.indexOf("─");
560
+ const ruleEnd = ruleLine.lastIndexOf("─") + 1;
561
+ assert(ruleStart === headerLine.indexOf("Provider / Model"), "the rule starts at the table's left edge");
562
+ assert(ruleEnd <= 100 - 3, `the rule stops at the table's right edge, got ${ruleEnd}`);
563
+ // Every column title ends on the same cell its numbers end on, so the header reads as the
564
+ // table's ruler instead of drifting a column or two to the left of its own data.
565
+ const right = (line: string, text: string) => line.indexOf(text) + text.length;
566
+ assert(headerLine.indexOf("Provider / Model") === dataLine.indexOf("Total"), "the header name column starts over the row names");
567
+ // Flush left with the box title: no screen row is indented into a gutter the header does
568
+ // not use, so the title, the table, the rule and Total share one left edge.
569
+ const titleCol = plain(lines[1]!).indexOf("Usage - Arnative");
570
+ for (const [what, col] of [
571
+ ["the header", headerLine.indexOf("Provider / Model")],
572
+ ["the table rule", ruleStart],
573
+ ["Total", dataLine.indexOf("Total")],
574
+ ] as const) {
575
+ assert(col === titleCol, `${what} starts on the title's left edge (${col} vs ${titleCol})`);
576
+ }
577
+ // A right-aligned column ends exactly where its title ends: take the slice of the row up
578
+ // to that boundary and it must be flush against it, never a cell short or a cell over.
579
+ let edge = headerLine.indexOf("Provider / Model");
580
+ for (const title of ["Session", "Messages", "Tokens", "Cost"]) {
581
+ const boundary = right(headerLine, title);
582
+ const cell = dataLine.slice(edge, boundary);
583
+ assert(cell.trim() !== "", `the ${title} cell is not empty`);
584
+ assert(cell === cell.trimEnd(), `the ${title} value is right-aligned to its column edge`);
585
+ edge = boundary;
586
+ }
587
+ // The unpriced notice is gone: the `-` in Cost already says it.
588
+ assert(!body.includes("unpriced"), "the unpriced notice line is removed");
589
+ // Provider rows are capitalized so they read as names, not raw ids.
590
+ assert(plain(lines[firstRow]!).includes("Wally") || plain(lines[firstRow]!).includes("Google"), "provider rows are capitalized");
591
+
592
+ // Thinking level -> theme slot is unit-tested in lib/ansi.ts (one definition); here we
593
+ // only prove the box border actually takes its color from that slot at render time.
594
+ const taggedTheme = { fg: (c: string, t: string) => `<${c}>${t}</>`, bold: (t: string) => t };
595
+ // Wide enough that the tagged theme's literal <tag> text does not force truncation.
596
+ const high = new UsageModalComponent(taggedTheme, () => {}, () => {}, "high");
597
+ const highLines = high.render(260);
598
+ assert(highLines[0]!.includes("<thinkingHigh>"), "the box border uses the thinking slot from the session level");
599
+ // The detail line: icon in accent, its text and dividers in accentSoft.
600
+ const detailTagged = highLines.find((l) => l.includes("<accent>\uf4bc</>") || (l.includes("\uf4bc") && l.includes("↑")))!;
601
+ assert(detailTagged.includes("<accent>\uf4bc</>"), "the detail icon is accent-colored");
602
+ assert(!detailTagged.includes("<thinkingHigh>\uf4bc"), "the detail icon is NOT the border/thinking color");
603
+ assert(/<accentSoft>[^<]*↑/.test(detailTagged), "the detail values are accentSoft");
604
+ assert(detailTagged.includes("<accentSoft> · </>"), "the detail divider is accentSoft");
605
+ const offLines = new UsageModalComponent(taggedTheme, () => {}, () => {}, "off").render(260);
606
+ assert(offLines[0]!.includes("<thinkingOff>"), "thinking off colors the border with thinkingOff");
607
+ // Shared chrome (per `renderScreen`): title accent+bold, description muted, legend dim.
608
+ // `taggedTheme.bold` is identity, so only the accent wrapper shows here; the bold half is
609
+ // pinned in lib/box.ts.
610
+ assert(highLines[1]!.includes("<accent>Usage - Arnative"), "the dashboard title is accent-colored");
611
+ assert(highLines[2]!.includes("<muted>Token, message"), "the dashboard description is muted");
612
+ assert(highLines[highLines.length - 2]!.includes("<dim>["), "the dashboard legend is dim");
613
+ assert(!highLines.some((l) => /[├┤]/.test(l)), "the dashboard draws no inner rule");
614
+
615
+ // Mouse: a click on a provider row toggles it, a click on empty space is ignored.
616
+ const clickModal = new UsageModalComponent(fakeTheme, () => {}, () => {}, "medium");
617
+ const clickLines = clickModal.render(100);
618
+ const providerLine = clickLines.findIndex((l) => plain(l).includes("▸") || plain(l).includes("▾"));
619
+ assert(providerLine > 0, "a provider row is rendered");
620
+ const before = plain(clickModal.render(100)[providerLine]!);
621
+ const toggled = clickModal.handleMouse!({
622
+ type: "click",
623
+ button: "left",
624
+ x: 3,
625
+ y: providerLine,
626
+ screenX: 3,
627
+ screenY: providerLine,
628
+ width: 100,
629
+ height: clickLines.length,
630
+ shift: false,
631
+ alt: false,
632
+ ctrl: false,
633
+ } as never);
634
+ assert(toggled?.handled === true, "a click on a provider row is handled");
635
+ const after = plain(clickModal.render(100)[providerLine]!);
636
+ assert(before !== after, "clicking the row toggled the provider (marker changed)");
637
+ assert(
638
+ clickModal.handleMouse!({
639
+ type: "click",
640
+ button: "left",
641
+ x: 3,
642
+ y: clickLines.length + 50,
643
+ screenX: 3,
644
+ screenY: clickLines.length + 50,
645
+ width: 100,
646
+ height: clickLines.length,
647
+ shift: false,
648
+ alt: false,
649
+ ctrl: false,
650
+ } as never) === undefined,
651
+ "a click outside any row is ignored",
652
+ );
653
+ // A hover move is not consumed and not repainted: nothing on screen depends on it.
654
+ assert(
655
+ clickModal.handleMouse!({
656
+ type: "move",
657
+ button: "none",
658
+ x: 3,
659
+ y: providerLine,
660
+ screenX: 3,
661
+ screenY: providerLine,
662
+ width: 100,
663
+ height: clickLines.length,
664
+ shift: false,
665
+ alt: false,
666
+ ctrl: false,
667
+ } as never) === undefined,
668
+ "a hover move is passed through, not swallowed",
669
+ );
670
+ // A wheel event must not be swallowed: scrolling belongs to the terminal.
671
+ assert(
672
+ clickModal.handleMouse!({
673
+ type: "wheel",
674
+ button: "left",
675
+ x: 3,
676
+ y: providerLine,
677
+ screenX: 3,
678
+ screenY: providerLine,
679
+ width: 100,
680
+ height: clickLines.length,
681
+ shift: false,
682
+ alt: false,
683
+ ctrl: false,
684
+ } as never) === undefined,
685
+ "a wheel event is left to the terminal",
686
+ );
687
+ // `←` is the arnative-wide "go back" key: the dashboard reports it upward instead of
688
+ // closing, so the /arnative menu can reopen. Esc/q still close it outright.
689
+ const doneArgs: boolean[] = [];
690
+ const nav = new UsageModalComponent(fakeTheme, (back) => doneArgs.push(back), () => {}, "high");
691
+ nav.handleInput("\x1b[D"); // ←
692
+ nav.handleInput("\x1b"); // Esc
693
+ nav.handleInput("q");
694
+ assert(doneArgs.length === 3 && doneArgs[0] === true && doneArgs[1] === false && doneArgs[2] === false, "← reports back, Esc/q close");
695
+ assert(
696
+ new UsageModalComponent(fakeTheme, () => {}, () => {}, "high").render(120).some((l) => l.includes("[←] Back")),
697
+ "the dashboard legend advertises the back key",
698
+ );
699
+
700
+ // models.json rates price the provider and models directly.
701
+ writeFileSync(
702
+ join(fixture, "models.json"),
703
+ JSON.stringify({
704
+ providers: {
705
+ wally: { models: [{ id: "gemini-3.8-flash", cost: { input: 0.28, output: 0.42, cacheRead: 0.028 } }] },
706
+ yarz: { models: [{ id: "gemini-3.8-flash" }] },
707
+ },
708
+ }),
709
+ );
710
+ const fromModels = collectUsageSummary(true);
711
+ const wally = fromModels.providers.find((p) => p.name === "wally")!;
712
+ const expected = (100 / 1e6) * 0.28 + (5 / 1e6) * 0.42 + (1 / 1e6) * 0.028;
713
+ assert(Math.abs(wally.estimatedCost - expected) < 1e-12, `models.json cost prices the provider, got ${wally.estimatedCost}`);
714
+ assert(
715
+ Math.abs(wally.models.reduce((n, m) => n + m.estimatedCost, 0) - wally.estimatedCost) < 1e-9,
716
+ "a provider's estimate is the sum of its models' estimates",
369
717
  );
718
+ assert(wally.models.find((m) => m.name === "other-model")!.estimatedCost === 0, "a model without a declared cost stays unpriced");
719
+ writeFileSync(join(fixture, "models.json"), "{}", "utf8");
720
+ collectUsageSummary(true);
721
+
722
+ // Per-row estimates: a row whose provider reported nothing but has a rate shows `~$x`
723
+ // instead of `-`, while a reported cost is never prefixed. The total line names both
724
+ // sources, since a rate can come from `models.json` or the user's table.
725
+ const estModal = new UsageModalComponent(fakeTheme, () => {}, () => {}, "low");
726
+ (estModal as unknown as { summary: unknown }).summary = {
727
+ providers: [
728
+ {
729
+ name: "wally", sessions: 1, msgs: 2, input: 1_000_000, output: 500_000, cacheRead: 2_000_000,
730
+ totalTokens: 1_500_000, cost: 0, estimatedCost: 0.546,
731
+ models: [{ name: "deepseek-v4.1-flash", sessions: 1, msgs: 2, input: 1_000_000, output: 500_000, cacheRead: 2_000_000, totalTokens: 1_500_000, cost: 0, estimatedCost: 0.546 }],
732
+ },
733
+ {
734
+ name: "antigravity", sessions: 1, msgs: 1, input: 2_200_000, output: 626_000, cacheRead: 0,
735
+ totalTokens: 2_826_000, cost: 0.65, estimatedCost: 0,
736
+ models: [{ name: "gemini-3.8-flash", sessions: 1, msgs: 1, input: 2_200_000, output: 626_000, cacheRead: 0, totalTokens: 2_826_000, cost: 0.65, estimatedCost: 0 }],
737
+ },
738
+ ],
739
+ totals: { sessions: 2, msgs: 3, input: 3_200_000, output: 1_126_000, cacheRead: 2_000_000, totalTokens: 4_326_000, cost: 0.65, reportedCost: 0.65, estimatedCost: 0.546 },
740
+ };
741
+ (estModal as unknown as { expandedProviders: Set<string> }).expandedProviders = new Set(["wally"]);
742
+ const estLines = estModal.render(90);
743
+ const estBody = estLines.map(plain).join("\n");
744
+ assert(estBody.includes("~$0.55"), "a row with a rate but no reported cost shows the estimate with `~`");
745
+ assert(estBody.includes("$0.65") && !estBody.includes("~$0.65"), "a reported cost is shown as-is, never with `~`");
746
+ assert(estBody.includes("estimated (models.json)"), "the estimate line names models.json");
747
+ // Only the two table rows — the detail line also carries the estimate.
748
+ const estRows = estLines.map(plain).filter((l) => /^\u2502 [\u25be\u25b8 ] (Wally|deepseek)/.test(l));
749
+ assert(estRows.length === 2 && estRows.every((l) => l.includes("~$0.55")), `the estimate shows on the provider row and its model row, got ${JSON.stringify(estRows)}`);
750
+
751
+ // Cost must survive every width: a clipped `$5` is worse than a dropped column.
752
+ for (const w of [60, 64, 78, 82, 90, 120]) {
753
+ const wide = new UsageModalComponent(fakeTheme, () => {}, () => {}, "high");
754
+ const rendered = wide.render(w);
755
+ assert(
756
+ !rendered.some((l) => /\$\d+[^\d.\s]/.test(plain(l))),
757
+ `cost is never clipped at width ${w}`,
758
+ );
759
+ }
370
760
 
371
761
  console.log("usage.ts self-check OK");
372
762
  }