@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.
- package/CHANGELOG.md +32 -0
- package/README.md +52 -46
- package/extensions/animations.ts +213 -0
- package/extensions/arnative.ts +410 -98
- package/extensions/footer.ts +334 -49
- package/extensions/section-headers.ts +11 -6
- package/extensions/timestamps.ts +1 -1
- package/extensions/tools.ts +19 -2
- package/extensions/ui-render-tweaks.ts +2 -1
- package/extensions/usage.ts +468 -78
- package/lib/ansi.ts +50 -0
- package/lib/box.ts +90 -5
- package/lib/shortcuts.ts +62 -13
- package/lib/spinner.ts +270 -0
- package/lib/usage-store.ts +96 -4
- package/package.json +4 -3
package/extensions/usage.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
155
|
-
//
|
|
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
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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", "
|
|
190
|
-
formatCol(th.fg("dim", "
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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),
|
|
222
|
-
const cSess = formatCol(th.fg(valColor, formatCount(item.sessions)),
|
|
223
|
-
const cMsgs = formatCol(th.fg(valColor, formatCount(item.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
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
const cCost = formatCol(th.fg(valColor,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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))),
|
|
240
|
-
const totalMsgs = formatCol(th.fg("accentSoft", bold(formatCount(t.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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
360
|
-
|
|
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) => ["╭", "│", "
|
|
368
|
-
"no line missing a box side
|
|
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
|
}
|