@hank-warren/pi-stats 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hank Warren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @hank-warren/pi-stats
2
+
3
+ A compact, theme-aware `/stats` dashboard for [Pi](https://pi.dev). It totals the usage already recorded in local Pi session JSONLs, including persisted `pi-subagents` child sessions.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@hank-warren/pi-stats
9
+ ```
10
+
11
+ Do not install this standalone package on a host that also installs the aggregate `hank-warren/pi-extensions` Git package; that would load the extension twice.
12
+
13
+ ## Use
14
+
15
+ Run `/stats` in interactive mode. The dashboard temporarily replaces the editor and closes with `Escape`.
16
+
17
+ - `Tab` or `←`/`→`: switch Overview and Models
18
+ - `↑`/`↓`: scroll the model list
19
+ - `r`: cycle All time, Last 7 days, and Last 30 days
20
+ - `u`: force a full rescan and rebuild the session index
21
+ - `Escape`: close
22
+
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
+
25
+ ## What is counted
26
+
27
+ Pi writes usage to assistant messages, usage-bearing tool results, compactions, and branch summaries. `pi-stats` recursively reads valid Pi session files and counts every recorded model call, including compacted and abandoned branches. Copied fork history is fingerprinted and counted once.
28
+
29
+ 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
+
31
+ 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
+
33
+ ## Cache and privacy
34
+
35
+ Sessions remain authoritative. For fast repeat opens, the extension stores one disposable index:
36
+
37
+ ```text
38
+ ~/.pi/agent/pi-stats/cache.json
39
+ ```
40
+
41
+ The location follows `PI_CODING_AGENT_DIR`. The cache contains file stat identity, session IDs/timestamps/working directories, model names, usage counters, and de-duplication fingerprints. It never stores prompts, responses, tool output, or other conversation content. It is written atomically with mode `0600` and can be deleted at any time; the next `/stats` invocation rebuilds it.
42
+
43
+ No network or billing API is used. Displayed costs are the estimates already recorded by Pi.
package/cache.ts ADDED
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, readdir, realpath, rename, stat, writeFile, mkdir, chmod, unlink } from "node:fs/promises";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { makeIndex, parseSessionText } from "./stats.ts";
6
+ import type { CachedFileRecord, ScanDiagnostics, SessionRecord, StatsCacheFile, StatsIndex, UsageRecord } from "./types.ts";
7
+
8
+ const CACHE_VERSION = 1 as const;
9
+ const SKIP_DIRECTORIES = new Set(["subagent-artifacts"]);
10
+
11
+ export interface ScanOptions {
12
+ agentDir: string;
13
+ activeSessionDir?: string;
14
+ env?: NodeJS.ProcessEnv;
15
+ sessionRoots?: string[];
16
+ force?: boolean;
17
+ signal?: AbortSignal;
18
+ onProgress?: (completed: number, total: number) => void;
19
+ }
20
+
21
+ function expandHome(value: string): string {
22
+ if (value === "~") return homedir();
23
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
24
+ return value;
25
+ }
26
+
27
+ async function settingsSubagentRoot(agentDir: string): Promise<string | undefined> {
28
+ try {
29
+ const settings = JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")) as unknown;
30
+ if (!settings || typeof settings !== "object") return undefined;
31
+ const subagents = (settings as { subagents?: unknown }).subagents;
32
+ if (!subagents || typeof subagents !== "object") return undefined;
33
+ const configured = (subagents as { defaultSessionDir?: unknown }).defaultSessionDir;
34
+ return typeof configured === "string" && configured.length > 0 ? expandHome(configured) : undefined;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ export async function resolveSessionRoots(options: ScanOptions): Promise<string[]> {
41
+ if (options.sessionRoots) return uniqueExistingDirectories(options.sessionRoots);
42
+ const env = options.env ?? process.env;
43
+ const roots = [join(options.agentDir, "sessions")];
44
+ if (env.PI_CODING_AGENT_SESSION_DIR) roots.push(expandHome(env.PI_CODING_AGENT_SESSION_DIR));
45
+ if (options.activeSessionDir) roots.push(options.activeSessionDir);
46
+ const configuredSubagents = await settingsSubagentRoot(options.agentDir);
47
+ if (configuredSubagents) roots.push(configuredSubagents);
48
+ if (env.PI_STATS_SESSION_DIRS) {
49
+ roots.push(...env.PI_STATS_SESSION_DIRS.split(delimiter).filter(Boolean).map(expandHome));
50
+ }
51
+ return uniqueExistingDirectories(roots);
52
+ }
53
+
54
+ async function uniqueExistingDirectories(paths: string[]): Promise<string[]> {
55
+ const result = new Set<string>();
56
+ for (const candidate of paths) {
57
+ try {
58
+ const resolved = await realpath(resolve(candidate));
59
+ if ((await stat(resolved)).isDirectory()) result.add(resolved);
60
+ } catch {
61
+ // Missing optional roots are expected.
62
+ }
63
+ }
64
+ return [...result];
65
+ }
66
+
67
+ async function discoverJsonlFiles(roots: readonly string[], signal?: AbortSignal): Promise<string[]> {
68
+ const found = new Set<string>();
69
+ const walk = async (directory: string): Promise<void> => {
70
+ signal?.throwIfAborted();
71
+ let entries;
72
+ try {
73
+ entries = await readdir(directory, { withFileTypes: true });
74
+ } catch {
75
+ return;
76
+ }
77
+ for (const entry of entries) {
78
+ signal?.throwIfAborted();
79
+ if (entry.isSymbolicLink()) continue;
80
+ const path = join(directory, entry.name);
81
+ if (entry.isDirectory()) {
82
+ if (!SKIP_DIRECTORIES.has(entry.name)) await walk(path);
83
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
84
+ try {
85
+ found.add(await realpath(path));
86
+ } catch {
87
+ // File disappeared between directory listing and resolution.
88
+ }
89
+ }
90
+ }
91
+ };
92
+ for (const root of roots) await walk(root);
93
+ return [...found].sort();
94
+ }
95
+
96
+ function emptyCache(): StatsCacheFile {
97
+ return { version: CACHE_VERSION, files: {} };
98
+ }
99
+
100
+ function validNumber(value: unknown): value is number {
101
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
102
+ }
103
+
104
+ function validUsageRecord(value: unknown): value is UsageRecord {
105
+ if (!value || typeof value !== "object") return false;
106
+ const record = value as Partial<UsageRecord>;
107
+ const usage = record.usage as Partial<UsageRecord["usage"]> | undefined;
108
+ return (
109
+ typeof record.fingerprint === "string" &&
110
+ validNumber(record.timestamp) &&
111
+ typeof record.model === "string" &&
112
+ (record.kind === "assistant" || record.kind === "tool" || record.kind === "summary") &&
113
+ Boolean(usage) &&
114
+ validNumber(usage?.input) &&
115
+ validNumber(usage?.output) &&
116
+ validNumber(usage?.cacheRead) &&
117
+ validNumber(usage?.cacheWrite) &&
118
+ validNumber(usage?.reasoning) &&
119
+ validNumber(usage?.cost) &&
120
+ validNumber(usage?.calls) &&
121
+ (record.childSessionFiles === undefined || (Array.isArray(record.childSessionFiles) && record.childSessionFiles.every((file) => typeof file === "string")))
122
+ );
123
+ }
124
+
125
+ function validSessionRecord(value: unknown): value is SessionRecord {
126
+ if (!value || typeof value !== "object") return false;
127
+ const session = value as Partial<SessionRecord>;
128
+ return (
129
+ typeof session.path === "string" &&
130
+ typeof session.sessionId === "string" &&
131
+ typeof session.cwd === "string" &&
132
+ validNumber(session.createdAt) &&
133
+ validNumber(session.lastActivityAt) &&
134
+ (session.source === "main" || session.source === "subagent") &&
135
+ Array.isArray(session.usage) &&
136
+ session.usage.every(validUsageRecord)
137
+ );
138
+ }
139
+
140
+ function validCache(value: unknown): value is StatsCacheFile {
141
+ if (!value || typeof value !== "object") return false;
142
+ const candidate = value as Partial<StatsCacheFile>;
143
+ if (candidate.version !== CACHE_VERSION || !candidate.files || typeof candidate.files !== "object") return false;
144
+ return Object.entries(candidate.files).every(([path, value]) => {
145
+ if (!path || !value || typeof value !== "object") return false;
146
+ const record = value as Partial<CachedFileRecord>;
147
+ return (
148
+ validNumber(record.size) &&
149
+ validNumber(record.mtimeMs) &&
150
+ validNumber(record.malformedLines) &&
151
+ (record.ignored === undefined || record.ignored === true) &&
152
+ (record.session === undefined || validSessionRecord(record.session))
153
+ );
154
+ });
155
+ }
156
+
157
+ async function loadCache(path: string): Promise<{ cache: StatsCacheFile; valid: boolean }> {
158
+ try {
159
+ const parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
160
+ if (!validCache(parsed)) return { cache: emptyCache(), valid: false };
161
+ return { cache: parsed, valid: true };
162
+ } catch {
163
+ return { cache: emptyCache(), valid: false };
164
+ }
165
+ }
166
+
167
+ async function cleanupStaleTemporaryFiles(path: string): Promise<void> {
168
+ const directory = dirname(path);
169
+ const prefix = `${basename(path)}.`;
170
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
171
+ try {
172
+ const entries = await readdir(directory, { withFileTypes: true });
173
+ await Promise.all(entries.map(async (entry) => {
174
+ if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(".tmp")) return;
175
+ const candidate = join(directory, entry.name);
176
+ try {
177
+ if ((await stat(candidate)).mtimeMs < cutoff) await unlink(candidate);
178
+ } catch {
179
+ // Another process may already have removed it.
180
+ }
181
+ }));
182
+ } catch {
183
+ // The cache directory may not exist yet.
184
+ }
185
+ }
186
+
187
+ async function atomicWriteCache(path: string, cache: StatsCacheFile): Promise<void> {
188
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
189
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
190
+ try {
191
+ await writeFile(temporary, JSON.stringify(cache), { encoding: "utf8", mode: 0o600 });
192
+ await rename(temporary, path);
193
+ await chmod(path, 0o600);
194
+ } catch (error) {
195
+ await unlink(temporary).catch(() => undefined);
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ function sameFile(record: CachedFileRecord | undefined, size: number, mtimeMs: number): boolean {
201
+ return Boolean(record && record.size === size && record.mtimeMs === mtimeMs);
202
+ }
203
+
204
+ export function statsCachePath(agentDir: string): string {
205
+ return join(agentDir, "pi-stats", "cache.json");
206
+ }
207
+
208
+ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
209
+ const roots = await resolveSessionRoots(options);
210
+ const files = await discoverJsonlFiles(roots, options.signal);
211
+ const cachePath = statsCachePath(options.agentDir);
212
+ await cleanupStaleTemporaryFiles(cachePath);
213
+ const loaded = options.force ? { cache: emptyCache(), valid: false } : await loadCache(cachePath);
214
+ const next: StatsCacheFile = emptyCache();
215
+ const diagnostics: ScanDiagnostics = {
216
+ discoveredFiles: files.length,
217
+ parsedFiles: 0,
218
+ reusedFiles: 0,
219
+ ignoredFiles: 0,
220
+ unreadableFiles: 0,
221
+ malformedLines: 0,
222
+ };
223
+ let changed = options.force === true || !loaded.valid;
224
+
225
+ for (let index = 0; index < files.length; index++) {
226
+ options.signal?.throwIfAborted();
227
+ const file = files[index]!;
228
+ try {
229
+ const metadata = await stat(file);
230
+ const previous = loaded.cache.files[file];
231
+ if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
232
+ next.files[file] = previous!;
233
+ diagnostics.reusedFiles++;
234
+ } else {
235
+ const parsed = parseSessionText(file, await readFile(file, "utf8"));
236
+ next.files[file] = {
237
+ size: metadata.size,
238
+ mtimeMs: metadata.mtimeMs,
239
+ ...(parsed.ignored ? { ignored: true as const } : {}),
240
+ ...(parsed.session ? { session: parsed.session } : {}),
241
+ malformedLines: parsed.malformedLines,
242
+ };
243
+ diagnostics.parsedFiles++;
244
+ changed = true;
245
+ }
246
+ const current = next.files[file]!;
247
+ if (current.ignored) diagnostics.ignoredFiles++;
248
+ diagnostics.malformedLines += current.malformedLines;
249
+ } catch {
250
+ diagnostics.unreadableFiles++;
251
+ changed = true;
252
+ }
253
+ options.onProgress?.(index + 1, files.length);
254
+ }
255
+
256
+ if (Object.keys(loaded.cache.files).some((file) => !(file in next.files))) changed = true;
257
+ if (changed) {
258
+ await atomicWriteCache(cachePath, next).catch(() => {
259
+ // Stats remain usable if the optional cache cannot be written.
260
+ });
261
+ }
262
+ const sessions = Object.values(next.files).flatMap((record) => (record.session ? [record.session] : []));
263
+ return makeIndex(sessions, diagnostics);
264
+ }
265
+
266
+ export function isExternalSessionDirectory(activeSessionDir: string, agentDir: string): boolean {
267
+ const standard = resolve(agentDir, "sessions");
268
+ const active = resolve(activeSessionDir);
269
+ return active !== standard && !active.startsWith(`${standard}/`);
270
+ }
271
+
package/index.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { isExternalSessionDirectory, scanStats } from "./cache.ts";
3
+ import { buildSnapshot, makeIndex, usageFromEntries } from "./stats.ts";
4
+ import type { SessionRecord, StatsIndex, StatsRange } from "./types.ts";
5
+ import { StatsWidget } from "./widget.ts";
6
+
7
+ function includeInMemorySession(index: StatsIndex, ctx: ExtensionCommandContext): StatsIndex {
8
+ const sessionId = ctx.sessionManager.getSessionId();
9
+ if (index.sessions.some((session) => session.sessionId === sessionId)) return index;
10
+ const entries = ctx.sessionManager.getEntries();
11
+ const header = ctx.sessionManager.getHeader();
12
+ const createdAt = header?.timestamp ? Date.parse(header.timestamp) : Date.now();
13
+ const usage = usageFromEntries(
14
+ sessionId,
15
+ entries,
16
+ Number.isFinite(createdAt) ? createdAt : Date.now(),
17
+ ctx.sessionManager.getSessionFile() ?? "current-session.jsonl",
18
+ );
19
+ const session: SessionRecord = {
20
+ path: ctx.sessionManager.getSessionFile() ?? `in-memory:${sessionId}`,
21
+ sessionId,
22
+ cwd: ctx.cwd,
23
+ createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
24
+ lastActivityAt: usage.reduce((latest, record) => Math.max(latest, record.timestamp), Number.isFinite(createdAt) ? createdAt : 0),
25
+ source: "main",
26
+ usage,
27
+ };
28
+ return makeIndex([...index.sessions, session], index.diagnostics);
29
+ }
30
+
31
+ export default function statsExtension(pi: ExtensionAPI): void {
32
+ pi.registerCommand("stats", {
33
+ description: "Open all-time Pi token statistics",
34
+ handler: async (_args, ctx) => {
35
+ if (ctx.mode !== "tui") {
36
+ ctx.ui.notify("/stats requires interactive TUI mode", "warning");
37
+ return;
38
+ }
39
+
40
+ const agentDir = getAgentDir();
41
+ const sessionDir = ctx.sessionManager.getSessionDir();
42
+ let controller: AbortController | undefined;
43
+ let currentIndex: StatsIndex | undefined;
44
+ let closed = false;
45
+
46
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
47
+ const snapshot = (range: StatsRange) =>
48
+ currentIndex
49
+ ? buildSnapshot(
50
+ currentIndex,
51
+ ctx.sessionManager.getEntries(),
52
+ ctx.sessionManager.getSessionId(),
53
+ range,
54
+ Date.now(),
55
+ ctx.sessionManager.getSessionFile() ?? "current-session.jsonl",
56
+ )
57
+ : undefined;
58
+
59
+ const widget = new StatsWidget({
60
+ theme,
61
+ requestRender: () => tui.requestRender(),
62
+ onClose: () => {
63
+ closed = true;
64
+ controller?.abort();
65
+ done(undefined);
66
+ },
67
+ onRefresh: () => void refresh(true),
68
+ onDispose: () => {
69
+ closed = true;
70
+ controller?.abort();
71
+ },
72
+ getSnapshot: snapshot,
73
+ });
74
+
75
+ const refresh = async (force: boolean): Promise<void> => {
76
+ controller?.abort();
77
+ const refreshController = new AbortController();
78
+ controller = refreshController;
79
+ widget.setLoading();
80
+ let lastReported = 0;
81
+ try {
82
+ const scanned = await scanStats({
83
+ agentDir,
84
+ ...(isExternalSessionDirectory(sessionDir, agentDir) ? { activeSessionDir: sessionDir } : {}),
85
+ force,
86
+ signal: refreshController.signal,
87
+ onProgress: (completed, total) => {
88
+ if (completed === total || completed - lastReported >= 8) {
89
+ lastReported = completed;
90
+ widget.setProgress(completed, total);
91
+ }
92
+ },
93
+ });
94
+ if (closed || refreshController.signal.aborted) return;
95
+ currentIndex = includeInMemorySession(scanned, ctx);
96
+ widget.setReady(snapshot(widget.getRange())!);
97
+ } catch (error) {
98
+ if (closed || refreshController.signal.aborted) return;
99
+ widget.setError(error instanceof Error ? error.message : String(error));
100
+ }
101
+ };
102
+
103
+ queueMicrotask(() => void refresh(false));
104
+ return widget;
105
+ });
106
+ },
107
+ });
108
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@hank-warren/pi-stats",
3
+ "version": "0.1.0",
4
+ "description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "stats",
10
+ "tokens",
11
+ "usage",
12
+ "dashboard"
13
+ ],
14
+ "author": "Hank Warren",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/hank-warren/pi-extensions.git",
19
+ "directory": "packages/pi-stats"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/hank-warren/pi-extensions/issues"
23
+ },
24
+ "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-stats#readme",
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "files": [
34
+ "index.ts",
35
+ "cache.ts",
36
+ "stats.ts",
37
+ "types.ts",
38
+ "widget.ts",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "@earendil-works/pi-tui": "*"
45
+ }
46
+ }
package/stats.ts ADDED
@@ -0,0 +1,405 @@
1
+ import { createHash } from "node:crypto";
2
+ import { dirname, resolve } from "node:path";
3
+ import {
4
+ addUsage,
5
+ emptyUsage,
6
+ totalTokens,
7
+ type DayStats,
8
+ type ModelStats,
9
+ type SessionRecord,
10
+ type StatsIndex,
11
+ type StatsRange,
12
+ type StatsSnapshot,
13
+ type UsageRecord,
14
+ type UsageTotals,
15
+ } from "./types.ts";
16
+
17
+ interface ParseResult {
18
+ ignored: boolean;
19
+ malformedLines: number;
20
+ session?: SessionRecord;
21
+ }
22
+
23
+ function object(value: unknown): Record<string, unknown> | undefined {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value)
25
+ ? (value as Record<string, unknown>)
26
+ : undefined;
27
+ }
28
+
29
+ function finite(value: unknown): number {
30
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
31
+ }
32
+
33
+ function firstNumber(source: Record<string, unknown>, names: string[]): number {
34
+ for (const name of names) {
35
+ if (typeof source[name] === "number") return finite(source[name]);
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ export function normalizeUsage(value: unknown): UsageTotals | undefined {
41
+ const usage = object(value);
42
+ if (!usage) return undefined;
43
+ const cost = object(usage.cost);
44
+ return {
45
+ input: firstNumber(usage, ["input", "inputTokens"]),
46
+ output: firstNumber(usage, ["output", "outputTokens"]),
47
+ cacheRead: firstNumber(usage, ["cacheRead", "cacheReadTokens"]),
48
+ cacheWrite: firstNumber(usage, ["cacheWrite", "cacheWriteTokens"]),
49
+ reasoning: firstNumber(usage, ["reasoning", "reasoningTokens"]),
50
+ cost: cost ? firstNumber(cost, ["total"]) : firstNumber(usage, ["costUsd", "cost"]),
51
+ calls: 1,
52
+ };
53
+ }
54
+
55
+ function timestamp(value: unknown, fallback: number): number {
56
+ if (typeof value === "number" && Number.isFinite(value)) return value;
57
+ if (typeof value === "string") {
58
+ const parsed = Date.parse(value);
59
+ if (Number.isFinite(parsed)) return parsed;
60
+ }
61
+ return fallback;
62
+ }
63
+
64
+ function modelName(provider: unknown, model: unknown): string {
65
+ const providerText = typeof provider === "string" ? provider : "";
66
+ const modelText = typeof model === "string" && model.length > 0 ? model : "unknown";
67
+ if (!providerText || modelText.includes("/")) return modelText;
68
+ return `${providerText}/${modelText}`;
69
+ }
70
+
71
+ function detailsModel(details: unknown): string | undefined {
72
+ const candidate = object(details)?.model;
73
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined;
74
+ }
75
+
76
+ function nestedSubagentUsage(details: unknown): UsageTotals | undefined {
77
+ const record = object(details);
78
+ if (!record) return undefined;
79
+ const aggregate = normalizeUsage(record.totalChildUsage);
80
+ if (aggregate) return aggregate;
81
+ if (!Array.isArray(record.results)) return undefined;
82
+ const total = emptyUsage();
83
+ let found = false;
84
+ for (const result of record.results) {
85
+ const normalized = normalizeUsage(object(result)?.usage);
86
+ if (!normalized) continue;
87
+ addUsage(total, normalized);
88
+ found = true;
89
+ }
90
+ return found ? total : undefined;
91
+ }
92
+
93
+ function childSessionFiles(details: unknown, sessionFile: string): string[] {
94
+ const found = new Set<string>();
95
+ const visit = (value: unknown, depth: number): void => {
96
+ if (depth > 6) return;
97
+ if (Array.isArray(value)) {
98
+ for (const item of value) visit(item, depth + 1);
99
+ return;
100
+ }
101
+ const record = object(value);
102
+ if (!record) return;
103
+ for (const [key, item] of Object.entries(record)) {
104
+ if (key === "sessionFile" && typeof item === "string" && item.length > 0) {
105
+ found.add(resolve(dirname(sessionFile), item));
106
+ } else if (typeof item === "object" && item !== null) {
107
+ visit(item, depth + 1);
108
+ }
109
+ }
110
+ };
111
+ visit(details, 0);
112
+ return [...found];
113
+ }
114
+
115
+ function fingerprint(parts: unknown[]): string {
116
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
117
+ }
118
+
119
+ function normalizeEntry(
120
+ entryValue: unknown,
121
+ sessionId: string,
122
+ sessionFile: string,
123
+ lineIndex: number,
124
+ fallbackTimestamp: number,
125
+ ): UsageRecord | undefined {
126
+ const entry = object(entryValue);
127
+ if (!entry) return undefined;
128
+ const entryTimestamp = timestamp(entry.timestamp, fallbackTimestamp);
129
+ const id = typeof entry.id === "string" ? entry.id : `${sessionId}:${lineIndex}`;
130
+
131
+ if (entry.type === "message") {
132
+ const message = object(entry.message);
133
+ if (!message) return undefined;
134
+ const role = message.role;
135
+ if (role === "assistant") {
136
+ const usage = normalizeUsage(message.usage);
137
+ if (!usage) return undefined;
138
+ const model = modelName(message.provider, message.responseModel ?? message.model);
139
+ return {
140
+ fingerprint: fingerprint([id, entry.timestamp, "assistant", model, usage]),
141
+ timestamp: timestamp(message.timestamp, entryTimestamp),
142
+ model,
143
+ usage,
144
+ kind: "assistant",
145
+ };
146
+ }
147
+ if (role === "toolResult") {
148
+ const toolName = typeof message.toolName === "string" ? message.toolName : "tool";
149
+ const usage = normalizeUsage(message.usage) ?? (toolName === "subagent" ? nestedSubagentUsage(message.details) : undefined);
150
+ if (!usage) return undefined;
151
+ const model = detailsModel(message.details) ?? "Tools/summaries";
152
+ const children = toolName === "subagent" ? childSessionFiles(message.details, sessionFile) : [];
153
+ return {
154
+ fingerprint: fingerprint([id, entry.timestamp, "tool", toolName, model, usage]),
155
+ timestamp: timestamp(message.timestamp, entryTimestamp),
156
+ model,
157
+ usage,
158
+ kind: "tool",
159
+ toolName,
160
+ ...(children.length > 0 ? { childSessionFiles: children } : {}),
161
+ };
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
167
+ const usage = normalizeUsage(entry.usage);
168
+ if (!usage) return undefined;
169
+ return {
170
+ fingerprint: fingerprint([id, entry.timestamp, entry.type, "Tools/summaries", usage]),
171
+ timestamp: entryTimestamp,
172
+ model: "Tools/summaries",
173
+ usage,
174
+ kind: "summary",
175
+ };
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ function sessionSource(filePath: string): "main" | "subagent" {
181
+ return /(?:^|\/)run-\d+\/session\.jsonl$/.test(filePath) ? "subagent" : "main";
182
+ }
183
+
184
+ export function parseSessionText(filePath: string, text: string): ParseResult {
185
+ const lines = text.split("\n");
186
+ let header: Record<string, unknown> | undefined;
187
+ try {
188
+ header = object(JSON.parse(lines[0]?.trim() ?? ""));
189
+ } catch {
190
+ return { ignored: true, malformedLines: 0 };
191
+ }
192
+ if (!header || header.type !== "session" || typeof header.id !== "string") {
193
+ return { ignored: true, malformedLines: 0 };
194
+ }
195
+
196
+ const headerTimestamp = timestamp(header.timestamp, 0);
197
+ const usage: UsageRecord[] = [];
198
+ let malformedLines = 0;
199
+ let earliestActivityAt = Number.POSITIVE_INFINITY;
200
+ let lastActivityAt = headerTimestamp;
201
+ for (let index = 1; index < lines.length; index++) {
202
+ const line = lines[index]?.trim();
203
+ if (!line) continue;
204
+ try {
205
+ const entry = JSON.parse(line) as unknown;
206
+ const entryRecord = object(entry);
207
+ const entryTimestamp = timestamp(entryRecord?.timestamp, 0);
208
+ if (entryTimestamp > 0) {
209
+ earliestActivityAt = Math.min(earliestActivityAt, entryTimestamp);
210
+ lastActivityAt = Math.max(lastActivityAt, entryTimestamp);
211
+ }
212
+ const normalized = normalizeEntry(entry, header.id, filePath, index, headerTimestamp || entryTimestamp);
213
+ if (normalized) {
214
+ usage.push(normalized);
215
+ if (normalized.timestamp > 0) {
216
+ earliestActivityAt = Math.min(earliestActivityAt, normalized.timestamp);
217
+ lastActivityAt = Math.max(lastActivityAt, normalized.timestamp);
218
+ }
219
+ }
220
+ } catch {
221
+ malformedLines++;
222
+ }
223
+ }
224
+
225
+ const createdAt = headerTimestamp || (Number.isFinite(earliestActivityAt) ? earliestActivityAt : 0);
226
+ return {
227
+ ignored: false,
228
+ malformedLines,
229
+ session: {
230
+ path: resolve(filePath),
231
+ sessionId: header.id,
232
+ cwd: typeof header.cwd === "string" ? header.cwd : "",
233
+ createdAt,
234
+ lastActivityAt: Math.max(lastActivityAt, createdAt),
235
+ source: sessionSource(filePath),
236
+ usage,
237
+ },
238
+ };
239
+ }
240
+
241
+ export function usageFromEntries(
242
+ sessionId: string,
243
+ entries: readonly unknown[],
244
+ fallbackTimestamp = Date.now(),
245
+ sessionFile = "current-session.jsonl",
246
+ ): UsageRecord[] {
247
+ return entries.flatMap((entry, index) => {
248
+ const normalized = normalizeEntry(entry, sessionId, sessionFile, index, fallbackTimestamp);
249
+ return normalized ? [normalized] : [];
250
+ });
251
+ }
252
+
253
+ function hasPersistedSubagentChild(record: UsageRecord, sessionPaths: ReadonlySet<string>): boolean {
254
+ return record.toolName === "subagent" && Boolean(record.childSessionFiles?.some((file) => sessionPaths.has(resolve(file))));
255
+ }
256
+
257
+ export function deduplicateUsage(sessions: readonly SessionRecord[]): UsageRecord[] {
258
+ const sessionPaths = new Set(sessions.map((session) => resolve(session.path)));
259
+ const seen = new Set<string>();
260
+ const result: UsageRecord[] = [];
261
+ for (const session of sessions) {
262
+ for (const record of session.usage) {
263
+ if (hasPersistedSubagentChild(record, sessionPaths)) continue;
264
+ if (seen.has(record.fingerprint)) continue;
265
+ seen.add(record.fingerprint);
266
+ result.push(record);
267
+ }
268
+ }
269
+ return result;
270
+ }
271
+
272
+ function localDay(time: number): string {
273
+ const date = new Date(time);
274
+ const year = date.getFullYear();
275
+ const month = String(date.getMonth() + 1).padStart(2, "0");
276
+ const day = String(date.getDate()).padStart(2, "0");
277
+ return `${year}-${month}-${day}`;
278
+ }
279
+
280
+ function dayStart(now: number, daysBack: number): number {
281
+ const date = new Date(now);
282
+ date.setHours(0, 0, 0, 0);
283
+ date.setDate(date.getDate() - daysBack);
284
+ return date.getTime();
285
+ }
286
+
287
+ function rangeStart(range: StatsRange, now: number): number {
288
+ if (range === "7d") return dayStart(now, 6);
289
+ if (range === "30d") return dayStart(now, 29);
290
+ return Number.NEGATIVE_INFINITY;
291
+ }
292
+
293
+ function total(records: readonly UsageRecord[]): UsageTotals {
294
+ const result = emptyUsage();
295
+ for (const record of records) addUsage(result, record.usage);
296
+ return result;
297
+ }
298
+
299
+ function aggregateModels(records: readonly UsageRecord[]): ModelStats[] {
300
+ const byModel = new Map<string, UsageTotals>();
301
+ for (const record of records) {
302
+ const aggregate = byModel.get(record.model) ?? emptyUsage();
303
+ addUsage(aggregate, record.usage);
304
+ byModel.set(record.model, aggregate);
305
+ }
306
+ const grandTotal = [...byModel.values()].reduce((sum, usage) => sum + totalTokens(usage), 0);
307
+ return [...byModel.entries()]
308
+ .map(([model, usage]) => ({ ...usage, model, total: totalTokens(usage), share: grandTotal > 0 ? totalTokens(usage) / grandTotal : 0 }))
309
+ .sort((a, b) => b.total - a.total || a.model.localeCompare(b.model));
310
+ }
311
+
312
+ function streaks(days: readonly string[], now: number): { current: number; longest: number } {
313
+ const unique = [...new Set(days)].sort();
314
+ let longest = 0;
315
+ let run = 0;
316
+ let previous: Date | undefined;
317
+ for (const day of unique) {
318
+ const current = new Date(`${day}T12:00:00`);
319
+ if (previous) {
320
+ const expected = new Date(previous);
321
+ expected.setDate(expected.getDate() + 1);
322
+ run = localDay(expected.getTime()) === day ? run + 1 : 1;
323
+ } else {
324
+ run = 1;
325
+ }
326
+ longest = Math.max(longest, run);
327
+ previous = current;
328
+ }
329
+
330
+ let current = 0;
331
+ const set = new Set(unique);
332
+ const cursor = new Date(now);
333
+ cursor.setHours(12, 0, 0, 0);
334
+ while (set.has(localDay(cursor.getTime()))) {
335
+ current++;
336
+ cursor.setDate(cursor.getDate() - 1);
337
+ }
338
+ return { current, longest };
339
+ }
340
+
341
+ function uniqueSessions(sessions: readonly SessionRecord[]): SessionRecord[] {
342
+ const byId = new Map<string, SessionRecord>();
343
+ for (const session of sessions) {
344
+ const previous = byId.get(session.sessionId);
345
+ if (!previous || session.lastActivityAt > previous.lastActivityAt) byId.set(session.sessionId, session);
346
+ }
347
+ return [...byId.values()];
348
+ }
349
+
350
+ export function buildSnapshot(
351
+ index: StatsIndex,
352
+ currentEntries: readonly unknown[],
353
+ currentSessionId: string,
354
+ range: StatsRange,
355
+ now = Date.now(),
356
+ currentSessionFile = "current-session.jsonl",
357
+ ): StatsSnapshot {
358
+ const cutoff = rangeStart(range, now);
359
+ const periodRecords = range === "all"
360
+ ? index.usage
361
+ : index.usage.filter((record) => record.timestamp >= cutoff && record.timestamp <= now);
362
+ const dayTotals = new Map<string, number>();
363
+ for (const record of index.usage) {
364
+ if (record.timestamp <= 0) continue;
365
+ const day = localDay(record.timestamp);
366
+ dayTotals.set(day, (dayTotals.get(day) ?? 0) + totalTokens(record.usage));
367
+ }
368
+ const days = [...dayTotals.entries()]
369
+ .map(([day, dayTotal]): DayStats => ({ day, total: dayTotal }))
370
+ .sort((a, b) => a.day.localeCompare(b.day));
371
+ const models = aggregateModels(periodRecords);
372
+ const allModels = aggregateModels(index.usage);
373
+ const sessions = uniqueSessions(index.sessions);
374
+ const sessionPaths = new Set(index.sessions.map((session) => resolve(session.path)));
375
+ const currentRecords = usageFromEntries(currentSessionId, currentEntries, now, currentSessionFile)
376
+ .filter((record) => !hasPersistedSubagentChild(record, sessionPaths));
377
+ const streak = streaks(days.map((day) => day.day), now);
378
+ const mostActiveDay = days.reduce<DayStats | undefined>((best, day) => (!best || day.total > best.total ? day : best), undefined);
379
+
380
+ return {
381
+ range,
382
+ allTime: total(index.usage),
383
+ period: total(periodRecords),
384
+ currentSession: total(currentRecords),
385
+ models,
386
+ days,
387
+ sessionCount: sessions.length,
388
+ mainSessionCount: sessions.filter((session) => session.source === "main").length,
389
+ subagentSessionCount: sessions.filter((session) => session.source === "subagent").length,
390
+ activeDays: days.length,
391
+ currentStreak: streak.current,
392
+ longestStreak: streak.longest,
393
+ ...(mostActiveDay ? { mostActiveDay } : {}),
394
+ ...(allModels[0] ? { favoriteModel: allModels[0].model } : {}),
395
+ longestSessionMs: sessions.reduce(
396
+ (longest, session) => Math.max(longest, session.createdAt > 0 ? Math.max(0, session.lastActivityAt - session.createdAt) : 0),
397
+ 0,
398
+ ),
399
+ diagnostics: index.diagnostics,
400
+ };
401
+ }
402
+
403
+ export function makeIndex(sessions: SessionRecord[], diagnostics: StatsIndex["diagnostics"]): StatsIndex {
404
+ return { sessions, usage: deduplicateUsage(sessions), diagnostics };
405
+ }
package/types.ts ADDED
@@ -0,0 +1,109 @@
1
+ export interface UsageTotals {
2
+ input: number;
3
+ output: number;
4
+ cacheRead: number;
5
+ cacheWrite: number;
6
+ reasoning: number;
7
+ cost: number;
8
+ calls: number;
9
+ }
10
+
11
+ export type SessionSource = "main" | "subagent";
12
+
13
+ export interface UsageRecord {
14
+ fingerprint: string;
15
+ timestamp: number;
16
+ model: string;
17
+ usage: UsageTotals;
18
+ kind: "assistant" | "tool" | "summary";
19
+ toolName?: string;
20
+ childSessionFiles?: string[];
21
+ }
22
+
23
+ export interface SessionRecord {
24
+ path: string;
25
+ sessionId: string;
26
+ cwd: string;
27
+ createdAt: number;
28
+ lastActivityAt: number;
29
+ source: SessionSource;
30
+ usage: UsageRecord[];
31
+ }
32
+
33
+ export interface CachedFileRecord {
34
+ size: number;
35
+ mtimeMs: number;
36
+ ignored?: true;
37
+ session?: SessionRecord;
38
+ malformedLines: number;
39
+ }
40
+
41
+ export interface StatsCacheFile {
42
+ version: 1;
43
+ files: Record<string, CachedFileRecord>;
44
+ }
45
+
46
+ export interface ScanDiagnostics {
47
+ discoveredFiles: number;
48
+ parsedFiles: number;
49
+ reusedFiles: number;
50
+ ignoredFiles: number;
51
+ unreadableFiles: number;
52
+ malformedLines: number;
53
+ }
54
+
55
+ export interface StatsIndex {
56
+ sessions: SessionRecord[];
57
+ usage: UsageRecord[];
58
+ diagnostics: ScanDiagnostics;
59
+ }
60
+
61
+ export type StatsRange = "all" | "7d" | "30d";
62
+
63
+ export interface ModelStats extends UsageTotals {
64
+ model: string;
65
+ total: number;
66
+ share: number;
67
+ }
68
+
69
+ export interface DayStats {
70
+ day: string;
71
+ total: number;
72
+ }
73
+
74
+ export interface StatsSnapshot {
75
+ range: StatsRange;
76
+ allTime: UsageTotals;
77
+ period: UsageTotals;
78
+ currentSession: UsageTotals;
79
+ models: ModelStats[];
80
+ days: DayStats[];
81
+ sessionCount: number;
82
+ mainSessionCount: number;
83
+ subagentSessionCount: number;
84
+ activeDays: number;
85
+ currentStreak: number;
86
+ longestStreak: number;
87
+ mostActiveDay?: DayStats;
88
+ favoriteModel?: string;
89
+ longestSessionMs: number;
90
+ diagnostics: ScanDiagnostics;
91
+ }
92
+
93
+ export function emptyUsage(): UsageTotals {
94
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0, calls: 0 };
95
+ }
96
+
97
+ export function addUsage(target: UsageTotals, usage: UsageTotals): void {
98
+ target.input += usage.input;
99
+ target.output += usage.output;
100
+ target.cacheRead += usage.cacheRead;
101
+ target.cacheWrite += usage.cacheWrite;
102
+ target.reasoning += usage.reasoning;
103
+ target.cost += usage.cost;
104
+ target.calls += usage.calls;
105
+ }
106
+
107
+ export function totalTokens(usage: UsageTotals): number {
108
+ return usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
109
+ }
package/widget.ts ADDED
@@ -0,0 +1,301 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
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";
4
+
5
+ export type WidgetState =
6
+ | { kind: "loading"; completed: number; total: number }
7
+ | { kind: "error"; message: string }
8
+ | { kind: "ready"; snapshot: StatsSnapshot };
9
+
10
+ export interface StatsWidgetOptions {
11
+ theme: Theme;
12
+ requestRender: () => void;
13
+ onClose: () => void;
14
+ onRefresh: () => void;
15
+ getSnapshot: (range: StatsRange) => StatsSnapshot | undefined;
16
+ onDispose?: () => void;
17
+ }
18
+
19
+ const MODEL_PAGE_SIZE = 8;
20
+ const RANGES: StatsRange[] = ["all", "7d", "30d"];
21
+ const RANGE_LABELS: Record<StatsRange, string> = { all: "All time", "7d": "Last 7 days", "30d": "Last 30 days" };
22
+
23
+ export function formatTokens(value: number): string {
24
+ 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`;
28
+ return String(safe);
29
+ }
30
+
31
+ function formatCost(value: number): string {
32
+ return `$${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)}`;
33
+ }
34
+
35
+ function formatDuration(milliseconds: number): string {
36
+ if (milliseconds <= 0) return "0m";
37
+ const minutes = Math.floor(milliseconds / 60_000);
38
+ const days = Math.floor(minutes / 1_440);
39
+ const hours = Math.floor((minutes % 1_440) / 60);
40
+ const mins = minutes % 60;
41
+ if (days > 0) return `${days}d ${hours}h`;
42
+ if (hours > 0) return `${hours}h ${mins}m`;
43
+ return `${mins}m`;
44
+ }
45
+
46
+ function cacheHit(usage: UsageTotals): string {
47
+ const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
48
+ return prompt > 0 ? `${((usage.cacheRead / prompt) * 100).toFixed(1)}%` : "—";
49
+ }
50
+
51
+ function localDay(time: number): string {
52
+ const date = new Date(time);
53
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
54
+ }
55
+
56
+ function plainPad(value: string, width: number): string {
57
+ const clipped = truncateToWidth(value, Math.max(0, width), "");
58
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
59
+ }
60
+
61
+ export class StatsWidget implements Component {
62
+ private state: WidgetState = { kind: "loading", completed: 0, total: 0 };
63
+ private tab: "overview" | "models" = "overview";
64
+ private range: StatsRange = "all";
65
+ private modelOffset = 0;
66
+ private readonly options: StatsWidgetOptions;
67
+
68
+ constructor(options: StatsWidgetOptions) {
69
+ this.options = options;
70
+ }
71
+
72
+ getRange(): StatsRange {
73
+ return this.range;
74
+ }
75
+
76
+ setLoading(completed = 0, total = 0): void {
77
+ this.state = { kind: "loading", completed, total };
78
+ this.options.requestRender();
79
+ }
80
+
81
+ setProgress(completed: number, total: number): void {
82
+ if (this.state.kind !== "loading") return;
83
+ this.state = { kind: "loading", completed, total };
84
+ this.options.requestRender();
85
+ }
86
+
87
+ setReady(snapshot: StatsSnapshot): void {
88
+ this.state = { kind: "ready", snapshot };
89
+ this.modelOffset = Math.min(this.modelOffset, Math.max(0, snapshot.models.length - MODEL_PAGE_SIZE));
90
+ this.options.requestRender();
91
+ }
92
+
93
+ setError(message: string): void {
94
+ this.state = { kind: "error", message };
95
+ this.options.requestRender();
96
+ }
97
+
98
+ handleInput(data: string): void {
99
+ if (matchesKey(data, Key.escape)) {
100
+ this.options.onClose();
101
+ return;
102
+ }
103
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
104
+ this.tab = this.tab === "overview" ? "models" : "overview";
105
+ this.options.requestRender();
106
+ return;
107
+ }
108
+ if (matchesKey(data, Key.left)) {
109
+ this.tab = this.tab === "models" ? "overview" : "models";
110
+ this.options.requestRender();
111
+ return;
112
+ }
113
+ if (data === "r" || data === "R") {
114
+ const next = RANGES[(RANGES.indexOf(this.range) + 1) % RANGES.length]!;
115
+ this.range = next;
116
+ const snapshot = this.options.getSnapshot(next);
117
+ if (snapshot) this.setReady(snapshot);
118
+ return;
119
+ }
120
+ if (data === "u" || data === "U") {
121
+ this.options.onRefresh();
122
+ return;
123
+ }
124
+ if (this.state.kind === "ready" && this.tab === "models") {
125
+ if (matchesKey(data, Key.down)) {
126
+ this.modelOffset = Math.min(Math.max(0, this.state.snapshot.models.length - MODEL_PAGE_SIZE), this.modelOffset + 1);
127
+ this.options.requestRender();
128
+ } else if (matchesKey(data, Key.up)) {
129
+ this.modelOffset = Math.max(0, this.modelOffset - 1);
130
+ this.options.requestRender();
131
+ }
132
+ }
133
+ }
134
+
135
+ dispose(): void {
136
+ this.options.onDispose?.();
137
+ }
138
+
139
+ invalidate(): void {
140
+ // Rendering uses the live injected theme and has no pre-baked cache.
141
+ }
142
+
143
+ render(width: number): string[] {
144
+ if (width <= 0) return [""];
145
+ const lines = this.renderContent(width).map((line) => truncateToWidth(line, width, ""));
146
+ return lines.length > 0 ? lines : [""];
147
+ }
148
+
149
+ private renderContent(width: number): string[] {
150
+ const { theme } = this.options;
151
+ const lines: string[] = [];
152
+ const selected = (text: string) => theme.bg("selectedBg", theme.fg("accent", theme.bold(` ${text} `)));
153
+ const normal = (text: string) => theme.fg("muted", ` ${text} `);
154
+ lines.push(theme.fg("borderAccent", "─".repeat(Math.max(1, width))));
155
+ lines.push(
156
+ `${theme.fg("accent", theme.bold(" PI STATS "))} ${this.tab === "overview" ? selected("Overview") : normal("Overview")} ${this.tab === "models" ? selected("Models") : normal("Models")}`,
157
+ );
158
+
159
+ if (this.state.kind === "loading") {
160
+ const progress = this.state.total > 0 ? ` ${this.state.completed}/${this.state.total}` : "";
161
+ lines.push("", theme.fg("accent", " ◌ ") + theme.fg("text", `Scanning Pi sessions…${progress}`), "");
162
+ lines.push(theme.fg("dim", " Esc close"));
163
+ lines.push(theme.fg("borderMuted", "─".repeat(Math.max(1, width))));
164
+ return lines;
165
+ }
166
+ if (this.state.kind === "error") {
167
+ lines.push("", theme.fg("error", ` Stats unavailable: ${this.state.message}`), "");
168
+ lines.push(theme.fg("dim", " u retry · Esc close"));
169
+ lines.push(theme.fg("borderMuted", "─".repeat(Math.max(1, width))));
170
+ return lines;
171
+ }
172
+
173
+ const snapshot = this.state.snapshot;
174
+ 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]}`)}`,
176
+ );
177
+ lines.push(...(this.tab === "overview" ? this.renderOverview(snapshot, width) : this.renderModels(snapshot, width)));
178
+ const scanWarnings: string[] = [];
179
+ if (snapshot.diagnostics.unreadableFiles > 0) scanWarnings.push(`${snapshot.diagnostics.unreadableFiles} unreadable file(s)`);
180
+ if (snapshot.diagnostics.malformedLines > 0) scanWarnings.push(`${snapshot.diagnostics.malformedLines} malformed line(s)`);
181
+ 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);
183
+ lines.push(theme.fg("borderMuted", "─".repeat(Math.max(1, width))));
184
+ return lines;
185
+ }
186
+
187
+ private renderUsage(label: string, usage: UsageTotals, width: number): string[] {
188
+ 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", " · "))}`];
200
+ }
201
+
202
+ private renderOverview(snapshot: StatsSnapshot, width: number): string[] {
203
+ 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),
207
+ ];
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
+ }
229
+ } 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.`));
235
+ }
236
+ return lines;
237
+ }
238
+
239
+ private renderHeatmap(snapshot: StatsSnapshot, width: number): string[] {
240
+ const { theme } = this.options;
241
+ const weeks = Math.max(8, Math.min(53, width - 8));
242
+ const today = new Date();
243
+ today.setHours(12, 0, 0, 0);
244
+ const end = new Date(today);
245
+ end.setDate(end.getDate() + (7 - ((end.getDay() + 6) % 7) - 1));
246
+ const start = new Date(end);
247
+ start.setDate(start.getDate() - weeks * 7 + 1);
248
+ const totals = new Map(snapshot.days.map((day) => [day.day, day.total]));
249
+ const maximum = Math.max(0, ...snapshot.days.map((day) => day.total));
250
+ const glyph = (value: number): string => {
251
+ if (value <= 0 || maximum <= 0) return theme.fg("dim", "·");
252
+ const level = Math.max(1, Math.min(4, Math.ceil((Math.log1p(value) / Math.log1p(maximum)) * 4)));
253
+ if (level === 1) return theme.fg("muted", "░");
254
+ if (level === 2) return theme.fg("success", "▒");
255
+ if (level === 3) return theme.fg("warning", "▓");
256
+ return theme.fg("accent", "█");
257
+ };
258
+ const lines = [` ${theme.fg("muted", `All-time activity · trailing ${weeks} weeks`)}`];
259
+ for (let weekday = 0; weekday < 7; weekday++) {
260
+ const label = weekday === 0 ? "Mon" : weekday === 2 ? "Wed" : weekday === 4 ? "Fri" : " ";
261
+ let row = ` ${theme.fg("dim", label)} `;
262
+ for (let week = 0; week < weeks; week++) {
263
+ const date = new Date(start);
264
+ date.setDate(date.getDate() + week * 7 + weekday);
265
+ row += glyph(totals.get(localDay(date.getTime())) ?? 0);
266
+ }
267
+ lines.push(row);
268
+ }
269
+ return lines;
270
+ }
271
+
272
+ private renderModels(snapshot: StatsSnapshot, width: number): string[] {
273
+ const { theme } = this.options;
274
+ 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
+ }
297
+ }
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
+ return lines;
300
+ }
301
+ }