@hank-warren/pi-stats 0.1.0 → 0.2.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 +6 -1
- package/package.json +1 -1
- 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
|
|
package/package.json
CHANGED
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
|
}
|