@hank-warren/pi-stats 0.1.0 → 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.
- package/README.md +16 -1
- package/cache.ts +54 -6
- package/package.json +1 -1
- package/stats.ts +55 -3
- package/types.ts +4 -2
- package/widget.ts +288 -84
package/README.md
CHANGED
|
@@ -17,10 +17,15 @@ Run `/stats` in interactive mode. The dashboard temporarily replaces the editor
|
|
|
17
17
|
- `Tab` or `←`/`→`: switch Overview and Models
|
|
18
18
|
- `↑`/`↓`: scroll the model list
|
|
19
19
|
- `r`: cycle All time, Last 7 days, and Last 30 days
|
|
20
|
+
- `e`: toggle compact totals (`1.5B`) and exact grouped digits (`1,486,201,553`)
|
|
20
21
|
- `u`: force a full rescan and rebuild the session index
|
|
21
22
|
- `Escape`: close
|
|
22
23
|
|
|
23
|
-
The Overview
|
|
24
|
+
The Overview compares current-session, selected-range, and all-time usage in one aligned table covering input, output, cache read, cache write, reasoning, cache-hit rate, and recorded cost, followed by a month-labelled activity heatmap and all-time session, streak, and favourite-model highlights. Reasoning is shown as a subset of output and is never added to the total twice.
|
|
25
|
+
|
|
26
|
+
The Models tab ranks models for the selected range with share bars and a totals row that reconciles with the range total. It attributes assistant usage to the provider response model and keeps otherwise unattributed nested usage in `Tools/summaries`.
|
|
27
|
+
|
|
28
|
+
Both tabs are responsive: as the terminal narrows, lower-priority columns move to a labelled continuation line under each row instead of disappearing, and the layout stays within a standard 24-row terminal.
|
|
24
29
|
|
|
25
30
|
## What is counted
|
|
26
31
|
|
|
@@ -28,6 +33,16 @@ Pi writes usage to assistant messages, usage-bearing tool results, compactions,
|
|
|
28
33
|
|
|
29
34
|
Persisted subagent files in the conventional `run-*/session.jsonl` layout are included. `pi-processes` does not create model sessions; model usage around its tool calls already belongs to the parent Pi session and therefore needs no separate integration.
|
|
30
35
|
|
|
36
|
+
### Extension usage sidecars
|
|
37
|
+
|
|
38
|
+
Some extensions call a model outside the agent loop, so their usage never reaches a session transcript. `pi-stats` also reads content-free usage sidecars at `<agent dir>/<extension>/usage.jsonl` (plus one rotated `usage.jsonl.1`). Each line is one call:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"v":1,"id":"3f2b…","ts":"2026-08-11T22:41:03.118Z","source":"auto-permissions","label":"guardian","provider":"anthropic","model":"claude-fable-5","usage":{"input":812,"output":96,"cacheRead":18442,"cacheWrite":0,"reasoning":48,"cost":0.0121}}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Records are de-duplicated by `id`, counted in every total, and shown as their own `provider/model (label)` row so the overhead stays visible. They never create sessions, so session counts, streaks, and session spans are unaffected. `@hank-warren/pi-auto-permissions` writes one for guardian reviews; set `PI_STATS_DISABLE_USAGE_SIDECARS=1` to ignore all of them.
|
|
45
|
+
|
|
31
46
|
The standard Pi session root, the current external session directory, `PI_CODING_AGENT_SESSION_DIR`, and a configured `subagents.defaultSessionDir` are discovered automatically. Add unusual one-off roots with the platform-delimited `PI_STATS_SESSION_DIRS` environment variable.
|
|
32
47
|
|
|
33
48
|
## Cache and privacy
|
package/cache.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFile, readdir, realpath, rename, stat, writeFile, mkdir, chmod, unlink } from "node:fs/promises";
|
|
3
3
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { makeIndex, parseSessionText } from "./stats.ts";
|
|
5
|
+
import { makeIndex, parseSessionText, parseUsageSidecar } from "./stats.ts";
|
|
6
6
|
import type { CachedFileRecord, ScanDiagnostics, SessionRecord, StatsCacheFile, StatsIndex, UsageRecord } from "./types.ts";
|
|
7
7
|
|
|
8
|
-
const CACHE_VERSION =
|
|
8
|
+
const CACHE_VERSION = 2 as const;
|
|
9
9
|
const SKIP_DIRECTORIES = new Set(["subagent-artifacts"]);
|
|
10
|
+
/** Extensions record out-of-transcript model usage in <agentDir>/<extension>/usage.jsonl. */
|
|
11
|
+
const USAGE_SIDECAR_NAMES = new Set(["usage.jsonl", "usage.jsonl.1"]);
|
|
10
12
|
|
|
11
13
|
export interface ScanOptions {
|
|
12
14
|
agentDir: string;
|
|
@@ -93,6 +95,37 @@ async function discoverJsonlFiles(roots: readonly string[], signal?: AbortSignal
|
|
|
93
95
|
return [...found].sort();
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
/** One level below the agent directory only; sidecars are a flat, documented convention. */
|
|
99
|
+
async function discoverUsageSidecars(agentDir: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<string[]> {
|
|
100
|
+
if (env.PI_STATS_DISABLE_USAGE_SIDECARS === "1") return [];
|
|
101
|
+
const found = new Set<string>();
|
|
102
|
+
let directories;
|
|
103
|
+
try {
|
|
104
|
+
directories = await readdir(agentDir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
for (const directory of directories) {
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) continue;
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = await readdir(join(agentDir, directory.name), { withFileTypes: true });
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !USAGE_SIDECAR_NAMES.has(entry.name)) continue;
|
|
119
|
+
try {
|
|
120
|
+
found.add(await realpath(join(agentDir, directory.name, entry.name)));
|
|
121
|
+
} catch {
|
|
122
|
+
// File disappeared between listing and resolution.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...found].sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
function emptyCache(): StatsCacheFile {
|
|
97
130
|
return { version: CACHE_VERSION, files: {} };
|
|
98
131
|
}
|
|
@@ -109,7 +142,7 @@ function validUsageRecord(value: unknown): value is UsageRecord {
|
|
|
109
142
|
typeof record.fingerprint === "string" &&
|
|
110
143
|
validNumber(record.timestamp) &&
|
|
111
144
|
typeof record.model === "string" &&
|
|
112
|
-
(record.kind === "assistant" || record.kind === "tool" || record.kind === "summary") &&
|
|
145
|
+
(record.kind === "assistant" || record.kind === "tool" || record.kind === "summary" || record.kind === "sidecar") &&
|
|
113
146
|
Boolean(usage) &&
|
|
114
147
|
validNumber(usage?.input) &&
|
|
115
148
|
validNumber(usage?.output) &&
|
|
@@ -149,7 +182,8 @@ function validCache(value: unknown): value is StatsCacheFile {
|
|
|
149
182
|
validNumber(record.mtimeMs) &&
|
|
150
183
|
validNumber(record.malformedLines) &&
|
|
151
184
|
(record.ignored === undefined || record.ignored === true) &&
|
|
152
|
-
(record.session === undefined || validSessionRecord(record.session))
|
|
185
|
+
(record.session === undefined || validSessionRecord(record.session)) &&
|
|
186
|
+
(record.sidecar === undefined || (Array.isArray(record.sidecar) && record.sidecar.every(validUsageRecord)))
|
|
153
187
|
);
|
|
154
188
|
});
|
|
155
189
|
}
|
|
@@ -207,7 +241,10 @@ export function statsCachePath(agentDir: string): string {
|
|
|
207
241
|
|
|
208
242
|
export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
209
243
|
const roots = await resolveSessionRoots(options);
|
|
210
|
-
const
|
|
244
|
+
const sessionFiles = await discoverJsonlFiles(roots, options.signal);
|
|
245
|
+
const sidecarFiles = await discoverUsageSidecars(options.agentDir, options.env ?? process.env, options.signal);
|
|
246
|
+
const sidecars = new Set(sidecarFiles);
|
|
247
|
+
const files = [...sessionFiles.filter((file) => !sidecars.has(file)), ...sidecarFiles];
|
|
211
248
|
const cachePath = statsCachePath(options.agentDir);
|
|
212
249
|
await cleanupStaleTemporaryFiles(cachePath);
|
|
213
250
|
const loaded = options.force ? { cache: emptyCache(), valid: false } : await loadCache(cachePath);
|
|
@@ -231,6 +268,16 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
|
231
268
|
if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
|
|
232
269
|
next.files[file] = previous!;
|
|
233
270
|
diagnostics.reusedFiles++;
|
|
271
|
+
} else if (sidecars.has(file)) {
|
|
272
|
+
const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
|
|
273
|
+
next.files[file] = {
|
|
274
|
+
size: metadata.size,
|
|
275
|
+
mtimeMs: metadata.mtimeMs,
|
|
276
|
+
sidecar: parsed.records,
|
|
277
|
+
malformedLines: parsed.malformedLines,
|
|
278
|
+
};
|
|
279
|
+
diagnostics.parsedFiles++;
|
|
280
|
+
changed = true;
|
|
234
281
|
} else {
|
|
235
282
|
const parsed = parseSessionText(file, await readFile(file, "utf8"));
|
|
236
283
|
next.files[file] = {
|
|
@@ -260,7 +307,8 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
|
260
307
|
});
|
|
261
308
|
}
|
|
262
309
|
const sessions = Object.values(next.files).flatMap((record) => (record.session ? [record.session] : []));
|
|
263
|
-
|
|
310
|
+
const sidecarUsage = Object.values(next.files).flatMap((record) => record.sidecar ?? []);
|
|
311
|
+
return makeIndex(sessions, diagnostics, sidecarUsage);
|
|
264
312
|
}
|
|
265
313
|
|
|
266
314
|
export function isExternalSessionDirectory(activeSessionDir: string, agentDir: string): boolean {
|
package/package.json
CHANGED
package/stats.ts
CHANGED
|
@@ -238,6 +238,49 @@ export function parseSessionText(filePath: string, text: string): ParseResult {
|
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Parse a usage sidecar: one content-free JSON record per model call made outside a Pi
|
|
243
|
+
* session transcript, such as pi-auto-permissions guardian reviews.
|
|
244
|
+
*/
|
|
245
|
+
export function parseUsageSidecar(filePath: string, text: string): { records: UsageRecord[]; malformedLines: number } {
|
|
246
|
+
const records: UsageRecord[] = [];
|
|
247
|
+
let malformedLines = 0;
|
|
248
|
+
const lines = text.split("\n");
|
|
249
|
+
for (let index = 0; index < lines.length; index++) {
|
|
250
|
+
const line = lines[index]?.trim();
|
|
251
|
+
if (!line) continue;
|
|
252
|
+
let entry: Record<string, unknown> | undefined;
|
|
253
|
+
try {
|
|
254
|
+
entry = object(JSON.parse(line));
|
|
255
|
+
} catch {
|
|
256
|
+
malformedLines++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!entry || entry.v !== 1) {
|
|
260
|
+
malformedLines++;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const usage = normalizeUsage(entry.usage);
|
|
264
|
+
if (!usage) {
|
|
265
|
+
malformedLines++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const label = typeof entry.label === "string" && entry.label.length > 0 ? entry.label : undefined;
|
|
269
|
+
const source = typeof entry.source === "string" && entry.source.length > 0 ? entry.source : "sidecar";
|
|
270
|
+
const model = `${modelName(entry.provider, entry.model)} (${label ?? source})`;
|
|
271
|
+
const identity = typeof entry.id === "string" && entry.id.length > 0 ? entry.id : `${resolve(filePath)}:${index}`;
|
|
272
|
+
records.push({
|
|
273
|
+
fingerprint: fingerprint([identity, entry.ts, source, model, usage]),
|
|
274
|
+
timestamp: timestamp(entry.ts, 0),
|
|
275
|
+
model,
|
|
276
|
+
usage,
|
|
277
|
+
kind: "sidecar",
|
|
278
|
+
toolName: source,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return { records, malformedLines };
|
|
282
|
+
}
|
|
283
|
+
|
|
241
284
|
export function usageFromEntries(
|
|
242
285
|
sessionId: string,
|
|
243
286
|
entries: readonly unknown[],
|
|
@@ -254,7 +297,7 @@ function hasPersistedSubagentChild(record: UsageRecord, sessionPaths: ReadonlySe
|
|
|
254
297
|
return record.toolName === "subagent" && Boolean(record.childSessionFiles?.some((file) => sessionPaths.has(resolve(file))));
|
|
255
298
|
}
|
|
256
299
|
|
|
257
|
-
export function deduplicateUsage(sessions: readonly SessionRecord[]): UsageRecord[] {
|
|
300
|
+
export function deduplicateUsage(sessions: readonly SessionRecord[], sidecar: readonly UsageRecord[] = []): UsageRecord[] {
|
|
258
301
|
const sessionPaths = new Set(sessions.map((session) => resolve(session.path)));
|
|
259
302
|
const seen = new Set<string>();
|
|
260
303
|
const result: UsageRecord[] = [];
|
|
@@ -266,6 +309,11 @@ export function deduplicateUsage(sessions: readonly SessionRecord[]): UsageRecor
|
|
|
266
309
|
result.push(record);
|
|
267
310
|
}
|
|
268
311
|
}
|
|
312
|
+
for (const record of sidecar) {
|
|
313
|
+
if (seen.has(record.fingerprint)) continue;
|
|
314
|
+
seen.add(record.fingerprint);
|
|
315
|
+
result.push(record);
|
|
316
|
+
}
|
|
269
317
|
return result;
|
|
270
318
|
}
|
|
271
319
|
|
|
@@ -400,6 +448,10 @@ export function buildSnapshot(
|
|
|
400
448
|
};
|
|
401
449
|
}
|
|
402
450
|
|
|
403
|
-
export function makeIndex(
|
|
404
|
-
|
|
451
|
+
export function makeIndex(
|
|
452
|
+
sessions: SessionRecord[],
|
|
453
|
+
diagnostics: StatsIndex["diagnostics"],
|
|
454
|
+
sidecar: UsageRecord[] = [],
|
|
455
|
+
): StatsIndex {
|
|
456
|
+
return { sessions, usage: deduplicateUsage(sessions, sidecar), diagnostics };
|
|
405
457
|
}
|
package/types.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface UsageRecord {
|
|
|
15
15
|
timestamp: number;
|
|
16
16
|
model: string;
|
|
17
17
|
usage: UsageTotals;
|
|
18
|
-
kind: "assistant" | "tool" | "summary";
|
|
18
|
+
kind: "assistant" | "tool" | "summary" | "sidecar";
|
|
19
19
|
toolName?: string;
|
|
20
20
|
childSessionFiles?: string[];
|
|
21
21
|
}
|
|
@@ -35,11 +35,13 @@ export interface CachedFileRecord {
|
|
|
35
35
|
mtimeMs: number;
|
|
36
36
|
ignored?: true;
|
|
37
37
|
session?: SessionRecord;
|
|
38
|
+
/** Usage recorded by extensions whose model calls never reach a session transcript. */
|
|
39
|
+
sidecar?: UsageRecord[];
|
|
38
40
|
malformedLines: number;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface StatsCacheFile {
|
|
42
|
-
version:
|
|
44
|
+
version: 2;
|
|
43
45
|
files: Record<string, CachedFileRecord>;
|
|
44
46
|
}
|
|
45
47
|
|
package/widget.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
-
import { totalTokens, type StatsRange, type StatsSnapshot, type UsageTotals } from "./types.ts";
|
|
3
|
+
import { totalTokens, type ModelStats, type StatsRange, type StatsSnapshot, type UsageTotals } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export type WidgetState =
|
|
6
6
|
| { kind: "loading"; completed: number; total: number }
|
|
@@ -16,20 +16,31 @@ export interface StatsWidgetOptions {
|
|
|
16
16
|
onDispose?: () => void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const
|
|
19
|
+
const MAX_ROWS = 24;
|
|
20
|
+
/** Top border, tab row, headline, hint row, bottom border. */
|
|
21
|
+
const CHROME_ROWS = 5;
|
|
22
|
+
const MAX_MODEL_PAGE = 10;
|
|
23
|
+
const MIN_MODEL_PAGE = 1;
|
|
24
|
+
const HEATMAP_ROWS = 9;
|
|
25
|
+
const HEATMAP_MIN_WEEKS = 12;
|
|
26
|
+
const HEATMAP_MAX_WEEKS = 53;
|
|
27
|
+
const HEATMAP_LABEL_WIDTH = 6;
|
|
28
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
20
29
|
const RANGES: StatsRange[] = ["all", "7d", "30d"];
|
|
21
30
|
const RANGE_LABELS: Record<StatsRange, string> = { all: "All time", "7d": "Last 7 days", "30d": "Last 30 days" };
|
|
22
31
|
|
|
23
|
-
export function formatTokens(value: number): string {
|
|
32
|
+
export function formatTokens(value: number, exact = false): string {
|
|
24
33
|
const safe = Math.max(0, Math.round(value));
|
|
25
|
-
if (
|
|
26
|
-
if (safe >=
|
|
27
|
-
if (safe >=
|
|
34
|
+
if (exact) return safe.toLocaleString("en-US");
|
|
35
|
+
if (safe >= 1_000_000_000) return `${(safe / 1_000_000_000).toFixed(1)}B`;
|
|
36
|
+
if (safe >= 1_000_000) return `${(safe / 1_000_000).toFixed(1)}M`;
|
|
37
|
+
if (safe >= 1_000) return `${(safe / 1_000).toFixed(safe < 10_000 ? 1 : 0)}K`;
|
|
28
38
|
return String(safe);
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
function formatCost(value: number): string {
|
|
32
|
-
|
|
41
|
+
export function formatCost(value: number): string {
|
|
42
|
+
const safe = Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
43
|
+
return `$${safe.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
function formatDuration(milliseconds: number): string {
|
|
@@ -53,16 +64,94 @@ function localDay(time: number): string {
|
|
|
53
64
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
function
|
|
67
|
+
function padRight(value: string, width: number): string {
|
|
57
68
|
const clipped = truncateToWidth(value, Math.max(0, width), "");
|
|
58
69
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
function padLeft(value: string, width: number): string {
|
|
73
|
+
const clipped = truncateToWidth(value, Math.max(0, width), "");
|
|
74
|
+
return " ".repeat(Math.max(0, width - visibleWidth(clipped))) + clipped;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface TableColumn {
|
|
78
|
+
header: string;
|
|
79
|
+
cells: string[];
|
|
80
|
+
align: "left" | "right";
|
|
81
|
+
/** 0 never drops; higher numbers are dropped first when the table does not fit. */
|
|
82
|
+
drop: number;
|
|
83
|
+
/** Short label used when the column moves to a continuation line. */
|
|
84
|
+
label?: string;
|
|
85
|
+
/** Decoration that simply disappears instead of moving to a continuation line. */
|
|
86
|
+
decorative?: true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface TableLayout {
|
|
90
|
+
header: string;
|
|
91
|
+
rows: string[];
|
|
92
|
+
extras: string[][];
|
|
93
|
+
width: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function wrap(pieces: readonly string[], width: number, indent: string): string[] {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
let current = "";
|
|
99
|
+
for (const piece of pieces) {
|
|
100
|
+
const candidate = current.length > 0 ? `${current} · ${piece}` : `${indent}${piece}`;
|
|
101
|
+
if (current.length > 0 && visibleWidth(candidate) > width) {
|
|
102
|
+
lines.push(current);
|
|
103
|
+
current = `${indent}${piece}`;
|
|
104
|
+
} else {
|
|
105
|
+
current = candidate;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (current.length > 0) lines.push(current);
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function layoutTable(columns: readonly TableColumn[], rowCount: number, width: number, gap = 2): TableLayout {
|
|
113
|
+
const measured = columns.map((column) => ({
|
|
114
|
+
column,
|
|
115
|
+
width: Math.max(visibleWidth(column.header), ...column.cells.map((cell) => visibleWidth(cell)), 1),
|
|
116
|
+
}));
|
|
117
|
+
const visible = [...measured];
|
|
118
|
+
const dropped: typeof measured = [];
|
|
119
|
+
const used = () => visible.reduce((sum, entry) => sum + entry.width, 1) + gap * Math.max(0, visible.length - 1);
|
|
120
|
+
while (used() > width && visible.some((entry) => entry.column.drop > 0)) {
|
|
121
|
+
let index = -1;
|
|
122
|
+
let rank = 0;
|
|
123
|
+
visible.forEach((entry, position) => {
|
|
124
|
+
if (entry.column.drop > 0 && entry.column.drop >= rank) {
|
|
125
|
+
rank = entry.column.drop;
|
|
126
|
+
index = position;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
if (index < 0) break;
|
|
130
|
+
dropped.unshift(...visible.splice(index, 1));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const separator = " ".repeat(gap);
|
|
134
|
+
const line = (cells: readonly string[]) =>
|
|
135
|
+
` ${visible.map((entry, index) => (entry.column.align === "right" ? padLeft(cells[index] ?? "", entry.width) : padRight(cells[index] ?? "", entry.width))).join(separator)}`;
|
|
136
|
+
const rows: string[] = [];
|
|
137
|
+
const extras: string[][] = [];
|
|
138
|
+
for (let row = 0; row < rowCount; row++) {
|
|
139
|
+
rows.push(line(visible.map((entry) => entry.column.cells[row] ?? "")));
|
|
140
|
+
const leftovers = dropped
|
|
141
|
+
.filter((entry) => !entry.column.decorative && (entry.column.cells[row] ?? "").trim().length > 0)
|
|
142
|
+
.map((entry) => `${entry.column.label ?? entry.column.header.toLowerCase()} ${entry.column.cells[row]}`);
|
|
143
|
+
extras.push(leftovers.length > 0 ? wrap(leftovers, width, " ") : []);
|
|
144
|
+
}
|
|
145
|
+
return { header: line(visible.map((entry) => entry.column.header)), rows, extras, width: used() };
|
|
146
|
+
}
|
|
147
|
+
|
|
61
148
|
export class StatsWidget implements Component {
|
|
62
149
|
private state: WidgetState = { kind: "loading", completed: 0, total: 0 };
|
|
63
150
|
private tab: "overview" | "models" = "overview";
|
|
64
151
|
private range: StatsRange = "all";
|
|
152
|
+
private exact = false;
|
|
65
153
|
private modelOffset = 0;
|
|
154
|
+
private pageSize = MAX_MODEL_PAGE;
|
|
66
155
|
private readonly options: StatsWidgetOptions;
|
|
67
156
|
|
|
68
157
|
constructor(options: StatsWidgetOptions) {
|
|
@@ -86,7 +175,7 @@ export class StatsWidget implements Component {
|
|
|
86
175
|
|
|
87
176
|
setReady(snapshot: StatsSnapshot): void {
|
|
88
177
|
this.state = { kind: "ready", snapshot };
|
|
89
|
-
this.modelOffset = Math.
|
|
178
|
+
this.modelOffset = Math.max(0, Math.min(this.modelOffset, snapshot.models.length - this.pageSize));
|
|
90
179
|
this.options.requestRender();
|
|
91
180
|
}
|
|
92
181
|
|
|
@@ -117,13 +206,18 @@ export class StatsWidget implements Component {
|
|
|
117
206
|
if (snapshot) this.setReady(snapshot);
|
|
118
207
|
return;
|
|
119
208
|
}
|
|
209
|
+
if (data === "e" || data === "E") {
|
|
210
|
+
this.exact = !this.exact;
|
|
211
|
+
this.options.requestRender();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
120
214
|
if (data === "u" || data === "U") {
|
|
121
215
|
this.options.onRefresh();
|
|
122
216
|
return;
|
|
123
217
|
}
|
|
124
218
|
if (this.state.kind === "ready" && this.tab === "models") {
|
|
125
219
|
if (matchesKey(data, Key.down)) {
|
|
126
|
-
this.modelOffset = Math.min(Math.max(0, this.state.snapshot.models.length -
|
|
220
|
+
this.modelOffset = Math.min(Math.max(0, this.state.snapshot.models.length - this.pageSize), this.modelOffset + 1);
|
|
127
221
|
this.options.requestRender();
|
|
128
222
|
} else if (matchesKey(data, Key.up)) {
|
|
129
223
|
this.modelOffset = Math.max(0, this.modelOffset - 1);
|
|
@@ -146,14 +240,21 @@ export class StatsWidget implements Component {
|
|
|
146
240
|
return lines.length > 0 ? lines : [""];
|
|
147
241
|
}
|
|
148
242
|
|
|
243
|
+
private tokens(value: number): string {
|
|
244
|
+
return formatTokens(value, this.exact);
|
|
245
|
+
}
|
|
246
|
+
|
|
149
247
|
private renderContent(width: number): string[] {
|
|
150
248
|
const { theme } = this.options;
|
|
151
249
|
const lines: string[] = [];
|
|
152
250
|
const selected = (text: string) => theme.bg("selectedBg", theme.fg("accent", theme.bold(` ${text} `)));
|
|
153
251
|
const normal = (text: string) => theme.fg("muted", ` ${text} `);
|
|
252
|
+
const title = " PI STATS ";
|
|
154
253
|
lines.push(theme.fg("borderAccent", "─".repeat(Math.max(1, width))));
|
|
254
|
+
const rangeChip = RANGE_LABELS[this.range];
|
|
255
|
+
const spacer = " ".repeat(Math.max(1, width - visibleWidth(`${title} Overview Models `) - visibleWidth(rangeChip) - 1));
|
|
155
256
|
lines.push(
|
|
156
|
-
`${theme.fg("accent", theme.bold(
|
|
257
|
+
`${theme.fg("accent", theme.bold(title))} ${this.tab === "overview" ? selected("Overview") : normal("Overview")} ${this.tab === "models" ? selected("Models") : normal("Models")}${spacer}${theme.fg("dim", rangeChip)}`,
|
|
157
258
|
);
|
|
158
259
|
|
|
159
260
|
if (this.state.kind === "loading") {
|
|
@@ -172,79 +273,114 @@ export class StatsWidget implements Component {
|
|
|
172
273
|
|
|
173
274
|
const snapshot = this.state.snapshot;
|
|
174
275
|
lines.push(
|
|
175
|
-
` ${theme.fg("
|
|
276
|
+
` ${theme.fg("accent", theme.bold(this.tokens(totalTokens(snapshot.allTime))))} ${theme.fg("muted", "tokens all time")} ${theme.fg("dim", `· ${formatCost(snapshot.allTime.cost)} · ${cacheHit(snapshot.allTime)} cache hit`)}`,
|
|
176
277
|
);
|
|
177
278
|
lines.push(...(this.tab === "overview" ? this.renderOverview(snapshot, width) : this.renderModels(snapshot, width)));
|
|
178
279
|
const scanWarnings: string[] = [];
|
|
179
280
|
if (snapshot.diagnostics.unreadableFiles > 0) scanWarnings.push(`${snapshot.diagnostics.unreadableFiles} unreadable file(s)`);
|
|
180
281
|
if (snapshot.diagnostics.malformedLines > 0) scanWarnings.push(`${snapshot.diagnostics.malformedLines} malformed line(s)`);
|
|
181
282
|
const warning = scanWarnings.length > 0 ? theme.fg("warning", ` · ⚠ ${scanWarnings.join(" · ")}`) : "";
|
|
182
|
-
|
|
283
|
+
const exactHint = this.exact ? "e compact" : "e exact";
|
|
284
|
+
lines.push(theme.fg("dim", ` Tab/←→ switch · r range · ${exactHint} · u refresh · Esc close`) + warning);
|
|
183
285
|
lines.push(theme.fg("borderMuted", "─".repeat(Math.max(1, width))));
|
|
184
286
|
return lines;
|
|
185
287
|
}
|
|
186
288
|
|
|
187
|
-
private
|
|
289
|
+
private renderScopes(snapshot: StatsSnapshot, width: number): string[] {
|
|
188
290
|
const { theme } = this.options;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
291
|
+
const scopes: Array<[string, UsageTotals]> = [["Current session", snapshot.currentSession]];
|
|
292
|
+
if (snapshot.range !== "all") scopes.push([RANGE_LABELS[snapshot.range], snapshot.period]);
|
|
293
|
+
scopes.push(["All time", snapshot.allTime]);
|
|
294
|
+
|
|
295
|
+
const column = (header: string, drop: number, label: string, pick: (usage: UsageTotals) => string): TableColumn => ({
|
|
296
|
+
header,
|
|
297
|
+
drop,
|
|
298
|
+
label,
|
|
299
|
+
align: header === "Scope" ? "left" : "right",
|
|
300
|
+
cells: scopes.map(([, usage]) => pick(usage)),
|
|
301
|
+
});
|
|
302
|
+
const table = layoutTable(
|
|
303
|
+
[
|
|
304
|
+
{ header: "Scope", cells: scopes.map(([name]) => name), align: "left", drop: 0 },
|
|
305
|
+
column("Tokens", 0, "total", (usage) => this.tokens(totalTokens(usage))),
|
|
306
|
+
column("Input", 4, "in", (usage) => this.tokens(usage.input)),
|
|
307
|
+
column("Output", 4, "out", (usage) => this.tokens(usage.output)),
|
|
308
|
+
column("Cache R", 5, "cache r", (usage) => this.tokens(usage.cacheRead)),
|
|
309
|
+
column("Cache W", 6, "cache w", (usage) => this.tokens(usage.cacheWrite)),
|
|
310
|
+
column("Reason", 7, "reason ⊂ output", (usage) => this.tokens(usage.reasoning)),
|
|
311
|
+
column("Cache hit", 3, "cache hit", (usage) => cacheHit(usage)),
|
|
312
|
+
column("Cost", 2, "cost", (usage) => formatCost(usage.cost)),
|
|
313
|
+
],
|
|
314
|
+
scopes.length,
|
|
315
|
+
width,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const lines = [theme.fg("dim", table.header)];
|
|
319
|
+
table.rows.forEach((row, index) => {
|
|
320
|
+
lines.push(index === table.rows.length - 1 ? theme.fg("text", theme.bold(row)) : theme.fg("text", row));
|
|
321
|
+
for (const extra of table.extras[index] ?? []) lines.push(theme.fg("dim", extra));
|
|
322
|
+
});
|
|
323
|
+
return lines;
|
|
200
324
|
}
|
|
201
325
|
|
|
202
326
|
private renderOverview(snapshot: StatsSnapshot, width: number): string[] {
|
|
203
327
|
const { theme } = this.options;
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
328
|
+
const scopes = this.renderScopes(snapshot, width);
|
|
329
|
+
|
|
330
|
+
const metrics: Array<[string, string, string]> = [
|
|
331
|
+
["Sessions", "Sessions", `${snapshot.sessionCount} (${snapshot.mainSessionCount} main · ${snapshot.subagentSessionCount} sub)`],
|
|
332
|
+
["Active days", "Active", String(snapshot.activeDays)],
|
|
333
|
+
["Streak", "Streak", `${snapshot.currentStreak}d current · ${snapshot.longestStreak}d longest`],
|
|
334
|
+
["Busiest", "Busiest", snapshot.mostActiveDay ? `${snapshot.mostActiveDay.day} · ${this.tokens(snapshot.mostActiveDay.total)}` : "—"],
|
|
335
|
+
["Favorite", "Favorite", snapshot.favoriteModel ?? "—"],
|
|
336
|
+
["Longest span", "Longest", formatDuration(snapshot.longestSessionMs)],
|
|
207
337
|
];
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const leftText = `${theme.fg("dim", left[0])}: ${theme.fg("text", left[1])}`;
|
|
226
|
-
const padded = leftText + " ".repeat(Math.max(1, 42 - visibleWidth(leftText)));
|
|
227
|
-
lines.push(` ${padded}${right ? `${theme.fg("dim", right[0])}: ${theme.fg("text", right[1])}` : ""}`);
|
|
228
|
-
}
|
|
338
|
+
const left = metrics.filter((_, index) => index % 2 === 0);
|
|
339
|
+
const right = metrics.filter((_, index) => index % 2 === 1);
|
|
340
|
+
const plan = (index: 0 | 1) => {
|
|
341
|
+
const labels = Math.max(...metrics.map((metric) => visibleWidth(metric[index])));
|
|
342
|
+
const columnWidth = (rows: typeof metrics) => labels + 2 + Math.max(...rows.map((metric) => visibleWidth(metric[2])));
|
|
343
|
+
return { index, leftWidth: columnWidth(left), fits: 1 + columnWidth(left) + 2 + columnWidth(right) <= width };
|
|
344
|
+
};
|
|
345
|
+
const chosen = [plan(0), plan(1)].find((candidate) => candidate.fits);
|
|
346
|
+
const labelWidth = Math.max(...metrics.map((metric) => visibleWidth(metric[chosen?.index ?? 0])));
|
|
347
|
+
const cell = (metric: [string, string, string], pad: number) =>
|
|
348
|
+
`${theme.fg("dim", padRight(metric[chosen?.index ?? 0], labelWidth))} ${theme.fg("text", pad > 0 ? padRight(metric[2], pad) : metric[2])}`;
|
|
349
|
+
const metricLines: string[] = [];
|
|
350
|
+
if (chosen) {
|
|
351
|
+
left.forEach((metric, index) => {
|
|
352
|
+
const other = right[index];
|
|
353
|
+
metricLines.push(` ${cell(metric, chosen.leftWidth - labelWidth)}${other ? cell(other, 0) : ""}`.trimEnd());
|
|
354
|
+
});
|
|
229
355
|
} else {
|
|
230
|
-
for (const
|
|
231
|
-
}
|
|
232
|
-
if (width >= 72 && snapshot.range === "all" && snapshot.allTime.input + snapshot.allTime.output > 0) {
|
|
233
|
-
const books = (snapshot.allTime.input + snapshot.allTime.output) / 400_000;
|
|
234
|
-
lines.push(theme.fg("muted", ` Your input + output are roughly ${books.toFixed(books >= 10 ? 0 : 1)}× the tokens in A Game of Thrones.`));
|
|
356
|
+
for (const metric of metrics) metricLines.push(` ${cell(metric, 0)}`.trimEnd());
|
|
235
357
|
}
|
|
236
|
-
|
|
358
|
+
|
|
359
|
+
const books = (snapshot.allTime.input + snapshot.allTime.output) / 400_000;
|
|
360
|
+
const comparison =
|
|
361
|
+
width >= 90 && snapshot.range === "all" && books >= 0.1
|
|
362
|
+
? [theme.fg("muted", ` Your input + output are roughly ${books.toFixed(books >= 10 ? 0 : 1)}× the tokens in A Game of Thrones.`)]
|
|
363
|
+
: [];
|
|
364
|
+
const spent = CHROME_ROWS + scopes.length + metricLines.length + comparison.length;
|
|
365
|
+
const heatmap = width >= 72 && spent + HEATMAP_ROWS <= MAX_ROWS ? this.renderHeatmap(snapshot, width) : [];
|
|
366
|
+
return [...scopes, ...heatmap, ...metricLines, ...comparison];
|
|
237
367
|
}
|
|
238
368
|
|
|
239
369
|
private renderHeatmap(snapshot: StatsSnapshot, width: number): string[] {
|
|
240
370
|
const { theme } = this.options;
|
|
241
|
-
const
|
|
371
|
+
const cells = Math.max(0, width - HEATMAP_LABEL_WIDTH - 1);
|
|
372
|
+
if (cells < HEATMAP_MIN_WEEKS) return [];
|
|
242
373
|
const today = new Date();
|
|
243
374
|
today.setHours(12, 0, 0, 0);
|
|
244
375
|
const end = new Date(today);
|
|
245
|
-
end.setDate(end.getDate() + (
|
|
376
|
+
end.setDate(end.getDate() + (6 - ((end.getDay() + 6) % 7)));
|
|
377
|
+
const active = snapshot.days.filter((day) => day.total > 0);
|
|
378
|
+
const first = active[0] ? new Date(`${active[0].day}T12:00:00`) : today;
|
|
379
|
+
const span = Math.ceil((end.getTime() - first.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1;
|
|
380
|
+
const weeks = Math.max(HEATMAP_MIN_WEEKS, Math.min(cells, HEATMAP_MAX_WEEKS, Math.max(span, HEATMAP_MIN_WEEKS)));
|
|
246
381
|
const start = new Date(end);
|
|
247
382
|
start.setDate(start.getDate() - weeks * 7 + 1);
|
|
383
|
+
|
|
248
384
|
const totals = new Map(snapshot.days.map((day) => [day.day, day.total]));
|
|
249
385
|
const maximum = Math.max(0, ...snapshot.days.map((day) => day.total));
|
|
250
386
|
const glyph = (value: number): string => {
|
|
@@ -255,14 +391,36 @@ export class StatsWidget implements Component {
|
|
|
255
391
|
if (level === 3) return theme.fg("warning", "▓");
|
|
256
392
|
return theme.fg("accent", "█");
|
|
257
393
|
};
|
|
258
|
-
|
|
394
|
+
|
|
395
|
+
const legend = `less ${theme.fg("dim", "·")}${theme.fg("muted", "░")}${theme.fg("success", "▒")}${theme.fg("warning", "▓")}${theme.fg("accent", "█")} more`;
|
|
396
|
+
const heading = ` ${theme.fg("muted", "Activity")}`;
|
|
397
|
+
const gap = Math.max(1, width - visibleWidth(heading) - visibleWidth(legend) - 1);
|
|
398
|
+
const lines = [`${heading}${" ".repeat(gap)}${theme.fg("dim", legend)}`];
|
|
399
|
+
|
|
400
|
+
let months = " ".repeat(HEATMAP_LABEL_WIDTH);
|
|
401
|
+
let lastLabel = -4;
|
|
402
|
+
for (let week = 0; week < weeks; week++) {
|
|
403
|
+
const date = new Date(start);
|
|
404
|
+
date.setDate(date.getDate() + week * 7);
|
|
405
|
+
const previous = new Date(date);
|
|
406
|
+
previous.setDate(previous.getDate() - 7);
|
|
407
|
+
if ((week === 0 || date.getMonth() !== previous.getMonth()) && week - lastLabel >= 4 && week + 3 <= weeks) {
|
|
408
|
+
months += MONTHS[date.getMonth()]!;
|
|
409
|
+
lastLabel = week;
|
|
410
|
+
week += 2;
|
|
411
|
+
} else {
|
|
412
|
+
months += " ";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
lines.push(theme.fg("dim", months));
|
|
416
|
+
|
|
259
417
|
for (let weekday = 0; weekday < 7; weekday++) {
|
|
260
418
|
const label = weekday === 0 ? "Mon" : weekday === 2 ? "Wed" : weekday === 4 ? "Fri" : " ";
|
|
261
|
-
let row = ` ${theme.fg("dim", label)}
|
|
419
|
+
let row = ` ${theme.fg("dim", label)} `;
|
|
262
420
|
for (let week = 0; week < weeks; week++) {
|
|
263
421
|
const date = new Date(start);
|
|
264
422
|
date.setDate(date.getDate() + week * 7 + weekday);
|
|
265
|
-
row += glyph(totals.get(localDay(date.getTime())) ?? 0);
|
|
423
|
+
row += date.getTime() > today.getTime() ? " " : glyph(totals.get(localDay(date.getTime())) ?? 0);
|
|
266
424
|
}
|
|
267
425
|
lines.push(row);
|
|
268
426
|
}
|
|
@@ -272,30 +430,76 @@ export class StatsWidget implements Component {
|
|
|
272
430
|
private renderModels(snapshot: StatsSnapshot, width: number): string[] {
|
|
273
431
|
const { theme } = this.options;
|
|
274
432
|
if (snapshot.models.length === 0) return [theme.fg("muted", ` No model usage in ${RANGE_LABELS[snapshot.range].toLowerCase()}.`)];
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
433
|
+
|
|
434
|
+
const totals = snapshot.models.reduce<UsageTotals & { total: number }>(
|
|
435
|
+
(sum, model) => ({
|
|
436
|
+
input: sum.input + model.input,
|
|
437
|
+
output: sum.output + model.output,
|
|
438
|
+
cacheRead: sum.cacheRead + model.cacheRead,
|
|
439
|
+
cacheWrite: sum.cacheWrite + model.cacheWrite,
|
|
440
|
+
reasoning: sum.reasoning + model.reasoning,
|
|
441
|
+
cost: sum.cost + model.cost,
|
|
442
|
+
calls: sum.calls + model.calls,
|
|
443
|
+
total: sum.total + model.total,
|
|
444
|
+
}),
|
|
445
|
+
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0, calls: 0, total: 0 },
|
|
446
|
+
);
|
|
447
|
+
const summaryRow: ModelStats = { ...totals, model: `${snapshot.models.length} model${snapshot.models.length === 1 ? "" : "s"}`, share: 1 };
|
|
448
|
+
// One layout over every row keeps column widths stable while scrolling and lets the totals row align exactly.
|
|
449
|
+
const entries = [...snapshot.models, summaryRow];
|
|
450
|
+
const summaryIndex = entries.length - 1;
|
|
451
|
+
const table = layoutTable(this.modelColumns(entries, snapshot.models), entries.length, width);
|
|
452
|
+
|
|
453
|
+
const rowsPerModel = 1 + Math.max(0, ...table.extras.slice(0, summaryIndex).map((extra) => extra.length));
|
|
454
|
+
const summaryRows = 2 + (table.extras[summaryIndex]?.length ?? 0);
|
|
455
|
+
const budget = MAX_ROWS - CHROME_ROWS - 1 - summaryRows - 1;
|
|
456
|
+
this.pageSize = Math.max(MIN_MODEL_PAGE, Math.min(MAX_MODEL_PAGE, Math.floor(budget / rowsPerModel)));
|
|
457
|
+
const start = Math.min(this.modelOffset, Math.max(0, snapshot.models.length - this.pageSize));
|
|
458
|
+
this.modelOffset = start;
|
|
459
|
+
const end = Math.min(snapshot.models.length, start + this.pageSize);
|
|
460
|
+
|
|
461
|
+
const lines = [theme.fg("dim", table.header)];
|
|
462
|
+
for (let index = start; index < end; index++) {
|
|
463
|
+
lines.push(theme.fg("text", table.rows[index] ?? ""));
|
|
464
|
+
for (const extra of table.extras[index] ?? []) lines.push(theme.fg("dim", extra));
|
|
465
|
+
}
|
|
466
|
+
lines.push(theme.fg("borderMuted", ` ${"─".repeat(Math.max(1, Math.min(width - 1, table.width)))}`));
|
|
467
|
+
lines.push(theme.fg("muted", table.rows[summaryIndex] ?? ""));
|
|
468
|
+
for (const extra of table.extras[summaryIndex] ?? []) lines.push(theme.fg("dim", extra));
|
|
469
|
+
if (snapshot.models.length > this.pageSize) {
|
|
470
|
+
lines.push(theme.fg("dim", ` ↑↓ models ${start + 1}-${end} of ${snapshot.models.length}`));
|
|
297
471
|
}
|
|
298
|
-
if (snapshot.models.length > MODEL_PAGE_SIZE) lines.push(theme.fg("dim", ` ↑↓ models ${start + 1}-${Math.min(snapshot.models.length, start + MODEL_PAGE_SIZE)} of ${snapshot.models.length}`));
|
|
299
472
|
return lines;
|
|
300
473
|
}
|
|
474
|
+
|
|
475
|
+
private modelColumns(rows: readonly ModelStats[], ranked: readonly ModelStats[]): TableColumn[] {
|
|
476
|
+
const maximum = Math.max(0, ...ranked.map((model) => model.share));
|
|
477
|
+
const isSummary = (index: number) => index >= ranked.length;
|
|
478
|
+
const bar = (share: number): string => {
|
|
479
|
+
if (maximum <= 0) return "";
|
|
480
|
+
const filled = Math.max(0, Math.min(6, (share / maximum) * 6));
|
|
481
|
+
const whole = Math.floor(filled);
|
|
482
|
+
const eighths = Math.round((filled - whole) * 8);
|
|
483
|
+
const partial = eighths > 0 ? "▏▎▍▌▋▊▉█"[eighths - 1]! : "";
|
|
484
|
+
return padRight("█".repeat(whole) + partial, 6);
|
|
485
|
+
};
|
|
486
|
+
return [
|
|
487
|
+
{ header: "Model", cells: rows.map((model) => model.model), align: "left", drop: 0 },
|
|
488
|
+
{ header: "Calls", cells: rows.map((model) => model.calls.toLocaleString("en-US")), align: "right", drop: 1, label: "calls" },
|
|
489
|
+
{ header: "Share", cells: rows.map((model) => `${(model.share * 100).toFixed(1)}%`), align: "right", drop: 2, label: "share" },
|
|
490
|
+
{ header: "", cells: rows.map((model, index) => (isSummary(index) ? "" : bar(model.share))), align: "left", drop: 3, decorative: true },
|
|
491
|
+
{ header: "Total", cells: rows.map((model) => this.tokens(model.total)), align: "right", drop: 0 },
|
|
492
|
+
{ header: "Input", cells: rows.map((model) => this.tokens(model.input)), align: "right", drop: 4, label: "in" },
|
|
493
|
+
{ header: "Output", cells: rows.map((model) => this.tokens(model.output)), align: "right", drop: 4, label: "out" },
|
|
494
|
+
{
|
|
495
|
+
header: "Cache R/W",
|
|
496
|
+
cells: rows.map((model) => `${this.tokens(model.cacheRead)}/${this.tokens(model.cacheWrite)}`),
|
|
497
|
+
align: "right",
|
|
498
|
+
drop: 5,
|
|
499
|
+
label: "cache",
|
|
500
|
+
},
|
|
501
|
+
{ header: "Reason", cells: rows.map((model) => this.tokens(model.reasoning)), align: "right", drop: 6, label: "reason ⊂ output" },
|
|
502
|
+
{ header: "Cost", cells: rows.map((model) => formatCost(model.cost)), align: "right", drop: 1, label: "cost" },
|
|
503
|
+
];
|
|
504
|
+
}
|
|
301
505
|
}
|