@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 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 `←`/`→`: switch Overview and Models
24
- - `↑`/`↓`: scroll the model list
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
- Both tabs are responsive: as the terminal narrows, lower-priority columns move to a labelled continuation line under each row instead of disappearing, and the layout stays within a standard 24-row terminal.
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
- const CACHE_VERSION = 2 as const;
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
- for (let index = 0; index < files.length; index++) {
263
- options.signal?.throwIfAborted();
264
- const file = files[index]!;
265
- try {
266
- const metadata = await stat(file);
267
- const previous = loaded.cache.files[file];
268
- if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
269
- next.files[file] = previous!;
270
- diagnostics.reusedFiles++;
271
- } else if (sidecars.has(file)) {
272
- const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
273
- next.files[file] = {
274
- size: metadata.size,
275
- mtimeMs: metadata.mtimeMs,
276
- sidecar: parsed.records,
277
- malformedLines: parsed.malformedLines,
278
- };
279
- diagnostics.parsedFiles++;
280
- changed = true;
281
- } else {
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
- next.files[file] = {
284
- size: metadata.size,
285
- mtimeMs: metadata.mtimeMs,
286
- ...(parsed.ignored ? { ignored: true as const } : {}),
287
- ...(parsed.session ? { session: parsed.session } : {}),
288
- malformedLines: parsed.malformedLines,
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
- diagnostics.parsedFiles++;
291
- changed = true;
361
+ } catch {
362
+ options.signal?.throwIfAborted();
363
+ return { file, reused: false, unreadable: true, changed: true };
292
364
  }
293
- const current = next.files[file]!;
294
- if (current.ignored) diagnostics.ignoredFiles++;
295
- diagnostics.malformedLines += current.malformedLines;
296
- } catch {
297
- diagnostics.unreadableFiles++;
298
- changed = true;
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
- options.onProgress?.(index + 1, files.length);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-stats",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
5
5
  "type": "module",
6
6
  "keywords": [
package/stats.ts CHANGED
@@ -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 { sessions, usage: deduplicateUsage(sessions, sidecar), diagnostics };
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: 2;
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 { totalTokens, type ModelStats, type StatsRange, type StatsSnapshot, type UsageTotals } from "./types.ts";
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: "overview" | "models" = "overview";
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.models.length - this.pageSize));
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.tab = this.tab === "overview" ? "models" : "overview";
194
- this.options.requestRender();
213
+ this.switchTab(1);
195
214
  return;
196
215
  }
197
216
  if (matchesKey(data, Key.left)) {
198
- this.tab = this.tab === "models" ? "overview" : "models";
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 === "models") {
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.models.length - this.pageSize), this.modelOffset + 1);
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 spacer = " ".repeat(Math.max(1, width - visibleWidth(`${title} Overview Models `) - visibleWidth(rangeChip) - 1));
256
- lines.push(
257
- `${theme.fg("accent", theme.bold(title))} ${this.tab === "overview" ? selected("Overview") : normal("Overview")} ${this.tab === "models" ? selected("Models") : normal("Models")}${spacer}${theme.fg("dim", rangeChip)}`,
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(...(this.tab === "overview" ? this.renderOverview(snapshot, width) : this.renderModels(snapshot, width)));
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
- lines.push(theme.fg("dim", ` Tab/←→ switch · r range · ${exactHint} · u refresh · Esc close`) + warning);
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
- const summaryIndex = entries.length - 1;
451
- const table = layoutTable(this.modelColumns(entries, snapshot.models), entries.length, width);
452
-
453
- const rowsPerModel = 1 + Math.max(0, ...table.extras.slice(0, summaryIndex).map((extra) => extra.length));
454
- const summaryRows = 2 + (table.extras[summaryIndex]?.length ?? 0);
455
- const budget = MAX_ROWS - CHROME_ROWS - 1 - summaryRows - 1;
456
- this.pageSize = Math.max(MIN_MODEL_PAGE, Math.min(MAX_MODEL_PAGE, Math.floor(budget / rowsPerModel)));
457
- const start = Math.min(this.modelOffset, Math.max(0, snapshot.models.length - this.pageSize));
458
- this.modelOffset = start;
459
- const end = Math.min(snapshot.models.length, start + this.pageSize);
460
-
461
- const lines = [theme.fg("dim", table.header)];
462
- for (let index = start; index < end; index++) {
463
- lines.push(theme.fg("text", table.rows[index] ?? ""));
464
- for (const extra of table.extras[index] ?? []) lines.push(theme.fg("dim", extra));
465
- }
466
- lines.push(theme.fg("borderMuted", ` ${"─".repeat(Math.max(1, Math.min(width - 1, table.width)))}`));
467
- lines.push(theme.fg("muted", table.rows[summaryIndex] ?? ""));
468
- for (const extra of table.extras[summaryIndex] ?? []) lines.push(theme.fg("dim", extra));
469
- if (snapshot.models.length > this.pageSize) {
470
- lines.push(theme.fg("dim", ` ↑↓ models ${start + 1}-${end} of ${snapshot.models.length}`));
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" },