@hank-warren/pi-stats 0.3.3 → 0.4.1
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/CHANGELOG.md +24 -0
- package/README.md +14 -4
- package/cache.ts +115 -36
- package/index.ts +24 -1
- package/package.json +1 -1
- package/stats.ts +156 -1
- package/types.ts +38 -1
- package/widget.ts +151 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @hank-warren/pi-stats
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c079c51: Scan sessions through a bounded worker pool instead of one file at a time.
|
|
8
|
+
|
|
9
|
+
The stats scan now runs an ordered pool capped at eight concurrent readers, reduced by the discovery index, which shortens a cold scan over a large session history. Results stay in discovery order regardless of completion order, and progress is reported monotonically.
|
|
10
|
+
|
|
11
|
+
Cancellation is settled rather than abandoned: an abort stops further work being assigned and publishes nothing afterwards, but waits for reads already in flight, so the caller's `catch` never runs while file reads are still landing in the result array or progress is still being reported into a dashboard that is being torn down.
|
|
12
|
+
|
|
13
|
+
## 0.4.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 5e0e3f5: Add Tools and Projects tabs to the `/stats` dashboard.
|
|
18
|
+
|
|
19
|
+
The Tools tab ranks every recorded tool call for the selected range by call count, with a share bar, error count, and error rate. Tool calls are now tallied from every tool result, not just the few that record model usage, and the `Source` column names the package that registered each tool so extension-provided tools are distinguishable from built-ins. Tools from extensions that are no longer installed still rank but show no source.
|
|
20
|
+
|
|
21
|
+
The Projects tab rolls sessions up by their recorded working directory, ranked by tokens, with session counts and cost.
|
|
22
|
+
|
|
23
|
+
Tool calls are counted separately from token usage and never affect model attribution. They de-duplicate on the provider tool-call id, so forked sessions do not inflate counts. `Tab` and the arrow keys now cycle four tabs and wrap in both directions.
|
|
24
|
+
|
|
25
|
+
The cache format is bumped to version 3 to store the new tool tallies, so the first run after upgrading performs one full rescan.
|
|
26
|
+
|
|
3
27
|
## 0.3.3
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -20,8 +20,8 @@ Do not install this standalone package on a host that also installs the aggregat
|
|
|
20
20
|
|
|
21
21
|
Run `/stats` in interactive mode. The dashboard temporarily replaces the editor and closes with `Escape`.
|
|
22
22
|
|
|
23
|
-
- `Tab` or `←`/`→`:
|
|
24
|
-
- `↑`/`↓`: scroll the
|
|
23
|
+
- `Tab` or `←`/`→`: cycle Overview, Models, Tools, and Projects (wraps in both directions)
|
|
24
|
+
- `↑`/`↓`: scroll the list on Models, Tools, and Projects
|
|
25
25
|
- `r`: cycle All time, Last 7 days, and Last 30 days
|
|
26
26
|
- `e`: toggle compact totals (`1.5B`) and exact grouped digits (`1,486,201,553`)
|
|
27
27
|
- `u`: force a full rescan and rebuild the session index
|
|
@@ -31,12 +31,18 @@ The Overview compares current-session, selected-range, and all-time usage in one
|
|
|
31
31
|
|
|
32
32
|
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`.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
The Tools tab ranks every recorded tool call for the selected range by call count, with a share bar, an error count, and an error rate. Most tool calls record no model usage at all, so they are counted separately from tokens and never affect model attribution. The `Source` column names the package that registered each tool, read from the live tool registry, so extension-provided tools are distinguishable from built-ins at a glance. Because the registry only knows what is installed right now, a tool from an extension you have since removed still appears in the ranking but shows `—` instead of a source.
|
|
35
|
+
|
|
36
|
+
The Projects tab rolls sessions up by the working directory recorded in each session header, ranked by tokens, with session counts and recorded cost. Sessions whose header carries no working directory are omitted.
|
|
37
|
+
|
|
38
|
+
Every tab is 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.
|
|
35
39
|
|
|
36
40
|
## What is counted
|
|
37
41
|
|
|
38
42
|
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.
|
|
39
43
|
|
|
44
|
+
Tool calls are counted separately from tokens, because most of them record no model usage. Every tool result in a session is tallied by name, along with whether it reported an error. Provider tool-call ids are reused verbatim when a session is forked, so they double as the de-duplication key and a forked branch never inflates the counts. Namespaced names such as `functions.bash` are ranked under their base name.
|
|
45
|
+
|
|
40
46
|
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.
|
|
41
47
|
|
|
42
48
|
### Extension usage sidecars
|
|
@@ -70,6 +76,10 @@ Each variable is matched exactly: `PI_STATS_DISABLE_USAGE_SIDECARS=true` does no
|
|
|
70
76
|
|
|
71
77
|
**Guardian or other extension usage is missing.** Sidecar reading is skipped entirely when `PI_STATS_DISABLE_USAGE_SIDECARS=1`. Otherwise confirm the writing extension is enabled and that `<agent dir>/<extension>/usage.jsonl` exists; sidecars are only discovered one level below the agent directory.
|
|
72
78
|
|
|
79
|
+
**A tool shows `—` in the Source column.** The source is resolved from the live tool registry, which only knows currently installed tools. A tool recorded by an extension that is no longer installed keeps its ranking but cannot be attributed to a package. This is also expected for sessions created by a different Pi host.
|
|
80
|
+
|
|
81
|
+
**Tool counts jumped after upgrading.** Versions before this one did not record tool calls at all, and the cache was rebuilt once on first run to backfill them from your existing sessions. The one-time rescan is expected; later runs reuse the cache as usual.
|
|
82
|
+
|
|
73
83
|
**A model shows under `Tools/summaries` instead of its own row.** That bucket holds nested usage that carries no attributable response model, such as some compaction and branch-summary records. It also absorbs Pi's own placeholder turns — assistant messages whose model is `<synthetic>` or missing — but only when they recorded no tokens and no cost; a placeholder that was actually billed keeps its own row so the spend stays visible. Everything in the bucket is counted in the totals either way.
|
|
74
84
|
|
|
75
85
|
**Costs read `$0.00` or look wrong.** Displayed cost is whatever Pi already recorded in the session; `pi-stats` performs no pricing lookup and contacts no billing API. Providers used through a subscription or proxy commonly record zero cost.
|
|
@@ -86,6 +96,6 @@ Sessions remain authoritative. For fast repeat opens, the extension stores one d
|
|
|
86
96
|
~/.pi/agent/pi-stats/cache.json
|
|
87
97
|
```
|
|
88
98
|
|
|
89
|
-
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.
|
|
99
|
+
The location follows `PI_CODING_AGENT_DIR`. The cache contains file stat identity, session IDs/timestamps/working directories, model names, tool names with call and error counts, usage counters, and de-duplication fingerprints. It never stores prompts, responses, tool arguments, 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.
|
|
90
100
|
|
|
91
101
|
No network or billing API is used. Displayed costs are the estimates already recorded by Pi.
|
package/cache.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { makeIndex, parseSessionText, parseUsageSidecar } from "./stats.ts";
|
|
6
6
|
import type { CachedFileRecord, ScanDiagnostics, SessionRecord, StatsCacheFile, StatsIndex, UsageRecord } from "./types.ts";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** Bumped to 3 when tool-call records were added; older caches lack them and must be reparsed. */
|
|
9
|
+
const CACHE_VERSION = 3 as const;
|
|
9
10
|
const SKIP_DIRECTORIES = new Set(["subagent-artifacts"]);
|
|
10
11
|
/** Extensions record out-of-transcript model usage in <agentDir>/<extension>/usage.jsonl. */
|
|
11
12
|
const USAGE_SIDECAR_NAMES = new Set(["usage.jsonl", "usage.jsonl.1"]);
|
|
@@ -239,6 +240,64 @@ export function statsCachePath(agentDir: string): string {
|
|
|
239
240
|
return join(agentDir, "pi-stats", "cache.json");
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
export async function mapConcurrentOrdered<T, R>(
|
|
244
|
+
items: readonly T[],
|
|
245
|
+
limit: number,
|
|
246
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
247
|
+
onProgress?: (completed: number, total: number) => void,
|
|
248
|
+
signal?: AbortSignal,
|
|
249
|
+
): Promise<R[]> {
|
|
250
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("concurrency limit must be positive");
|
|
251
|
+
const results = new Array<R>(items.length);
|
|
252
|
+
let nextIndex = 0;
|
|
253
|
+
let completed = 0;
|
|
254
|
+
// The first failure (an abort, a worker throw, or a progress callback that
|
|
255
|
+
// throws) stops work being assigned and stops anything else being published,
|
|
256
|
+
// but never abandons work already in flight: the caller's `catch` must not
|
|
257
|
+
// run while file reads are still landing in `results` and progress is still
|
|
258
|
+
// being reported into a widget that is about to be torn down.
|
|
259
|
+
let failure: unknown;
|
|
260
|
+
const failed = () => failure !== undefined;
|
|
261
|
+
const run = async () => {
|
|
262
|
+
while (!failed()) {
|
|
263
|
+
try {
|
|
264
|
+
signal?.throwIfAborted();
|
|
265
|
+
} catch (error) {
|
|
266
|
+
failure ??= error;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const index = nextIndex;
|
|
270
|
+
if (index >= items.length) return;
|
|
271
|
+
nextIndex += 1;
|
|
272
|
+
try {
|
|
273
|
+
const value = await worker(items[index]!, index);
|
|
274
|
+
// Re-check *after* the await: an abort during the read must not still
|
|
275
|
+
// publish a result or a progress tick into a dashboard that is being
|
|
276
|
+
// torn down. A sibling runner may not have noticed the abort yet.
|
|
277
|
+
signal?.throwIfAborted();
|
|
278
|
+
if (failed()) return;
|
|
279
|
+
results[index] = value;
|
|
280
|
+
completed += 1;
|
|
281
|
+
onProgress?.(completed, items.length);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
failure ??= error;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
|
|
289
|
+
if (failed()) throw failure;
|
|
290
|
+
return results;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type ScannedFile = {
|
|
294
|
+
file: string;
|
|
295
|
+
record?: CachedFileRecord;
|
|
296
|
+
reused: boolean;
|
|
297
|
+
unreadable: boolean;
|
|
298
|
+
changed: boolean;
|
|
299
|
+
};
|
|
300
|
+
|
|
242
301
|
export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
243
302
|
const roots = await resolveSessionRoots(options);
|
|
244
303
|
const sessionFiles = await discoverJsonlFiles(roots, options.signal);
|
|
@@ -259,45 +318,65 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
|
259
318
|
};
|
|
260
319
|
let changed = options.force === true || !loaded.valid;
|
|
261
320
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
321
|
+
const scanned = await mapConcurrentOrdered(
|
|
322
|
+
files,
|
|
323
|
+
8,
|
|
324
|
+
async (file): Promise<ScannedFile> => {
|
|
325
|
+
options.signal?.throwIfAborted();
|
|
326
|
+
try {
|
|
327
|
+
const metadata = await stat(file);
|
|
328
|
+
const previous = loaded.cache.files[file];
|
|
329
|
+
if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
|
|
330
|
+
return { file, record: previous!, reused: true, unreadable: false, changed: false };
|
|
331
|
+
}
|
|
332
|
+
if (sidecars.has(file)) {
|
|
333
|
+
const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
|
|
334
|
+
return {
|
|
335
|
+
file,
|
|
336
|
+
record: {
|
|
337
|
+
size: metadata.size,
|
|
338
|
+
mtimeMs: metadata.mtimeMs,
|
|
339
|
+
sidecar: parsed.records,
|
|
340
|
+
malformedLines: parsed.malformedLines,
|
|
341
|
+
},
|
|
342
|
+
reused: false,
|
|
343
|
+
unreadable: false,
|
|
344
|
+
changed: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
282
347
|
const parsed = parseSessionText(file, await readFile(file, "utf8"));
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
348
|
+
return {
|
|
349
|
+
file,
|
|
350
|
+
record: {
|
|
351
|
+
size: metadata.size,
|
|
352
|
+
mtimeMs: metadata.mtimeMs,
|
|
353
|
+
...(parsed.ignored ? { ignored: true as const } : {}),
|
|
354
|
+
...(parsed.session ? { session: parsed.session } : {}),
|
|
355
|
+
malformedLines: parsed.malformedLines,
|
|
356
|
+
},
|
|
357
|
+
reused: false,
|
|
358
|
+
unreadable: false,
|
|
359
|
+
changed: true,
|
|
289
360
|
};
|
|
290
|
-
|
|
291
|
-
|
|
361
|
+
} catch {
|
|
362
|
+
options.signal?.throwIfAborted();
|
|
363
|
+
return { file, reused: false, unreadable: true, changed: true };
|
|
292
364
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
365
|
+
},
|
|
366
|
+
options.onProgress,
|
|
367
|
+
options.signal,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
for (const result of scanned) {
|
|
371
|
+
if (result.record) {
|
|
372
|
+
next.files[result.file] = result.record;
|
|
373
|
+
if (result.reused) diagnostics.reusedFiles++;
|
|
374
|
+
else diagnostics.parsedFiles++;
|
|
375
|
+
if (result.record.ignored) diagnostics.ignoredFiles++;
|
|
376
|
+
diagnostics.malformedLines += result.record.malformedLines;
|
|
299
377
|
}
|
|
300
|
-
|
|
378
|
+
if (result.unreadable) diagnostics.unreadableFiles++;
|
|
379
|
+
if (result.changed) changed = true;
|
|
301
380
|
}
|
|
302
381
|
|
|
303
382
|
if (Object.keys(loaded.cache.files).some((file) => !(file in next.files))) changed = true;
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { isExternalSessionDirectory, scanStats } from "./cache.ts";
|
|
3
|
-
import { buildSnapshot, makeIndex, usageFromEntries } from "./stats.ts";
|
|
3
|
+
import { buildSnapshot, makeIndex, toolCallsFromEntries, usageFromEntries } from "./stats.ts";
|
|
4
4
|
import type { SessionRecord, StatsIndex, StatsRange } from "./types.ts";
|
|
5
5
|
import { StatsWidget } from "./widget.ts";
|
|
6
6
|
|
|
@@ -25,10 +25,30 @@ export function includeInMemorySession(index: StatsIndex, ctx: ExtensionCommandC
|
|
|
25
25
|
lastActivityAt: usage.reduce((latest, record) => Math.max(latest, record.timestamp), Number.isFinite(createdAt) ? createdAt : 0),
|
|
26
26
|
source: "main",
|
|
27
27
|
usage,
|
|
28
|
+
toolCalls: toolCallsFromEntries(sessionId, entries, Number.isFinite(createdAt) ? createdAt : Date.now()),
|
|
28
29
|
};
|
|
29
30
|
return makeIndex([...index.sessions, session], index.diagnostics, index.usage.filter((record) => record.kind === "sidecar"));
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Map tool name to the package that registered it, for the Tools tab source column.
|
|
35
|
+
* Only currently-installed tools are known; historical tools from removed extensions stay unlabelled.
|
|
36
|
+
*/
|
|
37
|
+
function toolSourceMap(pi: ExtensionAPI): Map<string, string> {
|
|
38
|
+
const sources = new Map<string, string>();
|
|
39
|
+
try {
|
|
40
|
+
for (const tool of pi.getAllTools()) {
|
|
41
|
+
const source = tool.sourceInfo?.source;
|
|
42
|
+
if (typeof tool.name === "string" && typeof source === "string" && source.length > 0) {
|
|
43
|
+
sources.set(tool.name, source);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// An older host without getAllTools() simply renders no source column values.
|
|
48
|
+
}
|
|
49
|
+
return sources;
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
export default function statsExtension(pi: ExtensionAPI): void {
|
|
33
53
|
pi.registerCommand("stats", {
|
|
34
54
|
description: "Open all-time Pi token statistics",
|
|
@@ -44,6 +64,8 @@ export default function statsExtension(pi: ExtensionAPI): void {
|
|
|
44
64
|
let currentIndex: StatsIndex | undefined;
|
|
45
65
|
let closed = false;
|
|
46
66
|
|
|
67
|
+
const toolSources = toolSourceMap(pi);
|
|
68
|
+
|
|
47
69
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
48
70
|
const snapshot = (range: StatsRange) =>
|
|
49
71
|
currentIndex
|
|
@@ -54,6 +76,7 @@ export default function statsExtension(pi: ExtensionAPI): void {
|
|
|
54
76
|
range,
|
|
55
77
|
Date.now(),
|
|
56
78
|
ctx.sessionManager.getSessionFile() ?? "current-session.jsonl",
|
|
79
|
+
toolSources,
|
|
57
80
|
)
|
|
58
81
|
: undefined;
|
|
59
82
|
|
package/package.json
CHANGED
package/stats.ts
CHANGED
|
@@ -6,10 +6,13 @@ import {
|
|
|
6
6
|
totalTokens,
|
|
7
7
|
type DayStats,
|
|
8
8
|
type ModelStats,
|
|
9
|
+
type ProjectStats,
|
|
9
10
|
type SessionRecord,
|
|
10
11
|
type StatsIndex,
|
|
11
12
|
type StatsRange,
|
|
12
13
|
type StatsSnapshot,
|
|
14
|
+
type ToolCallRecord,
|
|
15
|
+
type ToolStats,
|
|
13
16
|
type UsageRecord,
|
|
14
17
|
type UsageTotals,
|
|
15
18
|
} from "./types.ts";
|
|
@@ -203,6 +206,39 @@ function normalizeEntry(
|
|
|
203
206
|
return undefined;
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Extract a tool call from any toolResult entry, whether or not it recorded model usage.
|
|
211
|
+
* normalizeEntry deliberately drops usage-free results; this is the parallel path that keeps them.
|
|
212
|
+
*/
|
|
213
|
+
function normalizeToolCall(
|
|
214
|
+
entryValue: unknown,
|
|
215
|
+
sessionId: string,
|
|
216
|
+
lineIndex: number,
|
|
217
|
+
fallbackTimestamp: number,
|
|
218
|
+
): ToolCallRecord | undefined {
|
|
219
|
+
const entry = object(entryValue);
|
|
220
|
+
if (!entry || entry.type !== "message") return undefined;
|
|
221
|
+
const message = object(entry.message);
|
|
222
|
+
if (!message || message.role !== "toolResult") return undefined;
|
|
223
|
+
const toolName = typeof message.toolName === "string" && message.toolName.length > 0 ? message.toolName : "tool";
|
|
224
|
+
const entryTimestamp = timestamp(entry.timestamp, fallbackTimestamp);
|
|
225
|
+
// The provider tool-call id survives session forks verbatim, so it is the natural dedup key.
|
|
226
|
+
const toolCallId = typeof message.toolCallId === "string" && message.toolCallId.length > 0 ? message.toolCallId : undefined;
|
|
227
|
+
return {
|
|
228
|
+
fingerprint: toolCallId ?? fingerprint([sessionId, lineIndex, toolName, entryTimestamp]).slice(0, 16),
|
|
229
|
+
timestamp: timestamp(message.timestamp, entryTimestamp),
|
|
230
|
+
toolName: normalizeToolName(toolName),
|
|
231
|
+
isError: message.isError === true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Providers sometimes namespace tool names (`functions.bash`); rank them as one tool. */
|
|
236
|
+
function normalizeToolName(toolName: string): string {
|
|
237
|
+
const separator = toolName.lastIndexOf(".");
|
|
238
|
+
const tail = separator >= 0 ? toolName.slice(separator + 1) : toolName;
|
|
239
|
+
return tail.length > 0 ? tail : toolName;
|
|
240
|
+
}
|
|
241
|
+
|
|
206
242
|
function sessionSource(filePath: string): "main" | "subagent" {
|
|
207
243
|
return /(?:^|\/)run-\d+\/session\.jsonl$/.test(filePath) ? "subagent" : "main";
|
|
208
244
|
}
|
|
@@ -221,6 +257,7 @@ export function parseSessionText(filePath: string, text: string): ParseResult {
|
|
|
221
257
|
|
|
222
258
|
const headerTimestamp = timestamp(header.timestamp, 0);
|
|
223
259
|
const usage: UsageRecord[] = [];
|
|
260
|
+
const toolCalls: ToolCallRecord[] = [];
|
|
224
261
|
let malformedLines = 0;
|
|
225
262
|
let earliestActivityAt = Number.POSITIVE_INFINITY;
|
|
226
263
|
let lastActivityAt = headerTimestamp;
|
|
@@ -235,6 +272,8 @@ export function parseSessionText(filePath: string, text: string): ParseResult {
|
|
|
235
272
|
earliestActivityAt = Math.min(earliestActivityAt, entryTimestamp);
|
|
236
273
|
lastActivityAt = Math.max(lastActivityAt, entryTimestamp);
|
|
237
274
|
}
|
|
275
|
+
const toolCall = normalizeToolCall(entry, header.id, index, headerTimestamp || entryTimestamp);
|
|
276
|
+
if (toolCall) toolCalls.push(toolCall);
|
|
238
277
|
const normalized = normalizeEntry(entry, header.id, filePath, index, headerTimestamp || entryTimestamp);
|
|
239
278
|
if (normalized) {
|
|
240
279
|
usage.push(normalized);
|
|
@@ -260,6 +299,7 @@ export function parseSessionText(filePath: string, text: string): ParseResult {
|
|
|
260
299
|
lastActivityAt: Math.max(lastActivityAt, createdAt),
|
|
261
300
|
source: sessionSource(filePath),
|
|
262
301
|
usage,
|
|
302
|
+
toolCalls,
|
|
263
303
|
},
|
|
264
304
|
};
|
|
265
305
|
}
|
|
@@ -319,10 +359,35 @@ export function usageFromEntries(
|
|
|
319
359
|
});
|
|
320
360
|
}
|
|
321
361
|
|
|
362
|
+
/** Tool calls for the live in-memory session, which has no file on disk to rescan. */
|
|
363
|
+
export function toolCallsFromEntries(
|
|
364
|
+
sessionId: string,
|
|
365
|
+
entries: readonly unknown[],
|
|
366
|
+
fallbackTimestamp = Date.now(),
|
|
367
|
+
): ToolCallRecord[] {
|
|
368
|
+
return entries.flatMap((entry, index) => {
|
|
369
|
+
const normalized = normalizeToolCall(entry, sessionId, index, fallbackTimestamp);
|
|
370
|
+
return normalized ? [normalized] : [];
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
322
374
|
function hasPersistedSubagentChild(record: UsageRecord, sessionPaths: ReadonlySet<string>): boolean {
|
|
323
375
|
return record.toolName === "subagent" && Boolean(record.childSessionFiles?.some((file) => sessionPaths.has(resolve(file))));
|
|
324
376
|
}
|
|
325
377
|
|
|
378
|
+
export function deduplicateToolCalls(sessions: readonly SessionRecord[]): ToolCallRecord[] {
|
|
379
|
+
const seen = new Set<string>();
|
|
380
|
+
const result: ToolCallRecord[] = [];
|
|
381
|
+
for (const session of sessions) {
|
|
382
|
+
for (const record of session.toolCalls ?? []) {
|
|
383
|
+
if (seen.has(record.fingerprint)) continue;
|
|
384
|
+
seen.add(record.fingerprint);
|
|
385
|
+
result.push(record);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
|
|
326
391
|
export function deduplicateUsage(sessions: readonly SessionRecord[], sidecar: readonly UsageRecord[] = []): UsageRecord[] {
|
|
327
392
|
const sessionPaths = new Set(sessions.map((session) => resolve(session.path)));
|
|
328
393
|
const seen = new Set<string>();
|
|
@@ -383,6 +448,83 @@ function aggregateModels(records: readonly UsageRecord[]): ModelStats[] {
|
|
|
383
448
|
.sort((a, b) => b.total - a.total || a.model.localeCompare(b.model));
|
|
384
449
|
}
|
|
385
450
|
|
|
451
|
+
function aggregateTools(records: readonly ToolCallRecord[], sources: ReadonlyMap<string, string>): ToolStats[] {
|
|
452
|
+
const byTool = new Map<string, { calls: number; errors: number }>();
|
|
453
|
+
for (const record of records) {
|
|
454
|
+
const aggregate = byTool.get(record.toolName) ?? { calls: 0, errors: 0 };
|
|
455
|
+
aggregate.calls++;
|
|
456
|
+
if (record.isError) aggregate.errors++;
|
|
457
|
+
byTool.set(record.toolName, aggregate);
|
|
458
|
+
}
|
|
459
|
+
const grandTotal = [...byTool.values()].reduce((sum, entry) => sum + entry.calls, 0);
|
|
460
|
+
return [...byTool.entries()]
|
|
461
|
+
.map(([toolName, entry]): ToolStats => {
|
|
462
|
+
const source = sources.get(toolName);
|
|
463
|
+
return {
|
|
464
|
+
toolName,
|
|
465
|
+
...(source ? { source } : {}),
|
|
466
|
+
calls: entry.calls,
|
|
467
|
+
errors: entry.errors,
|
|
468
|
+
share: grandTotal > 0 ? entry.calls / grandTotal : 0,
|
|
469
|
+
};
|
|
470
|
+
})
|
|
471
|
+
.sort((a, b) => b.calls - a.calls || a.toolName.localeCompare(b.toolName));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Sessions carry an absolute cwd; the basename is the useful label, with a parent hint when ambiguous. */
|
|
475
|
+
function projectLabel(cwd: string): string {
|
|
476
|
+
const normalized = cwd.replace(/[/\\]+$/, "");
|
|
477
|
+
const parts = normalized.split(/[/\\]/).filter((part) => part.length > 0);
|
|
478
|
+
return parts[parts.length - 1] ?? normalized;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Roll sessions up by working directory.
|
|
483
|
+
*
|
|
484
|
+
* `counted` holds the fingerprints that survived de-duplication, so forked history and usage
|
|
485
|
+
* already attributed to a persisted subagent child are not counted a second time here. Without
|
|
486
|
+
* it the project totals would exceed the all-time total they are supposed to partition.
|
|
487
|
+
*/
|
|
488
|
+
function aggregateProjects(
|
|
489
|
+
sessions: readonly SessionRecord[],
|
|
490
|
+
counted: ReadonlySet<string>,
|
|
491
|
+
cutoff: number,
|
|
492
|
+
now: number,
|
|
493
|
+
): ProjectStats[] {
|
|
494
|
+
const byProject = new Map<string, { sessions: number; total: number; cost: number }>();
|
|
495
|
+
const seen = new Set<string>();
|
|
496
|
+
for (const session of sessions) {
|
|
497
|
+
if (!session.cwd) continue;
|
|
498
|
+
const records = session.usage.filter((record) => {
|
|
499
|
+
if (record.timestamp < cutoff || record.timestamp > now) return false;
|
|
500
|
+
if (!counted.has(record.fingerprint) || seen.has(record.fingerprint)) return false;
|
|
501
|
+
seen.add(record.fingerprint);
|
|
502
|
+
return true;
|
|
503
|
+
});
|
|
504
|
+
// A session counts toward its project whenever it was active in range, even with no billed usage.
|
|
505
|
+
const active = records.length > 0 || (session.lastActivityAt >= cutoff && session.lastActivityAt <= now);
|
|
506
|
+
if (!active) continue;
|
|
507
|
+
const aggregate = byProject.get(session.cwd) ?? { sessions: 0, total: 0, cost: 0 };
|
|
508
|
+
aggregate.sessions++;
|
|
509
|
+
for (const record of records) {
|
|
510
|
+
aggregate.total += totalTokens(record.usage);
|
|
511
|
+
aggregate.cost += record.usage.cost;
|
|
512
|
+
}
|
|
513
|
+
byProject.set(session.cwd, aggregate);
|
|
514
|
+
}
|
|
515
|
+
const grandTotal = [...byProject.values()].reduce((sum, entry) => sum + entry.total, 0);
|
|
516
|
+
return [...byProject.entries()]
|
|
517
|
+
.map(([cwd, entry]): ProjectStats => ({
|
|
518
|
+
cwd,
|
|
519
|
+
label: projectLabel(cwd),
|
|
520
|
+
sessions: entry.sessions,
|
|
521
|
+
total: entry.total,
|
|
522
|
+
cost: entry.cost,
|
|
523
|
+
share: grandTotal > 0 ? entry.total / grandTotal : 0,
|
|
524
|
+
}))
|
|
525
|
+
.sort((a, b) => b.total - a.total || b.sessions - a.sessions || a.label.localeCompare(b.label));
|
|
526
|
+
}
|
|
527
|
+
|
|
386
528
|
function streaks(days: readonly string[], now: number): { current: number; longest: number } {
|
|
387
529
|
const unique = [...new Set(days)].sort();
|
|
388
530
|
let longest = 0;
|
|
@@ -428,6 +570,7 @@ export function buildSnapshot(
|
|
|
428
570
|
range: StatsRange,
|
|
429
571
|
now = Date.now(),
|
|
430
572
|
currentSessionFile = "current-session.jsonl",
|
|
573
|
+
toolSources: ReadonlyMap<string, string> = new Map(),
|
|
431
574
|
): StatsSnapshot {
|
|
432
575
|
const cutoff = rangeStart(range, now);
|
|
433
576
|
const periodRecords = range === "all"
|
|
@@ -445,6 +588,11 @@ export function buildSnapshot(
|
|
|
445
588
|
const models = aggregateModels(periodRecords);
|
|
446
589
|
const allModels = aggregateModels(index.usage);
|
|
447
590
|
const sessions = uniqueSessions(index.sessions);
|
|
591
|
+
const periodToolCalls = range === "all"
|
|
592
|
+
? index.toolCalls
|
|
593
|
+
: index.toolCalls.filter((record) => record.timestamp >= cutoff && record.timestamp <= now);
|
|
594
|
+
const tools = aggregateTools(periodToolCalls, toolSources);
|
|
595
|
+
const projects = aggregateProjects(sessions, new Set(index.usage.map((record) => record.fingerprint)), cutoff, now);
|
|
448
596
|
const sessionPaths = new Set(index.sessions.map((session) => resolve(session.path)));
|
|
449
597
|
const currentRecords = usageFromEntries(currentSessionId, currentEntries, now, currentSessionFile)
|
|
450
598
|
.filter((record) => !hasPersistedSubagentChild(record, sessionPaths));
|
|
@@ -457,6 +605,8 @@ export function buildSnapshot(
|
|
|
457
605
|
period: total(periodRecords),
|
|
458
606
|
currentSession: total(currentRecords),
|
|
459
607
|
models,
|
|
608
|
+
tools,
|
|
609
|
+
projects,
|
|
460
610
|
days,
|
|
461
611
|
sessionCount: sessions.length,
|
|
462
612
|
mainSessionCount: sessions.filter((session) => session.source === "main").length,
|
|
@@ -479,5 +629,10 @@ export function makeIndex(
|
|
|
479
629
|
diagnostics: StatsIndex["diagnostics"],
|
|
480
630
|
sidecar: UsageRecord[] = [],
|
|
481
631
|
): StatsIndex {
|
|
482
|
-
return {
|
|
632
|
+
return {
|
|
633
|
+
sessions,
|
|
634
|
+
usage: deduplicateUsage(sessions, sidecar),
|
|
635
|
+
toolCalls: deduplicateToolCalls(sessions),
|
|
636
|
+
diagnostics,
|
|
637
|
+
};
|
|
483
638
|
}
|
package/types.ts
CHANGED
|
@@ -20,6 +20,19 @@ export interface UsageRecord {
|
|
|
20
20
|
childSessionFiles?: string[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* One recorded tool call. Kept separate from UsageRecord because most tool calls carry no
|
|
25
|
+
* model usage at all: mixing zero-usage records into the usage list would distort model
|
|
26
|
+
* attribution and call counts on the Models tab.
|
|
27
|
+
*/
|
|
28
|
+
export interface ToolCallRecord {
|
|
29
|
+
/** Provider tool-call id when present, else a synthetic hash. Copied forks repeat it, so it deduplicates. */
|
|
30
|
+
fingerprint: string;
|
|
31
|
+
timestamp: number;
|
|
32
|
+
toolName: string;
|
|
33
|
+
isError: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export interface SessionRecord {
|
|
24
37
|
path: string;
|
|
25
38
|
sessionId: string;
|
|
@@ -28,6 +41,7 @@ export interface SessionRecord {
|
|
|
28
41
|
lastActivityAt: number;
|
|
29
42
|
source: SessionSource;
|
|
30
43
|
usage: UsageRecord[];
|
|
44
|
+
toolCalls: ToolCallRecord[];
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
export interface CachedFileRecord {
|
|
@@ -41,7 +55,7 @@ export interface CachedFileRecord {
|
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
export interface StatsCacheFile {
|
|
44
|
-
version:
|
|
58
|
+
version: 3;
|
|
45
59
|
files: Record<string, CachedFileRecord>;
|
|
46
60
|
}
|
|
47
61
|
|
|
@@ -57,6 +71,7 @@ export interface ScanDiagnostics {
|
|
|
57
71
|
export interface StatsIndex {
|
|
58
72
|
sessions: SessionRecord[];
|
|
59
73
|
usage: UsageRecord[];
|
|
74
|
+
toolCalls: ToolCallRecord[];
|
|
60
75
|
diagnostics: ScanDiagnostics;
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -73,12 +88,34 @@ export interface DayStats {
|
|
|
73
88
|
total: number;
|
|
74
89
|
}
|
|
75
90
|
|
|
91
|
+
export interface ToolStats {
|
|
92
|
+
toolName: string;
|
|
93
|
+
/** Owning package/extension from the live tool registry, or undefined for tools no longer installed. */
|
|
94
|
+
source?: string;
|
|
95
|
+
calls: number;
|
|
96
|
+
errors: number;
|
|
97
|
+
share: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ProjectStats {
|
|
101
|
+
/** Absolute working directory recorded in the session header. */
|
|
102
|
+
cwd: string;
|
|
103
|
+
/** Short display label, normally the directory basename. */
|
|
104
|
+
label: string;
|
|
105
|
+
sessions: number;
|
|
106
|
+
total: number;
|
|
107
|
+
cost: number;
|
|
108
|
+
share: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
export interface StatsSnapshot {
|
|
77
112
|
range: StatsRange;
|
|
78
113
|
allTime: UsageTotals;
|
|
79
114
|
period: UsageTotals;
|
|
80
115
|
currentSession: UsageTotals;
|
|
81
116
|
models: ModelStats[];
|
|
117
|
+
tools: ToolStats[];
|
|
118
|
+
projects: ProjectStats[];
|
|
82
119
|
days: DayStats[];
|
|
83
120
|
sessionCount: number;
|
|
84
121
|
mainSessionCount: number;
|
package/widget.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
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 {
|
|
3
|
+
import {
|
|
4
|
+
totalTokens,
|
|
5
|
+
type ModelStats,
|
|
6
|
+
type ProjectStats,
|
|
7
|
+
type StatsRange,
|
|
8
|
+
type StatsSnapshot,
|
|
9
|
+
type ToolStats,
|
|
10
|
+
type UsageTotals,
|
|
11
|
+
} from "./types.ts";
|
|
4
12
|
|
|
5
13
|
export type WidgetState =
|
|
6
14
|
| { kind: "loading"; completed: number; total: number }
|
|
@@ -29,6 +37,10 @@ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "
|
|
|
29
37
|
const RANGES: StatsRange[] = ["all", "7d", "30d"];
|
|
30
38
|
const RANGE_LABELS: Record<StatsRange, string> = { all: "All time", "7d": "Last 7 days", "30d": "Last 30 days" };
|
|
31
39
|
|
|
40
|
+
export type StatsTab = "overview" | "models" | "tools" | "projects";
|
|
41
|
+
const TABS: StatsTab[] = ["overview", "models", "tools", "projects"];
|
|
42
|
+
const TAB_LABELS: Record<StatsTab, string> = { overview: "Overview", models: "Models", tools: "Tools", projects: "Projects" };
|
|
43
|
+
|
|
32
44
|
export function formatTokens(value: number, exact = false): string {
|
|
33
45
|
const safe = Math.max(0, Math.round(value));
|
|
34
46
|
if (exact) return safe.toLocaleString("en-US");
|
|
@@ -147,7 +159,7 @@ function layoutTable(columns: readonly TableColumn[], rowCount: number, width: n
|
|
|
147
159
|
|
|
148
160
|
export class StatsWidget implements Component {
|
|
149
161
|
private state: WidgetState = { kind: "loading", completed: 0, total: 0 };
|
|
150
|
-
private tab:
|
|
162
|
+
private tab: StatsTab = "overview";
|
|
151
163
|
private range: StatsRange = "all";
|
|
152
164
|
private exact = false;
|
|
153
165
|
private modelOffset = 0;
|
|
@@ -175,10 +187,18 @@ export class StatsWidget implements Component {
|
|
|
175
187
|
|
|
176
188
|
setReady(snapshot: StatsSnapshot): void {
|
|
177
189
|
this.state = { kind: "ready", snapshot };
|
|
178
|
-
this.modelOffset = Math.max(0, Math.min(this.modelOffset, snapshot
|
|
190
|
+
this.modelOffset = Math.max(0, Math.min(this.modelOffset, this.listLength(snapshot) - this.pageSize));
|
|
179
191
|
this.options.requestRender();
|
|
180
192
|
}
|
|
181
193
|
|
|
194
|
+
/** Row count of the scrollable list on the active tab; the Overview tab does not scroll. */
|
|
195
|
+
private listLength(snapshot: StatsSnapshot): number {
|
|
196
|
+
if (this.tab === "models") return snapshot.models.length;
|
|
197
|
+
if (this.tab === "tools") return snapshot.tools.length;
|
|
198
|
+
if (this.tab === "projects") return snapshot.projects.length;
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
182
202
|
setError(message: string): void {
|
|
183
203
|
this.state = { kind: "error", message };
|
|
184
204
|
this.options.requestRender();
|
|
@@ -190,13 +210,11 @@ export class StatsWidget implements Component {
|
|
|
190
210
|
return;
|
|
191
211
|
}
|
|
192
212
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
193
|
-
this.
|
|
194
|
-
this.options.requestRender();
|
|
213
|
+
this.switchTab(1);
|
|
195
214
|
return;
|
|
196
215
|
}
|
|
197
216
|
if (matchesKey(data, Key.left)) {
|
|
198
|
-
this.
|
|
199
|
-
this.options.requestRender();
|
|
217
|
+
this.switchTab(-1);
|
|
200
218
|
return;
|
|
201
219
|
}
|
|
202
220
|
if (data === "r" || data === "R") {
|
|
@@ -215,9 +233,9 @@ export class StatsWidget implements Component {
|
|
|
215
233
|
this.options.onRefresh();
|
|
216
234
|
return;
|
|
217
235
|
}
|
|
218
|
-
if (this.state.kind === "ready" && this.tab
|
|
236
|
+
if (this.state.kind === "ready" && this.tab !== "overview") {
|
|
219
237
|
if (matchesKey(data, Key.down)) {
|
|
220
|
-
this.modelOffset = Math.min(Math.max(0, this.state.snapshot
|
|
238
|
+
this.modelOffset = Math.min(Math.max(0, this.listLength(this.state.snapshot) - this.pageSize), this.modelOffset + 1);
|
|
221
239
|
this.options.requestRender();
|
|
222
240
|
} else if (matchesKey(data, Key.up)) {
|
|
223
241
|
this.modelOffset = Math.max(0, this.modelOffset - 1);
|
|
@@ -226,6 +244,14 @@ export class StatsWidget implements Component {
|
|
|
226
244
|
}
|
|
227
245
|
}
|
|
228
246
|
|
|
247
|
+
/** Tabs wrap in both directions; the scroll offset is per-tab and resets on every switch. */
|
|
248
|
+
private switchTab(step: number): void {
|
|
249
|
+
const index = TABS.indexOf(this.tab);
|
|
250
|
+
this.tab = TABS[(index + step + TABS.length) % TABS.length]!;
|
|
251
|
+
this.modelOffset = 0;
|
|
252
|
+
this.options.requestRender();
|
|
253
|
+
}
|
|
254
|
+
|
|
229
255
|
dispose(): void {
|
|
230
256
|
this.options.onDispose?.();
|
|
231
257
|
}
|
|
@@ -252,10 +278,10 @@ export class StatsWidget implements Component {
|
|
|
252
278
|
const title = " PI STATS ";
|
|
253
279
|
lines.push(theme.fg("borderAccent", "─".repeat(Math.max(1, width))));
|
|
254
280
|
const rangeChip = RANGE_LABELS[this.range];
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
);
|
|
281
|
+
const chips = TABS.map((tab) => (tab === this.tab ? selected(TAB_LABELS[tab]) : normal(TAB_LABELS[tab]))).join(" ");
|
|
282
|
+
const chipsWidth = TABS.reduce((sum, tab) => sum + TAB_LABELS[tab].length + 2, 0) + (TABS.length - 1);
|
|
283
|
+
const spacer = " ".repeat(Math.max(1, width - visibleWidth(title) - 2 - chipsWidth - visibleWidth(rangeChip) - 1));
|
|
284
|
+
lines.push(`${theme.fg("accent", theme.bold(title))} ${chips}${spacer}${theme.fg("dim", rangeChip)}`);
|
|
259
285
|
|
|
260
286
|
if (this.state.kind === "loading") {
|
|
261
287
|
const progress = this.state.total > 0 ? ` ${this.state.completed}/${this.state.total}` : "";
|
|
@@ -275,13 +301,14 @@ export class StatsWidget implements Component {
|
|
|
275
301
|
lines.push(
|
|
276
302
|
` ${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`)}`,
|
|
277
303
|
);
|
|
278
|
-
lines.push(...
|
|
304
|
+
lines.push(...this.renderTab(snapshot, width));
|
|
279
305
|
const scanWarnings: string[] = [];
|
|
280
306
|
if (snapshot.diagnostics.unreadableFiles > 0) scanWarnings.push(`${snapshot.diagnostics.unreadableFiles} unreadable file(s)`);
|
|
281
307
|
if (snapshot.diagnostics.malformedLines > 0) scanWarnings.push(`${snapshot.diagnostics.malformedLines} malformed line(s)`);
|
|
282
308
|
const warning = scanWarnings.length > 0 ? theme.fg("warning", ` · ⚠ ${scanWarnings.join(" · ")}`) : "";
|
|
283
309
|
const exactHint = this.exact ? "e compact" : "e exact";
|
|
284
|
-
|
|
310
|
+
const scrollHint = this.tab === "overview" ? "" : " · ↑↓ scroll";
|
|
311
|
+
lines.push(theme.fg("dim", ` Tab/←→ switch${scrollHint} · r range · ${exactHint} · u refresh · Esc close`) + warning);
|
|
285
312
|
lines.push(theme.fg("borderMuted", "─".repeat(Math.max(1, width))));
|
|
286
313
|
return lines;
|
|
287
314
|
}
|
|
@@ -427,6 +454,113 @@ export class StatsWidget implements Component {
|
|
|
427
454
|
return lines;
|
|
428
455
|
}
|
|
429
456
|
|
|
457
|
+
private renderTab(snapshot: StatsSnapshot, width: number): string[] {
|
|
458
|
+
if (this.tab === "models") return this.renderModels(snapshot, width);
|
|
459
|
+
if (this.tab === "tools") return this.renderTools(snapshot, width);
|
|
460
|
+
if (this.tab === "projects") return this.renderProjects(snapshot, width);
|
|
461
|
+
return this.renderOverview(snapshot, width);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Shared body for the ranked tabs: one layout across data rows plus a trailing summary row,
|
|
466
|
+
* so column widths stay stable while scrolling and the summary aligns exactly.
|
|
467
|
+
*/
|
|
468
|
+
private renderRanked(columns: TableColumn[], dataRows: number, width: number, noun: string): string[] {
|
|
469
|
+
const { theme } = this.options;
|
|
470
|
+
const entries = dataRows + 1;
|
|
471
|
+
const summaryIndex = entries - 1;
|
|
472
|
+
const table = layoutTable(columns, entries, width);
|
|
473
|
+
|
|
474
|
+
const rowsPerEntry = 1 + Math.max(0, ...table.extras.slice(0, summaryIndex).map((extra) => extra.length));
|
|
475
|
+
const summaryRows = 2 + (table.extras[summaryIndex]?.length ?? 0);
|
|
476
|
+
const budget = MAX_ROWS - CHROME_ROWS - 1 - summaryRows - 1;
|
|
477
|
+
this.pageSize = Math.max(MIN_MODEL_PAGE, Math.min(MAX_MODEL_PAGE, Math.floor(budget / rowsPerEntry)));
|
|
478
|
+
const start = Math.min(this.modelOffset, Math.max(0, dataRows - this.pageSize));
|
|
479
|
+
this.modelOffset = start;
|
|
480
|
+
const end = Math.min(dataRows, start + this.pageSize);
|
|
481
|
+
|
|
482
|
+
const lines = [theme.fg("dim", table.header)];
|
|
483
|
+
for (let index = start; index < end; index++) {
|
|
484
|
+
lines.push(theme.fg("text", table.rows[index] ?? ""));
|
|
485
|
+
for (const extra of table.extras[index] ?? []) lines.push(theme.fg("dim", extra));
|
|
486
|
+
}
|
|
487
|
+
lines.push(theme.fg("borderMuted", ` ${"─".repeat(Math.max(1, Math.min(width - 1, table.width)))}`));
|
|
488
|
+
lines.push(theme.fg("muted", table.rows[summaryIndex] ?? ""));
|
|
489
|
+
for (const extra of table.extras[summaryIndex] ?? []) lines.push(theme.fg("dim", extra));
|
|
490
|
+
if (dataRows > this.pageSize) {
|
|
491
|
+
lines.push(theme.fg("dim", ` ↑↓ ${noun} ${start + 1}-${end} of ${dataRows}`));
|
|
492
|
+
}
|
|
493
|
+
return lines;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Proportional bar shared by the ranked tabs, scaled to the largest share in the list. */
|
|
497
|
+
private shareBar(share: number, maximum: number): string {
|
|
498
|
+
if (maximum <= 0) return "";
|
|
499
|
+
const filled = Math.max(0, Math.min(6, (share / maximum) * 6));
|
|
500
|
+
const whole = Math.floor(filled);
|
|
501
|
+
const eighths = Math.round((filled - whole) * 8);
|
|
502
|
+
const partial = eighths > 0 ? "▏▎▍▌▋▊▉█"[eighths - 1]! : "";
|
|
503
|
+
return padRight("█".repeat(whole) + partial, 6);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private renderTools(snapshot: StatsSnapshot, width: number): string[] {
|
|
507
|
+
const { theme } = this.options;
|
|
508
|
+
if (snapshot.tools.length === 0) return [theme.fg("muted", ` No tool calls in ${RANGE_LABELS[snapshot.range].toLowerCase()}.`)];
|
|
509
|
+
|
|
510
|
+
const calls = snapshot.tools.reduce((sum, tool) => sum + tool.calls, 0);
|
|
511
|
+
const errors = snapshot.tools.reduce((sum, tool) => sum + tool.errors, 0);
|
|
512
|
+
const summaryRow: ToolStats = {
|
|
513
|
+
toolName: `${snapshot.tools.length} tool${snapshot.tools.length === 1 ? "" : "s"}`,
|
|
514
|
+
calls,
|
|
515
|
+
errors,
|
|
516
|
+
share: 1,
|
|
517
|
+
};
|
|
518
|
+
const rows = [...snapshot.tools, summaryRow];
|
|
519
|
+
const maximum = Math.max(0, ...snapshot.tools.map((tool) => tool.share));
|
|
520
|
+
const isSummary = (index: number) => index >= snapshot.tools.length;
|
|
521
|
+
const errorRate = (tool: ToolStats) => (tool.calls > 0 ? `${((tool.errors / tool.calls) * 100).toFixed(1)}%` : "—");
|
|
522
|
+
const columns: TableColumn[] = [
|
|
523
|
+
{ header: "Tool", cells: rows.map((tool) => tool.toolName), align: "left", drop: 0 },
|
|
524
|
+
{ header: "Source", cells: rows.map((tool, index) => (isSummary(index) ? "" : (tool.source ?? "—"))), align: "left", drop: 2, label: "from" },
|
|
525
|
+
{ header: "Calls", cells: rows.map((tool) => tool.calls.toLocaleString("en-US")), align: "right", drop: 0 },
|
|
526
|
+
{ header: "Share", cells: rows.map((tool) => `${(tool.share * 100).toFixed(1)}%`), align: "right", drop: 3, label: "share" },
|
|
527
|
+
{ header: "", cells: rows.map((tool, index) => (isSummary(index) ? "" : this.shareBar(tool.share, maximum))), align: "left", drop: 4, decorative: true },
|
|
528
|
+
{ header: "Errors", cells: rows.map((tool) => tool.errors.toLocaleString("en-US")), align: "right", drop: 1, label: "errors" },
|
|
529
|
+
{ header: "Err %", cells: rows.map((tool) => errorRate(tool)), align: "right", drop: 1, label: "err rate" },
|
|
530
|
+
];
|
|
531
|
+
return this.renderRanked(columns, snapshot.tools.length, width, "tools");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private renderProjects(snapshot: StatsSnapshot, width: number): string[] {
|
|
535
|
+
const { theme } = this.options;
|
|
536
|
+
if (snapshot.projects.length === 0) return [theme.fg("muted", ` No project activity in ${RANGE_LABELS[snapshot.range].toLowerCase()}.`)];
|
|
537
|
+
|
|
538
|
+
const sessions = snapshot.projects.reduce((sum, project) => sum + project.sessions, 0);
|
|
539
|
+
const total = snapshot.projects.reduce((sum, project) => sum + project.total, 0);
|
|
540
|
+
const cost = snapshot.projects.reduce((sum, project) => sum + project.cost, 0);
|
|
541
|
+
const summaryRow: ProjectStats = {
|
|
542
|
+
cwd: "",
|
|
543
|
+
label: `${snapshot.projects.length} project${snapshot.projects.length === 1 ? "" : "s"}`,
|
|
544
|
+
sessions,
|
|
545
|
+
total,
|
|
546
|
+
cost,
|
|
547
|
+
share: 1,
|
|
548
|
+
};
|
|
549
|
+
const rows = [...snapshot.projects, summaryRow];
|
|
550
|
+
const maximum = Math.max(0, ...snapshot.projects.map((project) => project.share));
|
|
551
|
+
const isSummary = (index: number) => index >= snapshot.projects.length;
|
|
552
|
+
const columns: TableColumn[] = [
|
|
553
|
+
{ header: "Project", cells: rows.map((project) => project.label), align: "left", drop: 0 },
|
|
554
|
+
{ header: "Sessions", cells: rows.map((project) => project.sessions.toLocaleString("en-US")), align: "right", drop: 1, label: "sessions" },
|
|
555
|
+
{ header: "Share", cells: rows.map((project) => `${(project.share * 100).toFixed(1)}%`), align: "right", drop: 3, label: "share" },
|
|
556
|
+
{ header: "", cells: rows.map((project, index) => (isSummary(index) ? "" : this.shareBar(project.share, maximum))), align: "left", drop: 4, decorative: true },
|
|
557
|
+
{ header: "Total", cells: rows.map((project) => this.tokens(project.total)), align: "right", drop: 0 },
|
|
558
|
+
{ header: "Cost", cells: rows.map((project) => formatCost(project.cost)), align: "right", drop: 1, label: "cost" },
|
|
559
|
+
{ header: "Path", cells: rows.map((project, index) => (isSummary(index) ? "" : project.cwd)), align: "left", drop: 5, label: "path" },
|
|
560
|
+
];
|
|
561
|
+
return this.renderRanked(columns, snapshot.projects.length, width, "projects");
|
|
562
|
+
}
|
|
563
|
+
|
|
430
564
|
private renderModels(snapshot: StatsSnapshot, width: number): string[] {
|
|
431
565
|
const { theme } = this.options;
|
|
432
566
|
if (snapshot.models.length === 0) return [theme.fg("muted", ` No model usage in ${RANGE_LABELS[snapshot.range].toLowerCase()}.`)];
|
|
@@ -445,44 +579,14 @@ export class StatsWidget implements Component {
|
|
|
445
579
|
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0, calls: 0, total: 0 },
|
|
446
580
|
);
|
|
447
581
|
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
582
|
const entries = [...snapshot.models, summaryRow];
|
|
450
|
-
|
|
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}`));
|
|
471
|
-
}
|
|
472
|
-
return lines;
|
|
583
|
+
return this.renderRanked(this.modelColumns(entries, snapshot.models), snapshot.models.length, width, "models");
|
|
473
584
|
}
|
|
474
585
|
|
|
475
586
|
private modelColumns(rows: readonly ModelStats[], ranked: readonly ModelStats[]): TableColumn[] {
|
|
476
587
|
const maximum = Math.max(0, ...ranked.map((model) => model.share));
|
|
477
588
|
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
|
-
};
|
|
589
|
+
const bar = (share: number): string => this.shareBar(share, maximum);
|
|
486
590
|
return [
|
|
487
591
|
{ header: "Model", cells: rows.map((model) => model.model), align: "left", drop: 0 },
|
|
488
592
|
{ header: "Calls", cells: rows.map((model) => model.calls.toLocaleString("en-US")), align: "right", drop: 1, label: "calls" },
|