@nredd/pi-session-kit 0.0.0-stage → 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.
Files changed (51) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +167 -2
  3. package/dist/pi-stats-export.mjs +657 -0
  4. package/docs/stats-snapshots.md +122 -0
  5. package/package.json +45 -4
  6. package/src/child.ts +79 -0
  7. package/src/config.ts +64 -0
  8. package/src/diff/component.ts +214 -0
  9. package/src/diff/git.ts +289 -0
  10. package/src/diff/index.ts +65 -0
  11. package/src/diff/parse.ts +180 -0
  12. package/src/diff/view.ts +98 -0
  13. package/src/goal/index.ts +343 -0
  14. package/src/goal/state.ts +270 -0
  15. package/src/goal/subagents.ts +77 -0
  16. package/src/index.ts +31 -0
  17. package/src/loop/index.ts +151 -0
  18. package/src/loop/state.ts +107 -0
  19. package/src/monitor/README.md +12 -0
  20. package/src/monitor/coalescer.ts +75 -0
  21. package/src/monitor/index.ts +213 -0
  22. package/src/monitor/manager.ts +322 -0
  23. package/src/monitor/matcher.ts +113 -0
  24. package/src/monitor/messages.ts +93 -0
  25. package/src/monitor/output.ts +41 -0
  26. package/src/monitor/pidfiles.ts +410 -0
  27. package/src/monitor/process.ts +124 -0
  28. package/src/monitor/ring.ts +52 -0
  29. package/src/monitor/splitter.ts +57 -0
  30. package/src/monitor/types.ts +35 -0
  31. package/src/monitor/validate.ts +59 -0
  32. package/src/monitor/widget.ts +75 -0
  33. package/src/output.ts +25 -0
  34. package/src/rules/index.ts +94 -0
  35. package/src/rules/logic.ts +96 -0
  36. package/src/shared.ts +177 -0
  37. package/src/stats/aggregate.ts +287 -0
  38. package/src/stats/chart.ts +247 -0
  39. package/src/stats/cli.ts +50 -0
  40. package/src/stats/component.ts +496 -0
  41. package/src/stats/days.ts +46 -0
  42. package/src/stats/format.ts +49 -0
  43. package/src/stats/heatmap.ts +98 -0
  44. package/src/stats/index.ts +81 -0
  45. package/src/stats/library.ts +4 -0
  46. package/src/stats/load.ts +512 -0
  47. package/src/stats/palette.ts +22 -0
  48. package/src/stats/snapshot.ts +497 -0
  49. package/src/titles/aliases.ts +20 -0
  50. package/src/titles/index.ts +82 -0
  51. package/src/titles/logic.ts +160 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The `/stats` overlay: Claude Code's Stats pane rebuilt over pi sessions. Overview has the activity
3
+ * heatmap, headline numbers and the token chart; Models has the chart and the per-model breakdown
4
+ * grouped by provider. `r` cycles the date range, `m` / `M` the provider/model filter.
5
+ */
6
+
7
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
8
+ import { type Color, type Component, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { sanitize } from "../diff/view.ts";
10
+ import {
11
+ chartSeries,
12
+ dailySeries,
13
+ dailyTotals,
14
+ type Filter,
15
+ filterCycle,
16
+ filterLabel,
17
+ hourlySeries,
18
+ inRange,
19
+ isHourly,
20
+ type ModelTotals,
21
+ matches,
22
+ modelName,
23
+ modelTotals,
24
+ overview,
25
+ RANGE_LABEL,
26
+ RANGES,
27
+ type Range,
28
+ rangeStart,
29
+ TOKENS_NOTE,
30
+ tokensOf,
31
+ } from "./aggregate.ts";
32
+ import { bucketize, GUTTER, plot, TICKS, xAxis } from "./chart.ts";
33
+ import { dayKey, shortDate } from "./days.ts";
34
+ import { duration, formatTokens, money, percent } from "./format.ts";
35
+ import { DAY_LABEL_WIDTH, heatmap, weeksFor, YEAR_WEEKS } from "./heatmap.ts";
36
+ import type { Call, Session } from "./load.ts";
37
+ import { seriesPalette } from "./palette.ts";
38
+
39
+ type ThemeLike = Pick<Theme, "fg" | "bg" | "bold" | "style" | "colors">;
40
+ export type Tab = "Overview" | "Models";
41
+ const TABS: readonly Tab[] = ["Overview", "Models"];
42
+ /** Chart lines, at most; their colours are shared by the chart, its legend and the model list. */
43
+ const SERIES = 3;
44
+ /** Narrowest plot `plot` can draw; only panes too narrow for a 6-week heatmap hit it. */
45
+ const MIN_PLOT_WIDTH = 2;
46
+ /** Share of the pane the heatmap and chart span. */
47
+ export const CONTENT_SHARE = 0.75;
48
+
49
+ /**
50
+ * Columns the heatmap and the chart both span in a `paneWidth` pane (after the 1-column margin):
51
+ * `CONTENT_SHARE` of it, but never narrower than a year of heatmap when that fits.
52
+ */
53
+ export function contentWidth(paneWidth: number): number {
54
+ const inner = Math.max(1, paneWidth - 1);
55
+ return Math.min(inner, Math.max(Math.round(inner * CONTENT_SHARE), DAY_LABEL_WIDTH + YEAR_WEEKS));
56
+ }
57
+
58
+ /** Tallest chart, in rows above the zero line. */
59
+ const MAX_CHART_HEIGHT = 40;
60
+
61
+ /** Chart rows for `available` lines: whole tick steps (so every tick is a row), 5 to 40. */
62
+ export function chartHeight(available: number): number {
63
+ const steps = Math.floor(available / TICKS) * TICKS;
64
+ return Math.max(TICKS, Math.min(MAX_CHART_HEIGHT, steps));
65
+ }
66
+
67
+ /** Model list goes two-up from this width. */
68
+ const TWO_COLUMN_WIDTH = 80;
69
+
70
+ export interface StatsViewOptions {
71
+ calls: readonly Call[];
72
+ sessions: readonly Session[];
73
+ theme: ThemeLike;
74
+ rows: () => number;
75
+ requestRender: () => void;
76
+ onClose: () => void;
77
+ /** Unparseable assistant lines found while loading. */
78
+ badLines?: number;
79
+ /** Injected for tests; defaults to the local today. */
80
+ today?: string;
81
+ now?: number;
82
+ coverage?: string;
83
+ tab?: Tab;
84
+ }
85
+
86
+ /** One scroll step of the model list: a provider header, or one or two models side by side. */
87
+ interface ListRow {
88
+ lines: string[];
89
+ /** Positions of its models in list order, for the `1-4 of N` hint. */
90
+ models: number[];
91
+ }
92
+
93
+ export class StatsView implements Component {
94
+ private tab: Tab;
95
+ private range: Range = "all";
96
+ private filterIndex = 0;
97
+ private scroll = 0;
98
+ private readonly palettes = new Map<number, Color[]>();
99
+ private readonly filters: Filter[];
100
+ private readonly today: string;
101
+ private readonly now: number;
102
+
103
+ constructor(private readonly o: StatsViewOptions) {
104
+ this.tab = o.tab ?? "Overview";
105
+ this.now = o.now ?? Date.now();
106
+ this.filters = filterCycle(o.calls.filter((c) => c.ms <= this.now));
107
+ this.today = o.today ?? dayKey(this.now);
108
+ }
109
+
110
+ invalidate(): void {
111
+ // Nothing themed is cached: every render rebuilds colours from the live theme.
112
+ }
113
+
114
+ get state(): { tab: Tab; range: Range; filter: Filter; scroll: number } {
115
+ return { tab: this.tab, range: this.range, filter: this.filter, scroll: this.scroll };
116
+ }
117
+
118
+ private get filter(): Filter {
119
+ return this.filters[this.filterIndex] ?? { kind: "all" };
120
+ }
121
+
122
+ // --- input -------------------------------------------------------------------------------
123
+
124
+ handleInput(data: string): void {
125
+ if (data === "q" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
126
+ this.o.onClose();
127
+ return;
128
+ }
129
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
130
+ this.tab = this.tab === "Overview" ? "Models" : "Overview";
131
+ this.scroll = 0;
132
+ } else if (data === "r") {
133
+ this.range = RANGES[(RANGES.indexOf(this.range) + 1) % RANGES.length] ?? "all";
134
+ this.scroll = 0;
135
+ } else if (data === "m") this.stepFilter(1);
136
+ else if (data === "M" || matchesKey(data, Key.shift("m"))) this.stepFilter(-1);
137
+ else if (data === "j" || matchesKey(data, Key.down)) this.scroll++;
138
+ else if (data === "k" || matchesKey(data, Key.up)) this.scroll = Math.max(0, this.scroll - 1);
139
+ else return;
140
+ this.o.requestRender();
141
+ }
142
+
143
+ private stepFilter(delta: number): void {
144
+ const n = this.filters.length;
145
+ this.filterIndex = (((this.filterIndex + delta) % n) + n) % n;
146
+ this.scroll = 0;
147
+ }
148
+
149
+ // --- render ------------------------------------------------------------------------------
150
+
151
+ render(width: number): string[] {
152
+ const w = Math.max(1, Math.floor(width));
153
+ const rows = Math.max(4, Math.floor(this.o.rows()));
154
+ const header = [this.tabsLine(), this.paint("muted", this.o.coverage ?? "local-only · never synced")];
155
+ const room = Math.max(0, rows - header.length - 2);
156
+ let body: string[];
157
+ let hint = "";
158
+ if (this.tab === "Overview") body = this.scrolled(this.overviewBody(w, room), room);
159
+ else {
160
+ const models = this.modelsBody(w, room);
161
+ body = this.scrolled(models.lines, room);
162
+ hint = this.modelHint(models.owners.slice(this.scroll, this.scroll + room), models.count);
163
+ }
164
+ while (body.length < room) body.push("");
165
+ return [...header, ...body, "", this.help(hint)].map((line) => truncateToWidth(` ${line}`, w, ""));
166
+ }
167
+
168
+ /**
169
+ * `↑ ↓ 3-6 of 14 models` from the list positions on screen; arrows only where more models are.
170
+ * The list follows the chart, so a window with no model in it is always above the list. Empty when
171
+ * every model is visible. Lives in the help row, so it shows however short the pane.
172
+ */
173
+ private modelHint(owners: readonly (readonly number[])[], count: number): string {
174
+ const shown = owners.flat();
175
+ if (count === 0 || new Set(shown).size === count) return "";
176
+ if (shown.length === 0) return ` ↓ ${count} models below`;
177
+ const first = Math.min(...shown) + 1;
178
+ const last = Math.max(...shown) + 1;
179
+ return `${first > 1 ? "↑" : " "} ${last < count ? "↓" : " "} ${first}-${last} of ${count} models`;
180
+ }
181
+
182
+ /** Clamp `scroll` and cut a `room`-line window out of `lines`. */
183
+ private scrolled(lines: string[], room: number): string[] {
184
+ this.scroll = Math.min(this.scroll, Math.max(0, lines.length - room));
185
+ return lines.slice(this.scroll, this.scroll + room);
186
+ }
187
+
188
+ private paint(color: ThemeColor, text: string): string {
189
+ return this.o.theme.fg(color, sanitize(text));
190
+ }
191
+
192
+ /**
193
+ * Paint in colour `index` of a `count`-colour series palette. Hues are spread evenly for the series
194
+ * actually drawn, so two providers get opposite hues (blue and orange on most themes).
195
+ */
196
+ private paintSeries(index: number, count: number, text: string): string {
197
+ let palette = this.palettes.get(count);
198
+ if (!palette) {
199
+ palette = seriesPalette(this.o.theme.colors.accent, count);
200
+ this.palettes.set(count, palette);
201
+ }
202
+ const fg = palette[index];
203
+ return fg ? this.o.theme.style(sanitize(text), { fg }) : this.paint("muted", text);
204
+ }
205
+
206
+ private tabsLine(): string {
207
+ const t = this.o.theme;
208
+ const tabs = TABS.map((tab) => (tab === this.tab ? t.bg("selectedBg", t.bold(` ${tab} `)) : ` ${tab} `)).join(" ");
209
+ const bad = this.o.badLines ? ` ${this.paint("warning", `! ${this.o.badLines} unreadable lines`)}` : "";
210
+ return `${t.bold(this.paint("accent", "/stats"))} ${tabs}${bad}`;
211
+ }
212
+
213
+ private selectors(): string {
214
+ const t = this.o.theme;
215
+ const ranges = RANGES.map((r) =>
216
+ r === this.range ? t.bold(this.paint("accent", RANGE_LABEL[r])) : this.paint("dim", RANGE_LABEL[r]),
217
+ ).join(this.paint("dim", " · "));
218
+ return `${ranges} ${this.paint("muted", "filter:")} ${t.bold(sanitize(filterLabel(this.filter)))}`;
219
+ }
220
+
221
+ private help(hint = ""): string {
222
+ const keys = this.paint("dim", "tab tabs · r dates · m/M filter · ↑↓ scroll · q close");
223
+ return hint ? `${this.paint("muted", hint)} ${keys}` : keys;
224
+ }
225
+
226
+ private scoped(): { all: Call[]; ranged: Call[] } {
227
+ const all = this.o.calls.filter((c) => matches(this.filter, c) && inRange("all", this.today, c.ms, this.now));
228
+ return { all, ranged: all.filter((c) => inRange(this.range, this.today, c.ms, this.now)) };
229
+ }
230
+
231
+ private overviewBody(width: number, room: number): string[] {
232
+ const { all, ranged } = this.scoped();
233
+ const t = this.o.theme;
234
+ const out: string[] = [];
235
+ const daily = dailyTotals(all);
236
+ if (!isHourly(this.range))
237
+ out.push(
238
+ ...heatmap(daily, this.today, contentWidth(width), (color, text) =>
239
+ color === "heat" ? this.paint("accent", text) : this.paint(color === "empty" ? "dim" : "muted", text),
240
+ ),
241
+ );
242
+ out.push("", this.selectors(), "");
243
+ if (ranged.length === 0) {
244
+ out.push(this.paint("muted", "No usage in this range."));
245
+ return out;
246
+ }
247
+ const ov = overview(ranged, this.o.sessions, this.range, this.today, daily.keys());
248
+ const v = (s: string) => this.paint("accent", s);
249
+ const pairs: [string, string][] = [
250
+ [
251
+ `Favorite model: ${t.bold(v(ov.favorite ?? "-"))}`,
252
+ `Total tokens: ${v(formatTokens(ov.totalTokens))} ${this.paint("muted", TOKENS_NOTE)}`,
253
+ ],
254
+ [`Sessions: ${v(String(ov.sessions))}`, `Longest session: ${v(duration(ov.longestSessionMs))}`],
255
+ [
256
+ `Active days: ${v(String(ov.activeDays))}${this.paint("muted", `/${ov.rangeDays}`)}`,
257
+ `Longest streak: ${t.bold(v(String(ov.longestStreak)))} ${ov.longestStreak === 1 ? "day" : "days"}`,
258
+ ],
259
+ [
260
+ `Most active day: ${v(ov.mostActiveDay ? shortDate(ov.mostActiveDay) : "-")}`,
261
+ `Current streak: ${t.bold(v(String(ov.currentStreak)))} ${ov.currentStreak === 1 ? "day" : "days"}`,
262
+ ],
263
+ [
264
+ `Cache tokens: ${v(formatTokens(ov.cacheRead))} read · ${v(formatTokens(ov.cacheWrite))} write`,
265
+ `Notional cost: ${v(money(ov.cost))}`,
266
+ ],
267
+ ];
268
+ const col = Math.max(36, Math.min(48, Math.floor(width / 2)));
269
+ const twoCol = width >= col * 2;
270
+ for (const [left, right] of pairs) {
271
+ if (twoCol) out.push(`${left}${" ".repeat(Math.max(2, col - visibleWidth(left)))}${right}`);
272
+ else out.push(left, right);
273
+ }
274
+ // Grow the chart into whatever the pane has left, in whole tick steps.
275
+ const smallest = this.chart(ranged, width, TICKS).lines;
276
+ const height = chartHeight(room - out.length - 1 - smallest.length + TICKS);
277
+ out.push("", ...(height === TICKS ? smallest : this.chart(ranged, width, height).lines));
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * "Tokens per <bucket>", x-axis, peak/average caption and legend. One line per provider under
283
+ * the "all" filter, else one per model; top three.
284
+ */
285
+ private chart(
286
+ ranged: readonly Call[],
287
+ width: number,
288
+ height: number,
289
+ ): { lines: string[]; colors: Map<string, number> } {
290
+ const t = this.o.theme;
291
+ const out: string[] = [];
292
+ const { by, keys: top } = chartSeries(this.filter, ranged, SERIES);
293
+ const colors = new Map(top.map((k, i): [string, number] => [k.key, i]));
294
+ if (isHourly(this.range)) {
295
+ const series = hourlySeries(ranged, this.range, this.now, by === "provider" ? (c) => c.provider : modelName);
296
+ const plotWidth = Math.max(MIN_PLOT_WIDTH, contentWidth(width) - GUTTER);
297
+ const p = plot(
298
+ top.map((k, i) => ({ values: series.map((s) => s.byKey.get(k.key) ?? 0), color: i })),
299
+ {
300
+ width: plotWidth,
301
+ height,
302
+ paint: (color, text) =>
303
+ color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text),
304
+ },
305
+ );
306
+ out.push(t.bold("Tokens per hour") + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`), ...p.lines);
307
+ out.push(this.paint("muted", `From: ${series[0]?.day}`), this.paint("muted", `To: ${series.at(-1)?.day}`));
308
+ // Repeated wall-clock hours get an explicit offset-bearing label, even between axis edges.
309
+ const labels = new Map<string, string>();
310
+ for (const point of series) {
311
+ const local = point.day.split(" UTC")[0] ?? point.day;
312
+ const prior = labels.get(local);
313
+ if (prior)
314
+ out.push(this.paint("muted", `Repeated hour: ${prior}`), this.paint("muted", ` ${point.day}`));
315
+ labels.set(local, point.day);
316
+ }
317
+ const peak = Math.max(0, ...series.map((s) => s.total));
318
+ const avg = series.reduce((n, s) => n + s.total, 0) / series.length;
319
+ out.push(
320
+ this.paint("muted", `total: peak ${formatTokens(peak)}/hour · avg ${formatTokens(avg)}/bucket · partial edges`),
321
+ );
322
+ out.push(
323
+ ...this.legend(
324
+ top.map((k) => k.key),
325
+ colors,
326
+ width - 1,
327
+ ),
328
+ );
329
+ return { lines: out, colors };
330
+ }
331
+ const from = rangeStart(this.range, this.today) ?? dayKey(ranged[0]?.ms ?? Date.now());
332
+ const series = dailySeries(ranged, from, this.today, by === "provider" ? (c) => c.provider : modelName);
333
+ const byDay = new Map(series.map((p) => [p.day, top.map((k) => p.byKey.get(k.key) ?? 0)]));
334
+ // Same total width as the heatmap above it: its day labels + weeks == the y gutter + plot.
335
+ const plotWidth = Math.max(MIN_PLOT_WIDTH, DAY_LABEL_WIDTH + weeksFor(contentWidth(width)) - GUTTER);
336
+ const bucketed = bucketize(from, this.today, (d) => byDay.get(d) ?? [], top.length, plotWidth + 1);
337
+ out.push(t.bold(`Tokens per ${bucketed.unit}`) + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`));
338
+ if (series.length < 2) {
339
+ out.push(this.paint("muted", "Not enough data for a chart yet"));
340
+ } else {
341
+ const p = plot(
342
+ top.map((k, i) => ({
343
+ values: bucketed.buckets.map((b) => b.values[i] ?? 0),
344
+ color: colors.get(k.key) ?? 0,
345
+ })),
346
+ {
347
+ width: plotWidth,
348
+ height,
349
+ paint: (color, text) =>
350
+ color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text),
351
+ },
352
+ );
353
+ out.push(...p.lines, this.paint("muted", xAxis(bucketed.buckets, plotWidth, p.gutter)));
354
+ let peak = series[0];
355
+ for (const point of series) if (point.total > (peak?.total ?? 0)) peak = point;
356
+ const avg = series.reduce((n, point) => n + point.total, 0) / series.length;
357
+ out.push(
358
+ this.paint(
359
+ "muted",
360
+ `total: peak ${formatTokens(peak?.total ?? 0)} on ${shortDate(peak?.day ?? this.today)} · avg ${formatTokens(avg)}/day`,
361
+ ),
362
+ );
363
+ }
364
+ out.push(
365
+ ...this.legend(
366
+ top.map((k) => k.key),
367
+ colors,
368
+ width - 1,
369
+ ),
370
+ );
371
+ return { lines: out, colors };
372
+ }
373
+
374
+ /** `● a · ● b · ● c`, wrapped onto more lines rather than cutting a series name off. */
375
+ private legend(keys: readonly string[], colors: ReadonlyMap<string, number>, width: number): string[] {
376
+ const sepText = " · ";
377
+ const lines: string[] = [];
378
+ let line = "";
379
+ let used = 0;
380
+ for (const key of keys) {
381
+ const cell = `${this.paintSeries(colors.get(key) ?? 0, colors.size, "●")} ${sanitize(key)}`;
382
+ const cellWidth = 2 + visibleWidth(sanitize(key));
383
+ if (used > 0 && used + sepText.length + cellWidth > width) {
384
+ lines.push(line);
385
+ line = "";
386
+ used = 0;
387
+ }
388
+ line += used > 0 ? `${this.paint("dim", sepText)}${cell}` : cell;
389
+ used += (used > 0 ? sepText.length : 0) + cellWidth;
390
+ }
391
+ if (line) lines.push(line);
392
+ return lines;
393
+ }
394
+
395
+ /**
396
+ * Chart, range line and the model list, scrolled as one body like Overview so even a short pane
397
+ * reaches every model. `owners[i]` holds the list positions of the models drawn on line `i`.
398
+ */
399
+ private modelsBody(width: number, room: number): { lines: string[]; owners: number[][]; count: number } {
400
+ const { ranged } = this.scoped();
401
+ const totals = modelTotals(ranged);
402
+ if (totals.length === 0) {
403
+ const lines = [this.selectors(), "", this.paint("muted", "No usage in this range.")];
404
+ return { lines, owners: lines.map(() => []), count: 0 };
405
+ }
406
+ // The list scrolls under the chart, so the chart gets about half the pane.
407
+ const chart = this.chart(ranged, width, chartHeight(Math.floor(room / 2) - 5));
408
+ const head = [...chart.lines, "", this.selectors(), ""];
409
+ const list = this.listRows(totals, width, chart.colors);
410
+ return {
411
+ lines: [...head, ...list.flatMap((row) => row.lines)],
412
+ owners: [...head.map(() => []), ...list.flatMap((row) => row.lines.map(() => row.models))],
413
+ count: totals.length,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Provider headers, then that provider's models: two per row when the pane is `TWO_COLUMN_WIDTH`
419
+ * or wider, else one. Rows are indented two columns inside the overlay's one-column margin.
420
+ */
421
+ private listRows(totals: readonly ModelTotals[], width: number, colors: ReadonlyMap<string, number>): ListRow[] {
422
+ const t = this.o.theme;
423
+ const grand = totals.reduce((n, m) => n + tokensOf(m), 0);
424
+ const twoUp = width >= TWO_COLUMN_WIDTH;
425
+ const inner = Math.max(1, width - 3);
426
+ const colWidth = twoUp ? Math.floor(inner / 2) : inner;
427
+ const bullet = (key: string) => {
428
+ const index = colors.get(key);
429
+ return index === undefined ? this.paint("muted", "•") : this.paintSeries(index, colors.size, "•");
430
+ };
431
+ const entry = (m: ModelTotals): string[] => [
432
+ `${bullet(m.name)} ${t.bold(sanitize(m.model))} ${this.paint("muted", `(${percent(tokensOf(m), grand)})`)}`,
433
+ this.paint("muted", ` In: ${formatTokens(m.input)} · Out: ${formatTokens(m.output)}`),
434
+ this.paint(
435
+ "muted",
436
+ ` Cache: ${formatTokens(m.cacheRead)} read · ${formatTokens(m.cacheWrite)} write · ${money(m.cost)} notional`,
437
+ ),
438
+ ];
439
+ const rows: ListRow[] = [];
440
+ let position = 0;
441
+ for (const provider of [...new Set(totals.map((m) => m.provider))]) {
442
+ const models = totals.filter((m) => m.provider === provider).map((m) => ({ m, i: position++ }));
443
+ const sum = models.reduce((n, { m }) => n + tokensOf(m), 0);
444
+ rows.push({
445
+ lines: [
446
+ `${bullet(provider)} ${t.bold(sanitize(provider))} ${formatTokens(sum)} ${this.paint("muted", `(${percent(sum, grand)})`)}`,
447
+ ],
448
+ models: [],
449
+ });
450
+ const per = twoUp ? 2 : 1;
451
+ for (let k = 0; k < models.length; k += per) {
452
+ const group = models.slice(k, k + per);
453
+ const blocks = group.map(({ m }) => entry(m));
454
+ const lines = [0, 1, 2].map((line) =>
455
+ blocks
456
+ .map((b, col) => {
457
+ const cell = truncateToWidth(
458
+ b[line] ?? "",
459
+ Math.max(1, colWidth - (col < blocks.length - 1 ? 2 : 0)),
460
+ "…",
461
+ );
462
+ return col < blocks.length - 1 ? cell + " ".repeat(Math.max(0, colWidth - visibleWidth(cell))) : cell;
463
+ })
464
+ .join(""),
465
+ );
466
+ rows.push({ lines: lines.map((l) => ` ${l}`), models: group.map(({ i }) => i) });
467
+ }
468
+ }
469
+ return rows;
470
+ }
471
+ }
472
+
473
+ /** Plain-text per-model table for print/json/rpc modes, all time. */
474
+ export function summary(calls: readonly Call[]): string {
475
+ const totals = modelTotals(calls);
476
+ if (totals.length === 0) return "/stats: no usage recorded yet";
477
+ const grand = totals.reduce((n, m) => n + tokensOf(m), 0);
478
+ const head = ["provider", "model", "in", "out", "cache read", "cache write", "cost", "%"];
479
+ const body = totals.map((m) => [
480
+ sanitize(m.provider),
481
+ sanitize(m.model),
482
+ formatTokens(m.input),
483
+ formatTokens(m.output),
484
+ formatTokens(m.cacheRead),
485
+ formatTokens(m.cacheWrite),
486
+ money(m.cost),
487
+ percent(tokensOf(m), grand),
488
+ ]);
489
+ const widths = head.map((h, c) => Math.max(h.length, ...body.map((r) => (r[c] ?? "").length)));
490
+ const fmt = (r: string[]) =>
491
+ r
492
+ .map((cell, c) => (c < 2 ? cell.padEnd(widths[c] ?? 0) : cell.padStart(widths[c] ?? 0)))
493
+ .join(" ")
494
+ .trimEnd();
495
+ return [`/stats: ${formatTokens(grand)} tokens ${TOKENS_NOTE}, all time`, fmt(head), ...body.map(fmt)].join("\n");
496
+ }
@@ -0,0 +1,46 @@
1
+ /** Local-calendar day keys (`YYYY-MM-DD`) and day arithmetic for `/stats`. DST-safe: built from `Date(y, m, d)`. */
2
+
3
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
4
+
5
+ function pad(n: number): string {
6
+ return n < 10 ? `0${n}` : String(n);
7
+ }
8
+
9
+ /** Local day key of an instant. */
10
+ export function dayKey(ms: number | Date): string {
11
+ const d = ms instanceof Date ? ms : new Date(ms);
12
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
13
+ }
14
+
15
+ /** Local midnight of a day key. */
16
+ export function keyDate(key: string): Date {
17
+ const [y, m, d] = key.split("-").map(Number);
18
+ return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
19
+ }
20
+
21
+ export function addDays(key: string, n: number): string {
22
+ const d = keyDate(key);
23
+ return dayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() + n));
24
+ }
25
+
26
+ /** Whole days from `a` to `b` (positive when `b` is later). */
27
+ export function daysBetween(a: string, b: string): number {
28
+ return Math.round((keyDate(b).getTime() - keyDate(a).getTime()) / 86_400_000);
29
+ }
30
+
31
+ /** Every key from `from` to `to`, inclusive. Empty when `from` is after `to`. */
32
+ export function dayRange(from: string, to: string): string[] {
33
+ const out: string[] = [];
34
+ for (let k = from; k <= to; k = addDays(k, 1)) out.push(k);
35
+ return out;
36
+ }
37
+
38
+ export function monthName(month: number): string {
39
+ return MONTHS[month] ?? "";
40
+ }
41
+
42
+ /** `Oct 3` */
43
+ export function shortDate(key: string): string {
44
+ const d = keyDate(key);
45
+ return `${monthName(d.getMonth())} ${d.getDate()}`;
46
+ }
@@ -0,0 +1,49 @@
1
+ /** Number and duration formatting for `/stats`. */
2
+
3
+ /**
4
+ * `999`, `12k`, `999k`, `1M`, `1.2M`, `3.4B`: whole numbers below 1k, `k` with no decimals, `M`/`B`
5
+ * with one decimal minus a trailing `.0`. A value that rounds up to 1000 of a unit moves up a unit.
6
+ */
7
+ export function formatTokens(n: number): string {
8
+ const abs = Math.abs(n);
9
+ if (abs < 999.5) return String(Math.round(n));
10
+ if (abs < 999_500) return `${Math.round(n / 1e3)}k`;
11
+ if (abs < 999_950_000) return `${trim((n / 1e6).toFixed(1))}M`;
12
+ return `${trim((n / 1e9).toFixed(1))}B`;
13
+ }
14
+
15
+ /**
16
+ * A y-axis tick. Same as `formatTokens` unless that would round the tick (a `2.5k` step), in which
17
+ * case one decimal is kept so labels never lie about where the gridline is.
18
+ */
19
+ export function axisLabel(n: number): string {
20
+ const plain = formatTokens(n);
21
+ const unit = plain.endsWith("k") ? 1e3 : plain.endsWith("M") ? 1e6 : plain.endsWith("B") ? 1e9 : 1;
22
+ if (unit === 1 || Math.abs(Number.parseFloat(plain) * unit - n) < unit / 1e4) return plain;
23
+ return `${trim((n / unit).toFixed(1))}${plain.slice(-1)}`;
24
+ }
25
+
26
+ function trim(fixed: string): string {
27
+ return fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed;
28
+ }
29
+
30
+ /** `$0.42`, `$12.30`, `$1.2k` */
31
+ export function money(n: number): string {
32
+ return n >= 1000 ? `$${trim((n / 1e3).toFixed(1))}k` : `$${n.toFixed(2)}`;
33
+ }
34
+
35
+ /** `27s`, `14m`, `3h 5m`, `2d 4h` */
36
+ export function duration(ms: number): string {
37
+ const s = Math.max(0, Math.round(ms / 1000));
38
+ if (s < 60) return `${s}s`;
39
+ const m = Math.floor(s / 60);
40
+ if (m < 60) return `${m}m`;
41
+ const h = Math.floor(m / 60);
42
+ if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
43
+ const d = Math.floor(h / 24);
44
+ return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
45
+ }
46
+
47
+ export function percent(part: number, whole: number): string {
48
+ return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "0.0%";
49
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GitHub-style activity heatmap, Claude Code's layout: one column per week (Sunday first), Mon/Wed/Fri
3
+ * labels, month names over the week they start in, and four intensity levels at the quartiles of
4
+ * the non-zero days.
5
+ */
6
+
7
+ import { addDays, keyDate, monthName } from "./days.ts";
8
+
9
+ /** A year: the heatmap shows at least this many weeks whenever the pane fits them. */
10
+ export const YEAR_WEEKS = 52;
11
+ export const DAY_LABEL_WIDTH = 4;
12
+ const LEVEL_CHARS = ["·", "░", "▒", "▓", "█"] as const;
13
+
14
+ export type HeatColor = "heat" | "empty" | "label";
15
+
16
+ export function quartiles(values: Iterable<number>): [number, number, number] | undefined {
17
+ const sorted = [...values].filter((v) => v > 0).sort((a, b) => a - b);
18
+ if (sorted.length === 0) return undefined;
19
+ const at = (q: number) => sorted[Math.floor(sorted.length * q)] ?? 0;
20
+ return [at(0.25), at(0.5), at(0.75)];
21
+ }
22
+
23
+ export function level(value: number, q: [number, number, number] | undefined): 0 | 1 | 2 | 3 | 4 {
24
+ if (value <= 0 || !q) return 0;
25
+ if (value >= q[2]) return 4;
26
+ if (value >= q[1]) return 3;
27
+ if (value >= q[0]) return 2;
28
+ return 1;
29
+ }
30
+
31
+ /**
32
+ * Month names over the week each month starts in. A month whose columns cannot hold its name plus
33
+ * a space (the clipped month at the left edge) goes unlabelled rather than crowding out the next;
34
+ * the newest month is always labelled, even when its name runs past the last column.
35
+ */
36
+ export function monthRow(start: string, weeks: number): string {
37
+ const starts: { week: number; month: number }[] = [];
38
+ for (let w = 0; w < weeks; w++) {
39
+ const month = keyDate(addDays(start, w * 7)).getMonth();
40
+ if (starts[starts.length - 1]?.month !== month) starts.push({ week: w, month });
41
+ }
42
+ const row = new Array<string>(weeks).fill(" ");
43
+ starts.forEach(({ week, month }, i) => {
44
+ const name = monthName(month);
45
+ const next = starts[i + 1]?.week;
46
+ // The newest month may run past the grid's right edge; the others need their name plus a space.
47
+ if (next !== undefined && next - week < name.length + 1) return;
48
+ for (let c = 0; c < name.length; c++) row[week + c] = name[c] ?? " ";
49
+ });
50
+ return row.join("").trimEnd();
51
+ }
52
+
53
+ /** How many week columns fit `width`: one per column after the day labels, so wider shows more history. */
54
+ export function weeksFor(width: number): number {
55
+ return Math.max(1, width - DAY_LABEL_WIDTH);
56
+ }
57
+
58
+ /**
59
+ * Render the heatmap for `daily` (tokens per day key) ending at `today`. Returns 7 day rows plus the
60
+ * month row on top and a legend row at the bottom.
61
+ */
62
+ export function heatmap(
63
+ daily: ReadonlyMap<string, number>,
64
+ today: string,
65
+ width: number,
66
+ paint: (color: HeatColor, text: string) => string,
67
+ ): string[] {
68
+ const weeks = weeksFor(width);
69
+ const q = quartiles(daily.values());
70
+ const todayDate = keyDate(today);
71
+ const weekStart = addDays(today, -todayDate.getDay());
72
+ const start = addDays(weekStart, -(weeks - 1) * 7);
73
+
74
+ const rows: string[][] = Array.from({ length: 7 }, () => []);
75
+ const months = monthRow(start, weeks);
76
+ for (let w = 0; w < weeks; w++) {
77
+ const sunday = addDays(start, w * 7);
78
+ for (let d = 0; d < 7; d++) {
79
+ const day = addDays(sunday, d);
80
+ if (day > today) {
81
+ rows[d]?.push(" ");
82
+ continue;
83
+ }
84
+ const lv = level(daily.get(day) ?? 0, q);
85
+ rows[d]?.push(lv === 0 ? paint("empty", LEVEL_CHARS[0]) : paint("heat", LEVEL_CHARS[lv]));
86
+ }
87
+ }
88
+
89
+ const labels = [" ", "Mon", " ", "Wed", " ", "Fri", " "];
90
+ const out = [`${" ".repeat(DAY_LABEL_WIDTH)}${paint("label", months)}`];
91
+ for (let d = 0; d < 7; d++) out.push(`${paint("label", `${labels[d]} `)}${(rows[d] ?? []).join("")}`);
92
+ out.push(
93
+ `${" ".repeat(DAY_LABEL_WIDTH)}${paint("label", "Less ")}${[1, 2, 3, 4]
94
+ .map((lv) => paint("heat", LEVEL_CHARS[lv] ?? ""))
95
+ .join(" ")}${paint("label", " More")}`,
96
+ );
97
+ return out;
98
+ }