@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 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 shows current-session, selected-range, and all-time input, output, cache-read, cache-write, reasoning, cost, and cache-hit statistics, plus an activity heatmap and clearly labelled all-time session/streak highlights. Reasoning is shown as a subset of output and is never added to the total twice. The Models tab attributes assistant usage to the provider response model and keeps otherwise unattributed nested usage in `Tools/summaries`, so its totals reconcile.
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 = 1 as const;
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 files = await discoverJsonlFiles(roots, options.signal);
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
- return makeIndex(sessions, diagnostics);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-stats",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
5
5
  "type": "module",
6
6
  "keywords": [
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(sessions: SessionRecord[], diagnostics: StatsIndex["diagnostics"]): StatsIndex {
404
- return { sessions, usage: deduplicateUsage(sessions), diagnostics };
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: 1;
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 MODEL_PAGE_SIZE = 8;
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 (safe >= 1_000_000_000) return `${(safe / 1_000_000_000).toFixed(1)}b`;
26
- if (safe >= 1_000_000) return `${(safe / 1_000_000).toFixed(1)}m`;
27
- if (safe >= 1_000) return `${(safe / 1_000).toFixed(safe < 10_000 ? 1 : 0)}k`;
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
- return `$${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)}`;
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 plainPad(value: string, width: number): string {
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.min(this.modelOffset, Math.max(0, snapshot.models.length - MODEL_PAGE_SIZE));
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 - MODEL_PAGE_SIZE), this.modelOffset + 1);
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(" PI STATS "))} ${this.tab === "overview" ? selected("Overview") : normal("Overview")} ${this.tab === "models" ? selected("Models") : normal("Models")}`,
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("muted", "All-time tokens")} ${theme.fg("accent", theme.bold(formatTokens(totalTokens(snapshot.allTime))))} ${theme.fg("dim", `• ${RANGE_LABELS[this.range]}`)}`,
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
- lines.push(theme.fg("dim", " Tab/←→ switch · r range · u refresh · Esc close") + warning);
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 renderUsage(label: string, usage: UsageTotals, width: number): string[] {
289
+ private renderScopes(snapshot: StatsSnapshot, width: number): string[] {
188
290
  const { theme } = this.options;
189
- const heading = ` ${theme.fg("text", theme.bold(label))} ${theme.fg("accent", formatTokens(totalTokens(usage)))} ${theme.fg("dim", `tokens · ${formatCost(usage.cost)} · cache hit ${cacheHit(usage)}`)}`;
190
- const items = [
191
- ["Input", usage.input],
192
- ["Output", usage.output],
193
- ["Cache R", usage.cacheRead],
194
- ["Cache W", usage.cacheWrite],
195
- ["Reasoning ⊂ output", usage.reasoning],
196
- ] as const;
197
- const formatted = items.map(([name, value]) => `${theme.fg("dim", name)} ${theme.fg("text", formatTokens(value))}`);
198
- if (width >= 68) return [heading, ` ${formatted.join(theme.fg("dim", " · "))}`];
199
- return [heading, ` ${formatted.slice(0, 2).join(theme.fg("dim", " · "))}`, ` ${formatted.slice(2).join(theme.fg("dim", " · "))}`];
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 lines = [
205
- ...this.renderUsage("Current session", snapshot.currentSession, width),
206
- ...this.renderUsage(RANGE_LABELS[snapshot.range], snapshot.period, width),
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
- if (snapshot.range !== "all") lines.push(...this.renderUsage("All time", snapshot.allTime, width));
209
- if (width >= 72) lines.push(...this.renderHeatmap(snapshot, width));
210
-
211
- const sessions = `${snapshot.sessionCount} (${snapshot.mainSessionCount} main · ${snapshot.subagentSessionCount} subagents)`;
212
- const active = `${snapshot.activeDays} · streak ${snapshot.currentStreak}/${snapshot.longestStreak}d`;
213
- const topDay = snapshot.mostActiveDay ? `${snapshot.mostActiveDay.day} · ${formatTokens(snapshot.mostActiveDay.total)}` : "—";
214
- const metrics = [
215
- ["All-time sessions", sessions],
216
- ["All-time active days", active],
217
- ["All-time favorite", snapshot.favoriteModel ?? "—"],
218
- ["All-time busiest", topDay],
219
- ["Longest session span", formatDuration(snapshot.longestSessionMs)],
220
- ] as const;
221
- if (width >= 80) {
222
- for (let index = 0; index < metrics.length; index += 2) {
223
- const left = metrics[index]!;
224
- const right = metrics[index + 1];
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 [name, value] of metrics) lines.push(` ${theme.fg("dim", name)}: ${theme.fg("text", value)}`);
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
- return lines;
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 weeks = Math.max(8, Math.min(53, width - 8));
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() + (7 - ((end.getDay() + 6) % 7) - 1));
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
- const lines = [` ${theme.fg("muted", `All-time activity · trailing ${weeks} weeks`)}`];
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
- const start = Math.min(this.modelOffset, Math.max(0, snapshot.models.length - MODEL_PAGE_SIZE));
276
- const models = snapshot.models.slice(start, start + MODEL_PAGE_SIZE);
277
- const lines: string[] = [];
278
- if (width >= 110) {
279
- lines.push(theme.fg("dim", ` ${plainPad("Model", 34)} ${plainPad("Calls", 7)} ${plainPad("Share", 7)} ${plainPad("Total", 9)} ${plainPad("Input", 9)} ${plainPad("Output", 9)} ${plainPad("Cache R/W", 17)} Cost`));
280
- for (const model of models) {
281
- lines.push(
282
- ` ${theme.fg("text", plainPad(model.model, 34))} ${plainPad(String(model.calls), 7)} ${plainPad(`${(model.share * 100).toFixed(1)}%`, 7)} ${plainPad(formatTokens(model.total), 9)} ${plainPad(formatTokens(model.input), 9)} ${plainPad(formatTokens(model.output), 9)} ${plainPad(`${formatTokens(model.cacheRead)}/${formatTokens(model.cacheWrite)}`, 17)} ${formatCost(model.cost)}`,
283
- );
284
- if (model.reasoning > 0) lines.push(theme.fg("dim", ` reasoning ⊂ output ${formatTokens(model.reasoning)}`));
285
- }
286
- } else if (width >= 72) {
287
- lines.push(theme.fg("dim", ` ${plainPad("Model", 31)} ${plainPad("Calls", 7)} ${plainPad("Share", 7)} Total`));
288
- for (const model of models) {
289
- lines.push(` ${theme.fg("text", plainPad(model.model, 31))} ${plainPad(String(model.calls), 7)} ${plainPad(`${(model.share * 100).toFixed(1)}%`, 7)} ${formatTokens(model.total)}`);
290
- lines.push(theme.fg("dim", ` in ${formatTokens(model.input)} · out ${formatTokens(model.output)} · cache ${formatTokens(model.cacheRead)}/${formatTokens(model.cacheWrite)} · reason ${formatTokens(model.reasoning)} · ${formatCost(model.cost)}`));
291
- }
292
- } else {
293
- for (const model of models) {
294
- lines.push(` ${theme.fg("text", theme.bold(model.model))} ${theme.fg("accent", formatTokens(model.total))} ${theme.fg("dim", `· ${(model.share * 100).toFixed(1)}% · ${model.calls} calls · ${formatCost(model.cost)}`)}`);
295
- lines.push(theme.fg("dim", ` in ${formatTokens(model.input)} · out ${formatTokens(model.output)} · cache ${formatTokens(model.cacheRead)}/${formatTokens(model.cacheWrite)} · reason ${formatTokens(model.reasoning)}`));
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
  }