tau-coding-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -0
  3. package/extensions/answer.ts +601 -0
  4. package/extensions/branch-term.ts +405 -0
  5. package/extensions/btw.ts +444 -0
  6. package/extensions/ghostty.ts +301 -0
  7. package/extensions/git-diff-stats.ts +277 -0
  8. package/extensions/git-pr-status.ts +286 -0
  9. package/extensions/insights.ts +2367 -0
  10. package/extensions/interlude.ts +144 -0
  11. package/extensions/loop.ts +529 -0
  12. package/extensions/memory.ts +1889 -0
  13. package/extensions/notify.ts +161 -0
  14. package/extensions/openai-fast.ts +227 -0
  15. package/extensions/openai-verbosity.ts +223 -0
  16. package/extensions/review.ts +4347 -0
  17. package/extensions/sandbox/index.ts +2578 -0
  18. package/extensions/usage/anthropic.ts +198 -0
  19. package/extensions/usage/github-copilot.ts +204 -0
  20. package/extensions/usage/google-gemini-cli.ts +232 -0
  21. package/extensions/usage/index.ts +2310 -0
  22. package/extensions/usage/minimax.ts +208 -0
  23. package/extensions/usage/openai-codex.ts +180 -0
  24. package/extensions/usage/openrouter.ts +168 -0
  25. package/extensions/usage/providers.ts +26 -0
  26. package/extensions/usage/shared.ts +156 -0
  27. package/extensions/usage/types.ts +53 -0
  28. package/extensions/usage/zai.ts +186 -0
  29. package/extensions/websearch/README.md +68 -0
  30. package/extensions/websearch/browser/chromium.ts +330 -0
  31. package/extensions/websearch/browser/discovery.ts +75 -0
  32. package/extensions/websearch/browser/firefox.ts +150 -0
  33. package/extensions/websearch/browser/sqlite.ts +38 -0
  34. package/extensions/websearch/config.ts +76 -0
  35. package/extensions/websearch/index.ts +312 -0
  36. package/extensions/websearch/normalize.ts +155 -0
  37. package/extensions/websearch/providers/anthropic.pi.ts +139 -0
  38. package/extensions/websearch/providers/gemini.browser.ts +185 -0
  39. package/extensions/websearch/providers/gemini.pi.ts +108 -0
  40. package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
  41. package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
  42. package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
  43. package/extensions/websearch/providers/pi-model.shared.ts +86 -0
  44. package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
  45. package/extensions/websearch/providers/shared.ts +76 -0
  46. package/extensions/websearch/types.ts +52 -0
  47. package/extensions/worktree.ts +2223 -0
  48. package/package.json +72 -0
  49. package/skills/browser-tools/SKILL.md +253 -0
  50. package/skills/browser-tools/scripts/browser-content.js +100 -0
  51. package/skills/browser-tools/scripts/browser-cookies.js +33 -0
  52. package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
  53. package/skills/browser-tools/scripts/browser-eval.js +75 -0
  54. package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
  55. package/skills/browser-tools/scripts/browser-nav.js +28 -0
  56. package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
  57. package/skills/browser-tools/scripts/browser-pick.js +133 -0
  58. package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
  59. package/skills/browser-tools/scripts/browser-start.js +281 -0
  60. package/skills/browser-tools/scripts/browser-watch.js +321 -0
  61. package/skills/browser-tools/scripts/utils.js +60 -0
  62. package/skills/git-clean-history/SKILL.md +45 -0
  63. package/skills/git-commit/SKILL.md +55 -0
  64. package/skills/oracle/SKILL.md +64 -0
  65. package/skills/oracle/scripts/oracle +249 -0
  66. package/skills/oracle/scripts/oracle-bundle +227 -0
  67. package/skills/sentry/SKILL.md +198 -0
  68. package/skills/sentry/lib/auth.js +99 -0
  69. package/skills/sentry/scripts/fetch-event.js +325 -0
  70. package/skills/sentry/scripts/fetch-issue.js +368 -0
  71. package/skills/sentry/scripts/list-issues.js +245 -0
  72. package/skills/sentry/scripts/search-events.js +291 -0
  73. package/skills/sentry/scripts/search-logs.js +234 -0
  74. package/skills/update-changelog/SKILL.md +135 -0
  75. package/skills/web-design/SKILL.md +117 -0
@@ -0,0 +1,2310 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { BorderedLoader } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ Key,
5
+ matchesKey,
6
+ type Component,
7
+ type TUI,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ } from "@mariozechner/pi-tui";
11
+ import { createReadStream, type Dirent } from "node:fs";
12
+ import fs from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import readline from "node:readline";
16
+ import { getSupportedProvider, SUPPORTED_PROVIDERS } from "./providers.js";
17
+ import {
18
+ formatCount,
19
+ formatUsedPercent,
20
+ isLiveUsageUnavailableError,
21
+ readNumber,
22
+ } from "./shared.js";
23
+ import type {
24
+ LiveUsageAvailability,
25
+ LiveUsageItem,
26
+ LiveUsageMeter,
27
+ LiveUsageSnapshot,
28
+ RGB,
29
+ SupportedProviderId,
30
+ UsageProviderDefinition,
31
+ } from "./types.js";
32
+
33
+ const ALL_TAB_ID = "all";
34
+ const SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
35
+ const RANGE_DAYS = [7, 30, 90] as const;
36
+ const FILE_PARSE_CONCURRENCY = 16;
37
+
38
+ const DOW_NAMES: DowKey[] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
39
+ const TOD_BUCKETS: { key: TodKey; label: string; from: number; to: number }[] = [
40
+ { key: "after-midnight", label: "After midnight (0–5)", from: 0, to: 5 },
41
+ { key: "morning", label: "Morning (6–11)", from: 6, to: 11 },
42
+ { key: "afternoon", label: "Afternoon (12–16)", from: 12, to: 16 },
43
+ { key: "evening", label: "Evening (17–21)", from: 17, to: 21 },
44
+ { key: "night", label: "Night (22–23)", from: 22, to: 23 },
45
+ ];
46
+
47
+ const DEFAULT_BG: RGB = { r: 13, g: 17, b: 23 };
48
+ const EMPTY_CELL_BG: RGB = { r: 22, g: 27, b: 34 };
49
+ const ERROR_COLOR: RGB = { r: 244, g: 67, b: 54 };
50
+ const MUTED_COLOR: RGB = { r: 160, g: 160, b: 160 };
51
+
52
+ const PALETTE: RGB[] = [
53
+ { r: 54, g: 179, b: 166 },
54
+ { r: 82, g: 146, b: 247 },
55
+ { r: 171, g: 123, b: 245 },
56
+ { r: 232, g: 180, b: 72 },
57
+ { r: 244, g: 67, b: 54 },
58
+ ];
59
+
60
+ const DOW_PALETTE: RGB[] = [
61
+ { r: 47, g: 129, b: 247 },
62
+ { r: 64, g: 196, b: 99 },
63
+ { r: 163, g: 113, b: 247 },
64
+ { r: 47, g: 175, b: 200 },
65
+ { r: 100, g: 200, b: 150 },
66
+ { r: 255, g: 159, b: 10 },
67
+ { r: 244, g: 67, b: 54 },
68
+ ];
69
+
70
+ const TOD_PALETTE: Map<TodKey, RGB> = new Map([
71
+ ["after-midnight", { r: 100, g: 60, b: 180 }],
72
+ ["morning", { r: 255, g: 200, b: 50 }],
73
+ ["afternoon", { r: 64, g: 196, b: 99 }],
74
+ ["evening", { r: 47, g: 129, b: 247 }],
75
+ ["night", { r: 60, g: 40, b: 140 }],
76
+ ]);
77
+
78
+ type HistoryProviderId = string;
79
+ type ModelKey = string;
80
+ type CwdKey = string;
81
+ type DowKey = string;
82
+ type TodKey = string;
83
+ type MeasurementMode = "sessions" | "messages" | "tokens";
84
+ type BreakdownView = "model" | "cwd" | "dow" | "tod";
85
+
86
+ type LiveUsageState =
87
+ | { status: "loading" }
88
+ | { status: "ok"; snapshot: LiveUsageSnapshot }
89
+ | { status: "error"; error: string }
90
+ | { status: "unavailable"; reason: string };
91
+
92
+ interface ParsedSessionBase {
93
+ startedAt: Date;
94
+ dayKeyLocal: string;
95
+ cwd: CwdKey | null;
96
+ dow: DowKey;
97
+ tod: TodKey;
98
+ modelsUsed: Set<ModelKey>;
99
+ messages: number;
100
+ tokens: number;
101
+ totalCost: number;
102
+ costByModel: Map<ModelKey, number>;
103
+ messagesByModel: Map<ModelKey, number>;
104
+ tokensByModel: Map<ModelKey, number>;
105
+ }
106
+
107
+ interface ParsedAllSession extends ParsedSessionBase {}
108
+
109
+ interface ParsedProviderSession extends ParsedSessionBase {
110
+ provider: HistoryProviderId;
111
+ }
112
+
113
+ interface ParsedHistoricalFile {
114
+ all: ParsedAllSession | null;
115
+ providers: ParsedProviderSession[];
116
+ }
117
+
118
+ interface DayAgg {
119
+ date: Date;
120
+ dayKeyLocal: string;
121
+ sessions: number;
122
+ messages: number;
123
+ tokens: number;
124
+ totalCost: number;
125
+ costByModel: Map<ModelKey, number>;
126
+ sessionsByModel: Map<ModelKey, number>;
127
+ messagesByModel: Map<ModelKey, number>;
128
+ tokensByModel: Map<ModelKey, number>;
129
+ sessionsByCwd: Map<CwdKey, number>;
130
+ messagesByCwd: Map<CwdKey, number>;
131
+ tokensByCwd: Map<CwdKey, number>;
132
+ costByCwd: Map<CwdKey, number>;
133
+ sessionsByTod: Map<TodKey, number>;
134
+ messagesByTod: Map<TodKey, number>;
135
+ tokensByTod: Map<TodKey, number>;
136
+ costByTod: Map<TodKey, number>;
137
+ }
138
+
139
+ interface RangeAgg {
140
+ days: DayAgg[];
141
+ dayByKey: Map<string, DayAgg>;
142
+ sessions: number;
143
+ totalMessages: number;
144
+ totalTokens: number;
145
+ totalCost: number;
146
+ modelCost: Map<ModelKey, number>;
147
+ modelSessions: Map<ModelKey, number>;
148
+ modelMessages: Map<ModelKey, number>;
149
+ modelTokens: Map<ModelKey, number>;
150
+ cwdCost: Map<CwdKey, number>;
151
+ cwdSessions: Map<CwdKey, number>;
152
+ cwdMessages: Map<CwdKey, number>;
153
+ cwdTokens: Map<CwdKey, number>;
154
+ dowCost: Map<DowKey, number>;
155
+ dowSessions: Map<DowKey, number>;
156
+ dowMessages: Map<DowKey, number>;
157
+ dowTokens: Map<DowKey, number>;
158
+ todCost: Map<TodKey, number>;
159
+ todSessions: Map<TodKey, number>;
160
+ todMessages: Map<TodKey, number>;
161
+ todTokens: Map<TodKey, number>;
162
+ }
163
+
164
+ interface ProviderPalette {
165
+ modelPalette: {
166
+ modelColors: Map<ModelKey, RGB>;
167
+ otherColor: RGB;
168
+ orderedModels: ModelKey[];
169
+ };
170
+ cwdPalette: {
171
+ cwdColors: Map<CwdKey, RGB>;
172
+ otherColor: RGB;
173
+ orderedCwds: CwdKey[];
174
+ };
175
+ }
176
+
177
+ interface BreakdownTab {
178
+ id: string;
179
+ label: string;
180
+ shortLabel: string;
181
+ mode: "all" | "provider";
182
+ liveProvider?: UsageProviderDefinition;
183
+ }
184
+
185
+ interface BreakdownData {
186
+ tabs: BreakdownTab[];
187
+ liveUsage: Map<SupportedProviderId, LiveUsageState>;
188
+ allRanges: Map<number, RangeAgg>;
189
+ providerRangesById: Map<HistoryProviderId, Map<number, RangeAgg>>;
190
+ allPalette: ProviderPalette;
191
+ providerPalettes: Map<HistoryProviderId, ProviderPalette>;
192
+ dowPalette: {
193
+ dowColors: Map<DowKey, RGB>;
194
+ orderedDows: DowKey[];
195
+ };
196
+ todPalette: {
197
+ todColors: Map<TodKey, RGB>;
198
+ orderedTods: TodKey[];
199
+ };
200
+ }
201
+
202
+ type BreakdownProgressPhase = "scan" | "parse" | "finalize";
203
+
204
+ interface BreakdownProgressState {
205
+ phase: BreakdownProgressPhase;
206
+ foundFiles: number;
207
+ parsedFiles: number;
208
+ totalFiles: number;
209
+ currentFile?: string;
210
+ }
211
+
212
+ function setBorderedLoaderMessage(loader: BorderedLoader, message: string) {
213
+ // BorderedLoader currently stores its inner Loader on a private `loader` field, and that
214
+ // Loader exposes `setMessage()`. Replace this cast once pi-coding-agent exposes a public
215
+ // BorderedLoader message update API.
216
+ const inner = (loader as unknown as { loader?: { setMessage?: (message: string) => void } })
217
+ .loader;
218
+ inner?.setMessage?.(message);
219
+ }
220
+
221
+ function throwIfAborted(signal?: AbortSignal): void {
222
+ signal?.throwIfAborted();
223
+ }
224
+
225
+ function clamp01(value: number): number {
226
+ return Math.max(0, Math.min(1, value));
227
+ }
228
+
229
+ function lerp(a: number, b: number, t: number): number {
230
+ return a + (b - a) * t;
231
+ }
232
+
233
+ function mixRgb(a: RGB, b: RGB, t: number): RGB {
234
+ return {
235
+ r: Math.round(lerp(a.r, b.r, t)),
236
+ g: Math.round(lerp(a.g, b.g, t)),
237
+ b: Math.round(lerp(a.b, b.b, t)),
238
+ };
239
+ }
240
+
241
+ function weightedMix(colors: Array<{ color: RGB; weight: number }>): RGB {
242
+ let total = 0;
243
+ let r = 0;
244
+ let g = 0;
245
+ let b = 0;
246
+ for (const entry of colors) {
247
+ if (!Number.isFinite(entry.weight) || entry.weight <= 0) continue;
248
+ total += entry.weight;
249
+ r += entry.color.r * entry.weight;
250
+ g += entry.color.g * entry.weight;
251
+ b += entry.color.b * entry.weight;
252
+ }
253
+ if (total <= 0) return EMPTY_CELL_BG;
254
+ return { r: Math.round(r / total), g: Math.round(g / total), b: Math.round(b / total) };
255
+ }
256
+
257
+ function ansiFg(rgb: RGB, text: string): string {
258
+ return `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m${text}\x1b[0m`;
259
+ }
260
+
261
+ function dim(text: string): string {
262
+ return `\x1b[2m${text}\x1b[0m`;
263
+ }
264
+
265
+ function bold(text: string): string {
266
+ return `\x1b[1m${text}\x1b[0m`;
267
+ }
268
+
269
+ function formatUsd(value: number): string {
270
+ if (!Number.isFinite(value)) return "$0.00";
271
+ if (value >= 1) return `$${value.toFixed(2)}`;
272
+ if (value >= 0.1) return `$${value.toFixed(3)}`;
273
+ return `$${value.toFixed(4)}`;
274
+ }
275
+
276
+ function formatFetchedAt(value: Date): string {
277
+ return value.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
278
+ }
279
+
280
+ function abbreviatePath(input: string, maxWidth = 40): string {
281
+ const home = os.homedir();
282
+ let display = input;
283
+ if (display.startsWith(home)) display = `~${display.slice(home.length)}`;
284
+ if (display.length <= maxWidth) return display;
285
+
286
+ const parts = display.split("/").filter(Boolean);
287
+ if (parts.length <= 2) return display;
288
+
289
+ const prefix = parts[0] ?? "~";
290
+ for (let keep = parts.length - 1; keep >= 1; keep -= 1) {
291
+ const tail = parts.slice(parts.length - keep);
292
+ const candidate = `${prefix}/…/${tail.join("/")}`;
293
+ if (candidate.length <= maxWidth || keep === 1) return candidate;
294
+ }
295
+ return display;
296
+ }
297
+
298
+ function padRight(text: string, width: number): string {
299
+ const delta = width - visibleWidth(text);
300
+ return delta > 0 ? text + " ".repeat(delta) : text;
301
+ }
302
+
303
+ function padLeft(text: string, width: number): string {
304
+ const delta = width - visibleWidth(text);
305
+ return delta > 0 ? " ".repeat(delta) + text : text;
306
+ }
307
+
308
+ function toLocalDayKey(date: Date): string {
309
+ const yyyy = date.getFullYear();
310
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
311
+ const dd = String(date.getDate()).padStart(2, "0");
312
+ return `${yyyy}-${mm}-${dd}`;
313
+ }
314
+
315
+ function localMidnight(date: Date): Date {
316
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
317
+ }
318
+
319
+ function addDaysLocal(date: Date, days: number): Date {
320
+ const copy = new Date(date);
321
+ copy.setDate(copy.getDate() + days);
322
+ return copy;
323
+ }
324
+
325
+ function countDaysInclusiveLocal(start: Date, end: Date): number {
326
+ return Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
327
+ }
328
+
329
+ function mondayIndex(date: Date): number {
330
+ return (date.getDay() + 6) % 7;
331
+ }
332
+
333
+ function todBucketForHour(hour: number): TodKey {
334
+ for (const bucket of TOD_BUCKETS) {
335
+ if (hour >= bucket.from && hour <= bucket.to) return bucket.key;
336
+ }
337
+ return "after-midnight";
338
+ }
339
+
340
+ function todBucketLabel(key: TodKey): string {
341
+ return TOD_BUCKETS.find((bucket) => bucket.key === key)?.label ?? key;
342
+ }
343
+
344
+ function parseSessionStartFromFilename(name: string): Date | undefined {
345
+ const match = name.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z_/);
346
+ if (!match) return undefined;
347
+ const iso = `${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`;
348
+ const date = new Date(iso);
349
+ return Number.isFinite(date.getTime()) ? date : undefined;
350
+ }
351
+
352
+ function normalizeHistoryProvider(value: unknown): HistoryProviderId | undefined {
353
+ if (typeof value !== "string") return undefined;
354
+ const trimmed = value.trim();
355
+ return trimmed.length > 0 ? trimmed : undefined;
356
+ }
357
+
358
+ function formatProviderShortLabel(providerId: string): string {
359
+ const supported = getSupportedProvider(providerId);
360
+ if (supported) return supported.shortLabel;
361
+ if (providerId.length <= 12) return providerId;
362
+ return providerId.slice(0, 12);
363
+ }
364
+
365
+ function formatProviderLabel(providerId: string): string {
366
+ return getSupportedProvider(providerId)?.label ?? providerId;
367
+ }
368
+
369
+ function extractProviderModelAndUsage(input: unknown): {
370
+ provider?: unknown;
371
+ model?: unknown;
372
+ modelId?: unknown;
373
+ usage?: unknown;
374
+ role?: unknown;
375
+ } {
376
+ const obj = input as { message?: Record<string, unknown> } | null | undefined;
377
+ const message = obj?.message;
378
+ return {
379
+ provider: (obj as Record<string, unknown> | undefined)?.provider ?? message?.provider,
380
+ model: (obj as Record<string, unknown> | undefined)?.model ?? message?.model,
381
+ modelId: (obj as Record<string, unknown> | undefined)?.modelId ?? message?.modelId,
382
+ usage: (obj as Record<string, unknown> | undefined)?.usage ?? message?.usage,
383
+ role: message?.role,
384
+ };
385
+ }
386
+
387
+ function extractCostTotal(usage: unknown): number {
388
+ const input = usage as { cost?: unknown } | null | undefined;
389
+ if (!input) return 0;
390
+
391
+ const cost = input.cost as Record<string, unknown> | number | string | null | undefined;
392
+ const directCost = readNumber(cost);
393
+ if (directCost !== undefined) return directCost;
394
+ if (!cost || typeof cost !== "object") return 0;
395
+
396
+ return readNumber((cost as { total?: unknown }).total) ?? 0;
397
+ }
398
+
399
+ function extractTokensTotal(usage: unknown): number {
400
+ const input = usage as Record<string, unknown> | null | undefined;
401
+ if (!input) return 0;
402
+
403
+ let total =
404
+ readNumber(input.totalTokens) ??
405
+ readNumber(input.total_tokens) ??
406
+ readNumber(input.tokens) ??
407
+ readNumber(input.tokenCount) ??
408
+ readNumber(input.token_count) ??
409
+ 0;
410
+ if (total > 0) return total;
411
+
412
+ const tokens = input.tokens as Record<string, unknown> | null | undefined;
413
+ total =
414
+ readNumber(tokens?.total) ??
415
+ readNumber(tokens?.totalTokens) ??
416
+ readNumber(tokens?.total_tokens) ??
417
+ 0;
418
+ if (total > 0) return total;
419
+
420
+ const prompt =
421
+ readNumber(input.promptTokens) ??
422
+ readNumber(input.prompt_tokens) ??
423
+ readNumber(input.inputTokens) ??
424
+ readNumber(input.input_tokens) ??
425
+ 0;
426
+ const completion =
427
+ readNumber(input.completionTokens) ??
428
+ readNumber(input.completion_tokens) ??
429
+ readNumber(input.outputTokens) ??
430
+ readNumber(input.output_tokens) ??
431
+ 0;
432
+
433
+ const sum = prompt + completion;
434
+ return sum > 0 ? sum : 0;
435
+ }
436
+
437
+ function modelKeyFromParts(provider?: unknown, model?: unknown): ModelKey | undefined {
438
+ const providerText = typeof provider === "string" ? provider.trim() : "";
439
+ const modelText = typeof model === "string" ? model.trim() : "";
440
+ if (!providerText && !modelText) return undefined;
441
+ if (!providerText) return modelText || undefined;
442
+ if (!modelText) return providerText;
443
+ return `${providerText}/${modelText}`;
444
+ }
445
+
446
+ function resolveModelKey(
447
+ provider?: unknown,
448
+ model?: unknown,
449
+ modelId?: unknown,
450
+ ): ModelKey | undefined {
451
+ const modelIdText = typeof modelId === "string" ? modelId.trim() : "";
452
+ if (modelIdText) return modelKeyFromParts(provider, modelIdText);
453
+
454
+ const modelText = typeof model === "string" ? model.trim() : "";
455
+ if (modelText) return modelKeyFromParts(provider, modelText);
456
+
457
+ return modelKeyFromParts(provider);
458
+ }
459
+
460
+ async function walkSessionFiles(
461
+ root: string,
462
+ startCutoffLocal: Date,
463
+ signal?: AbortSignal,
464
+ onFound?: (found: number) => void,
465
+ ): Promise<string[]> {
466
+ const output: string[] = [];
467
+ const stack = [root];
468
+
469
+ while (stack.length > 0) {
470
+ throwIfAborted(signal);
471
+ const dir = stack.pop()!;
472
+ let entries: Dirent[] = [];
473
+ try {
474
+ entries = await fs.readdir(dir, { withFileTypes: true });
475
+ } catch {
476
+ continue;
477
+ }
478
+
479
+ for (const entry of entries) {
480
+ throwIfAborted(signal);
481
+ const filePath = path.join(dir, entry.name);
482
+ if (entry.isDirectory()) {
483
+ stack.push(filePath);
484
+ continue;
485
+ }
486
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
487
+
488
+ const startedAt = parseSessionStartFromFilename(entry.name);
489
+ if (startedAt) {
490
+ if (localMidnight(startedAt) >= startCutoffLocal) {
491
+ output.push(filePath);
492
+ if (onFound && output.length % 10 === 0) onFound(output.length);
493
+ }
494
+ continue;
495
+ }
496
+
497
+ try {
498
+ const stats = await fs.stat(filePath);
499
+ const approx = new Date(stats.mtimeMs);
500
+ if (localMidnight(approx) >= startCutoffLocal) {
501
+ output.push(filePath);
502
+ if (onFound && output.length % 10 === 0) onFound(output.length);
503
+ }
504
+ } catch {
505
+ // Ignore unreadable files.
506
+ }
507
+ }
508
+ }
509
+
510
+ onFound?.(output.length);
511
+ return output;
512
+ }
513
+
514
+ async function parseHistoricalFile(
515
+ filePath: string,
516
+ signal?: AbortSignal,
517
+ ): Promise<ParsedHistoricalFile> {
518
+ const fileName = path.basename(filePath);
519
+ let startedAt = parseSessionStartFromFilename(fileName);
520
+ let cwd: CwdKey | null = null;
521
+ let currentModelAll: ModelKey | undefined;
522
+ const currentModelByProvider = new Map<HistoryProviderId, ModelKey>();
523
+
524
+ let allSession: ParsedAllSession | null = null;
525
+ const providerSessions = new Map<HistoryProviderId, ParsedProviderSession>();
526
+
527
+ const createParsedSessionBase = (baseDate: Date): ParsedSessionBase => ({
528
+ startedAt: baseDate,
529
+ dayKeyLocal: toLocalDayKey(baseDate),
530
+ cwd,
531
+ dow: DOW_NAMES[mondayIndex(baseDate)] ?? "Mon",
532
+ tod: todBucketForHour(baseDate.getHours()),
533
+ modelsUsed: new Set<ModelKey>(),
534
+ messages: 0,
535
+ tokens: 0,
536
+ totalCost: 0,
537
+ costByModel: new Map<ModelKey, number>(),
538
+ messagesByModel: new Map<ModelKey, number>(),
539
+ tokensByModel: new Map<ModelKey, number>(),
540
+ });
541
+
542
+ const getAllSession = (): ParsedAllSession => {
543
+ const baseDate = startedAt ?? new Date();
544
+ if (allSession) return allSession;
545
+ allSession = createParsedSessionBase(baseDate);
546
+ return allSession;
547
+ };
548
+
549
+ const getProviderSession = (provider: HistoryProviderId): ParsedProviderSession => {
550
+ const existing = providerSessions.get(provider);
551
+ if (existing) return existing;
552
+
553
+ const baseDate = startedAt ?? new Date();
554
+ const created: ParsedProviderSession = {
555
+ provider,
556
+ ...createParsedSessionBase(baseDate),
557
+ };
558
+ providerSessions.set(provider, created);
559
+ return created;
560
+ };
561
+
562
+ const updateSessionBaseFields = (session: ParsedAllSession | ParsedProviderSession): void => {
563
+ const baseDate = startedAt ?? session.startedAt;
564
+ session.startedAt = baseDate;
565
+ session.dayKeyLocal = toLocalDayKey(baseDate);
566
+ session.cwd = cwd;
567
+ session.dow = DOW_NAMES[mondayIndex(baseDate)] ?? "Mon";
568
+ session.tod = todBucketForHour(baseDate.getHours());
569
+ };
570
+
571
+ const stream = createReadStream(filePath, { encoding: "utf8" });
572
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
573
+
574
+ try {
575
+ for await (const line of rl) {
576
+ throwIfAborted(signal);
577
+ if (!line) continue;
578
+
579
+ let obj: Record<string, unknown>;
580
+ try {
581
+ obj = JSON.parse(line) as Record<string, unknown>;
582
+ } catch {
583
+ continue;
584
+ }
585
+
586
+ if (obj.type === "session") {
587
+ if (!startedAt && typeof obj.timestamp === "string") {
588
+ const parsed = new Date(obj.timestamp);
589
+ if (Number.isFinite(parsed.getTime())) startedAt = parsed;
590
+ }
591
+ if (typeof obj.cwd === "string" && obj.cwd.trim()) cwd = obj.cwd.trim();
592
+ continue;
593
+ }
594
+
595
+ if (obj.type === "model_change") {
596
+ const provider = normalizeHistoryProvider(obj.provider);
597
+ const fullModel = resolveModelKey(obj.provider, obj.model, obj.modelId);
598
+ if (fullModel) currentModelAll = fullModel;
599
+ if (provider) {
600
+ const providerSession = getProviderSession(provider);
601
+ if (fullModel) {
602
+ currentModelByProvider.set(provider, fullModel);
603
+ providerSession.modelsUsed.add(fullModel);
604
+ }
605
+ }
606
+ continue;
607
+ }
608
+
609
+ if (obj.type !== "message") continue;
610
+
611
+ const {
612
+ provider: rawProvider,
613
+ model,
614
+ modelId,
615
+ usage,
616
+ role,
617
+ } = extractProviderModelAndUsage(obj);
618
+
619
+ const aggregate = getAllSession();
620
+ const aggregateModelKey =
621
+ resolveModelKey(rawProvider, model, modelId) ?? currentModelAll ?? "unknown";
622
+ aggregate.modelsUsed.add(aggregateModelKey);
623
+ aggregate.messages += 1;
624
+ addToMap(aggregate.messagesByModel, aggregateModelKey, 1);
625
+
626
+ const tokens = extractTokensTotal(usage);
627
+ if (tokens > 0) {
628
+ aggregate.tokens += tokens;
629
+ addToMap(aggregate.tokensByModel, aggregateModelKey, tokens);
630
+ }
631
+
632
+ const cost = extractCostTotal(usage);
633
+ if (cost > 0) {
634
+ aggregate.totalCost += cost;
635
+ addToMap(aggregate.costByModel, aggregateModelKey, cost);
636
+ }
637
+
638
+ if (role !== "assistant") continue;
639
+
640
+ const explicitProvider = normalizeHistoryProvider(rawProvider);
641
+ const fallbackProvider = currentModelAll?.includes("/")
642
+ ? currentModelAll.slice(0, currentModelAll.indexOf("/"))
643
+ : undefined;
644
+ const provider = explicitProvider ?? fallbackProvider;
645
+ if (!provider) continue;
646
+
647
+ const providerSession = getProviderSession(provider);
648
+ const providerModel =
649
+ resolveModelKey(rawProvider, model, modelId) ??
650
+ currentModelByProvider.get(provider) ??
651
+ "unknown";
652
+
653
+ providerSession.modelsUsed.add(providerModel);
654
+ providerSession.messages += 1;
655
+ addToMap(providerSession.messagesByModel, providerModel, 1);
656
+
657
+ if (tokens > 0) {
658
+ providerSession.tokens += tokens;
659
+ addToMap(providerSession.tokensByModel, providerModel, tokens);
660
+ }
661
+
662
+ if (cost > 0) {
663
+ providerSession.totalCost += cost;
664
+ addToMap(providerSession.costByModel, providerModel, cost);
665
+ }
666
+ }
667
+ } finally {
668
+ rl.close();
669
+ stream.destroy();
670
+ }
671
+
672
+ if (!allSession && startedAt) {
673
+ allSession = createParsedSessionBase(startedAt);
674
+ }
675
+
676
+ if (allSession) updateSessionBaseFields(allSession);
677
+ for (const providerSession of providerSessions.values()) {
678
+ updateSessionBaseFields(providerSession);
679
+ }
680
+
681
+ return {
682
+ all: allSession,
683
+ providers: [...providerSessions.values()],
684
+ };
685
+ }
686
+
687
+ function buildRangeAgg(days: number, now: Date): RangeAgg {
688
+ const end = localMidnight(now);
689
+ const start = addDaysLocal(end, -(days - 1));
690
+ const daysOut: DayAgg[] = [];
691
+ const dayByKey = new Map<string, DayAgg>();
692
+
693
+ for (let index = 0; index < days; index += 1) {
694
+ const date = addDaysLocal(start, index);
695
+ const dayKeyLocal = toLocalDayKey(date);
696
+ const day: DayAgg = {
697
+ date,
698
+ dayKeyLocal,
699
+ sessions: 0,
700
+ messages: 0,
701
+ tokens: 0,
702
+ totalCost: 0,
703
+ costByModel: new Map(),
704
+ sessionsByModel: new Map(),
705
+ messagesByModel: new Map(),
706
+ tokensByModel: new Map(),
707
+ sessionsByCwd: new Map(),
708
+ messagesByCwd: new Map(),
709
+ tokensByCwd: new Map(),
710
+ costByCwd: new Map(),
711
+ sessionsByTod: new Map(),
712
+ messagesByTod: new Map(),
713
+ tokensByTod: new Map(),
714
+ costByTod: new Map(),
715
+ };
716
+ daysOut.push(day);
717
+ dayByKey.set(dayKeyLocal, day);
718
+ }
719
+
720
+ return {
721
+ days: daysOut,
722
+ dayByKey,
723
+ sessions: 0,
724
+ totalMessages: 0,
725
+ totalTokens: 0,
726
+ totalCost: 0,
727
+ modelCost: new Map(),
728
+ modelSessions: new Map(),
729
+ modelMessages: new Map(),
730
+ modelTokens: new Map(),
731
+ cwdCost: new Map(),
732
+ cwdSessions: new Map(),
733
+ cwdMessages: new Map(),
734
+ cwdTokens: new Map(),
735
+ dowCost: new Map(),
736
+ dowSessions: new Map(),
737
+ dowMessages: new Map(),
738
+ dowTokens: new Map(),
739
+ todCost: new Map(),
740
+ todSessions: new Map(),
741
+ todMessages: new Map(),
742
+ todTokens: new Map(),
743
+ };
744
+ }
745
+
746
+ const emptyRangeCache = new Map<number, { dayKey: string; range: RangeAgg }>();
747
+
748
+ function addToMap<K>(map: Map<K, number>, key: K, value: number): void {
749
+ map.set(key, (map.get(key) ?? 0) + value);
750
+ }
751
+
752
+ function getEmptyRange(days: number, now = new Date()): RangeAgg {
753
+ const dayKey = toLocalDayKey(localMidnight(now));
754
+ const cached = emptyRangeCache.get(days);
755
+ if (cached?.dayKey === dayKey) return cached.range;
756
+
757
+ const range = buildRangeAgg(days, now);
758
+ emptyRangeCache.set(days, { dayKey, range });
759
+ return range;
760
+ }
761
+
762
+ function addSessionToRange(
763
+ range: RangeAgg,
764
+ session: ParsedAllSession | ParsedProviderSession,
765
+ ): void {
766
+ const day = range.dayByKey.get(session.dayKeyLocal);
767
+ if (!day) return;
768
+
769
+ range.sessions += 1;
770
+ range.totalMessages += session.messages;
771
+ range.totalTokens += session.tokens;
772
+ range.totalCost += session.totalCost;
773
+
774
+ day.sessions += 1;
775
+ day.messages += session.messages;
776
+ day.tokens += session.tokens;
777
+ day.totalCost += session.totalCost;
778
+
779
+ for (const model of session.modelsUsed) {
780
+ addToMap(day.sessionsByModel, model, 1);
781
+ addToMap(range.modelSessions, model, 1);
782
+ }
783
+
784
+ for (const [model, count] of session.messagesByModel.entries()) {
785
+ addToMap(day.messagesByModel, model, count);
786
+ addToMap(range.modelMessages, model, count);
787
+ }
788
+
789
+ for (const [model, count] of session.tokensByModel.entries()) {
790
+ addToMap(day.tokensByModel, model, count);
791
+ addToMap(range.modelTokens, model, count);
792
+ }
793
+
794
+ for (const [model, cost] of session.costByModel.entries()) {
795
+ addToMap(day.costByModel, model, cost);
796
+ addToMap(range.modelCost, model, cost);
797
+ }
798
+
799
+ if (session.cwd) {
800
+ addToMap(day.sessionsByCwd, session.cwd, 1);
801
+ addToMap(range.cwdSessions, session.cwd, 1);
802
+ addToMap(day.messagesByCwd, session.cwd, session.messages);
803
+ addToMap(range.cwdMessages, session.cwd, session.messages);
804
+ addToMap(day.tokensByCwd, session.cwd, session.tokens);
805
+ addToMap(range.cwdTokens, session.cwd, session.tokens);
806
+ addToMap(day.costByCwd, session.cwd, session.totalCost);
807
+ addToMap(range.cwdCost, session.cwd, session.totalCost);
808
+ }
809
+
810
+ addToMap(range.dowSessions, session.dow, 1);
811
+ addToMap(range.dowMessages, session.dow, session.messages);
812
+ addToMap(range.dowTokens, session.dow, session.tokens);
813
+ addToMap(range.dowCost, session.dow, session.totalCost);
814
+
815
+ addToMap(day.sessionsByTod, session.tod, 1);
816
+ addToMap(day.messagesByTod, session.tod, session.messages);
817
+ addToMap(day.tokensByTod, session.tod, session.tokens);
818
+ addToMap(day.costByTod, session.tod, session.totalCost);
819
+
820
+ addToMap(range.todSessions, session.tod, 1);
821
+ addToMap(range.todMessages, session.tod, session.messages);
822
+ addToMap(range.todTokens, session.tod, session.tokens);
823
+ addToMap(range.todCost, session.tod, session.totalCost);
824
+ }
825
+
826
+ function sortMapByValueDesc<K extends string>(
827
+ map: Map<K, number>,
828
+ ): Array<{ key: K; value: number }> {
829
+ return [...map.entries()]
830
+ .map(([key, value]) => ({ key, value }))
831
+ .sort((left, right) => right.value - left.value);
832
+ }
833
+
834
+ function preferredPopularityMap<K>(options: {
835
+ cost: Map<K, number>;
836
+ tokens: Map<K, number>;
837
+ messages: Map<K, number>;
838
+ sessions: Map<K, number>;
839
+ totalTokens: number;
840
+ totalMessages: number;
841
+ }): Map<K, number> {
842
+ const costSum = [...options.cost.values()].reduce((sum, value) => sum + value, 0);
843
+ if (costSum > 0) return options.cost;
844
+ if (options.totalTokens > 0) return options.tokens;
845
+ if (options.totalMessages > 0) return options.messages;
846
+ return options.sessions;
847
+ }
848
+
849
+ function metricValuesForKind<K>(
850
+ kind: GraphMetric["kind"],
851
+ options: {
852
+ sessions: Map<K, number>;
853
+ messages: Map<K, number>;
854
+ tokens: Map<K, number>;
855
+ totalSessions: number;
856
+ totalMessages: number;
857
+ totalTokens: number;
858
+ },
859
+ ): { values: Map<K, number>; total: number } {
860
+ if (kind === "tokens") {
861
+ return { values: options.tokens, total: options.totalTokens };
862
+ }
863
+ if (kind === "messages") {
864
+ return { values: options.messages, total: options.totalMessages };
865
+ }
866
+ return { values: options.sessions, total: options.totalSessions };
867
+ }
868
+
869
+ function normalizeHue(value: number): number {
870
+ const normalized = value % 360;
871
+ return normalized < 0 ? normalized + 360 : normalized;
872
+ }
873
+
874
+ function rgbToHsl(color: RGB): { h: number; s: number; l: number } {
875
+ const r = color.r / 255;
876
+ const g = color.g / 255;
877
+ const b = color.b / 255;
878
+ const max = Math.max(r, g, b);
879
+ const min = Math.min(r, g, b);
880
+ const lightness = (max + min) / 2;
881
+
882
+ if (max === min) {
883
+ return { h: 0, s: 0, l: lightness };
884
+ }
885
+
886
+ const delta = max - min;
887
+ const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
888
+
889
+ let hue = 0;
890
+ if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0);
891
+ else if (max === g) hue = (b - r) / delta + 2;
892
+ else hue = (r - g) / delta + 4;
893
+
894
+ return { h: normalizeHue(hue * 60), s: saturation, l: lightness };
895
+ }
896
+
897
+ function hslToRgb(hue: number, saturation: number, lightness: number): RGB {
898
+ const h = normalizeHue(hue) / 360;
899
+ const s = clamp01(saturation);
900
+ const l = clamp01(lightness);
901
+
902
+ if (s === 0) {
903
+ const value = Math.round(l * 255);
904
+ return { r: value, g: value, b: value };
905
+ }
906
+
907
+ const hueToChannel = (p: number, q: number, t: number): number => {
908
+ let value = t;
909
+ if (value < 0) value += 1;
910
+ if (value > 1) value -= 1;
911
+ if (value < 1 / 6) return p + (q - p) * 6 * value;
912
+ if (value < 1 / 2) return q;
913
+ if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6;
914
+ return p;
915
+ };
916
+
917
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
918
+ const p = 2 * l - q;
919
+
920
+ return {
921
+ r: Math.round(hueToChannel(p, q, h + 1 / 3) * 255),
922
+ g: Math.round(hueToChannel(p, q, h) * 255),
923
+ b: Math.round(hueToChannel(p, q, h - 1 / 3) * 255),
924
+ };
925
+ }
926
+
927
+ function adjustColor(
928
+ color: RGB,
929
+ adjustments: { hueShift?: number; saturationShift?: number; lightnessShift?: number },
930
+ ): RGB {
931
+ const hsl = rgbToHsl(color);
932
+ return hslToRgb(
933
+ hsl.h + (adjustments.hueShift ?? 0),
934
+ hsl.s + (adjustments.saturationShift ?? 0),
935
+ hsl.l + (adjustments.lightnessShift ?? 0),
936
+ );
937
+ }
938
+
939
+ function buildProviderModelPalette(color: RGB): readonly RGB[] {
940
+ return [
941
+ color,
942
+ adjustColor(color, { hueShift: 16, saturationShift: 0.04, lightnessShift: 0.08 }),
943
+ adjustColor(color, { hueShift: -12, saturationShift: 0.02, lightnessShift: -0.06 }),
944
+ ];
945
+ }
946
+
947
+ function chooseModelPalette(
948
+ range30: RangeAgg,
949
+ topN = 4,
950
+ paletteColors: readonly RGB[] = PALETTE,
951
+ ): ProviderPalette["modelPalette"] {
952
+ const popularity = preferredPopularityMap({
953
+ cost: range30.modelCost,
954
+ tokens: range30.modelTokens,
955
+ messages: range30.modelMessages,
956
+ sessions: range30.modelSessions,
957
+ totalTokens: range30.totalTokens,
958
+ totalMessages: range30.totalMessages,
959
+ });
960
+
961
+ const orderedModels = sortMapByValueDesc(popularity)
962
+ .slice(0, topN)
963
+ .map((entry) => entry.key);
964
+ const modelColors = new Map<ModelKey, RGB>();
965
+
966
+ for (let index = 0; index < orderedModels.length; index += 1) {
967
+ const model = orderedModels[index];
968
+ const color = paletteColors[index] ?? PALETTE[index % PALETTE.length] ?? MUTED_COLOR;
969
+ if (model) modelColors.set(model, color);
970
+ }
971
+
972
+ return {
973
+ modelColors,
974
+ otherColor: MUTED_COLOR,
975
+ orderedModels,
976
+ };
977
+ }
978
+
979
+ function chooseCwdPalette(range30: RangeAgg, topN = 4): ProviderPalette["cwdPalette"] {
980
+ const popularity = preferredPopularityMap({
981
+ cost: range30.cwdCost,
982
+ tokens: range30.cwdTokens,
983
+ messages: range30.cwdMessages,
984
+ sessions: range30.cwdSessions,
985
+ totalTokens: range30.totalTokens,
986
+ totalMessages: range30.totalMessages,
987
+ });
988
+
989
+ const orderedCwds = sortMapByValueDesc(popularity)
990
+ .slice(0, topN)
991
+ .map((entry) => entry.key);
992
+ const cwdColors = new Map<CwdKey, RGB>();
993
+ for (let index = 0; index < orderedCwds.length; index += 1) {
994
+ const cwd = orderedCwds[index];
995
+ if (cwd) cwdColors.set(cwd, PALETTE[index % PALETTE.length]!);
996
+ }
997
+
998
+ return {
999
+ cwdColors,
1000
+ otherColor: MUTED_COLOR,
1001
+ orderedCwds,
1002
+ };
1003
+ }
1004
+
1005
+ function buildDowPalette(): BreakdownData["dowPalette"] {
1006
+ const dowColors = new Map<DowKey, RGB>();
1007
+ for (let index = 0; index < DOW_NAMES.length; index += 1) {
1008
+ const dow = DOW_NAMES[index];
1009
+ const color = DOW_PALETTE[index];
1010
+ if (dow && color) dowColors.set(dow, color);
1011
+ }
1012
+ return { dowColors, orderedDows: [...DOW_NAMES] };
1013
+ }
1014
+
1015
+ function buildTodPalette(): BreakdownData["todPalette"] {
1016
+ const todColors = new Map<TodKey, RGB>();
1017
+ const orderedTods: TodKey[] = [];
1018
+ for (const bucket of TOD_BUCKETS) {
1019
+ const color = TOD_PALETTE.get(bucket.key);
1020
+ if (color) todColors.set(bucket.key, color);
1021
+ orderedTods.push(bucket.key);
1022
+ }
1023
+ return { todColors, orderedTods };
1024
+ }
1025
+
1026
+ function dayMixedColor(
1027
+ day: DayAgg,
1028
+ colorMap: Map<string, RGB>,
1029
+ otherColor: RGB,
1030
+ mode: MeasurementMode,
1031
+ view: BreakdownView,
1032
+ ): RGB {
1033
+ if (view === "dow") {
1034
+ const dowKey = DOW_NAMES[mondayIndex(day.date)] ?? "Mon";
1035
+ return colorMap.get(dowKey) ?? otherColor;
1036
+ }
1037
+
1038
+ let weights: Map<string, number>;
1039
+ if (view === "tod") {
1040
+ weights =
1041
+ mode === "tokens"
1042
+ ? day.tokens > 0
1043
+ ? day.tokensByTod
1044
+ : day.messages > 0
1045
+ ? day.messagesByTod
1046
+ : day.sessionsByTod
1047
+ : mode === "messages"
1048
+ ? day.messages > 0
1049
+ ? day.messagesByTod
1050
+ : day.sessionsByTod
1051
+ : day.sessionsByTod;
1052
+ } else if (view === "cwd") {
1053
+ weights =
1054
+ mode === "tokens"
1055
+ ? day.tokens > 0
1056
+ ? day.tokensByCwd
1057
+ : day.messages > 0
1058
+ ? day.messagesByCwd
1059
+ : day.sessionsByCwd
1060
+ : mode === "messages"
1061
+ ? day.messages > 0
1062
+ ? day.messagesByCwd
1063
+ : day.sessionsByCwd
1064
+ : day.sessionsByCwd;
1065
+ } else {
1066
+ weights =
1067
+ mode === "tokens"
1068
+ ? day.tokens > 0
1069
+ ? day.tokensByModel
1070
+ : day.messages > 0
1071
+ ? day.messagesByModel
1072
+ : day.sessionsByModel
1073
+ : mode === "messages"
1074
+ ? day.messages > 0
1075
+ ? day.messagesByModel
1076
+ : day.sessionsByModel
1077
+ : day.sessionsByModel;
1078
+ }
1079
+
1080
+ const parts: Array<{ color: RGB; weight: number }> = [];
1081
+ let otherWeight = 0;
1082
+ for (const [key, weight] of weights.entries()) {
1083
+ const color = colorMap.get(key);
1084
+ if (color) parts.push({ color, weight });
1085
+ else otherWeight += weight;
1086
+ }
1087
+ if (otherWeight > 0) parts.push({ color: otherColor, weight: otherWeight });
1088
+
1089
+ return weightedMix(parts);
1090
+ }
1091
+
1092
+ interface GraphMetric {
1093
+ kind: "sessions" | "messages" | "tokens";
1094
+ denom: number;
1095
+ }
1096
+
1097
+ function graphMetricForRange(range: RangeAgg, mode: MeasurementMode): GraphMetric {
1098
+ if (mode === "tokens") {
1099
+ const maxTokens = Math.max(0, ...range.days.map((day) => day.tokens));
1100
+ if (maxTokens > 0) return { kind: "tokens", denom: Math.log1p(maxTokens) };
1101
+ mode = "messages";
1102
+ }
1103
+
1104
+ if (mode === "messages") {
1105
+ const maxMessages = Math.max(0, ...range.days.map((day) => day.messages));
1106
+ if (maxMessages > 0) {
1107
+ return { kind: "messages", denom: Math.log1p(maxMessages) };
1108
+ }
1109
+ mode = "sessions";
1110
+ }
1111
+
1112
+ const maxSessions = Math.max(0, ...range.days.map((day) => day.sessions));
1113
+ return { kind: "sessions", denom: Math.log1p(maxSessions) };
1114
+ }
1115
+
1116
+ function weeksForRange(range: RangeAgg): number {
1117
+ const start = range.days[0]?.date;
1118
+ const end = range.days[range.days.length - 1]?.date;
1119
+ if (!start || !end) return 0;
1120
+ const gridStart = addDaysLocal(start, -mondayIndex(start));
1121
+ const gridEnd = addDaysLocal(end, 6 - mondayIndex(end));
1122
+ const totalGridDays = countDaysInclusiveLocal(gridStart, gridEnd);
1123
+ return Math.ceil(totalGridDays / 7);
1124
+ }
1125
+
1126
+ function renderGraphLines(
1127
+ range: RangeAgg,
1128
+ colorMap: Map<string, RGB>,
1129
+ otherColor: RGB,
1130
+ mode: MeasurementMode,
1131
+ options?: { cellWidth?: number; gap?: number },
1132
+ view: BreakdownView = "model",
1133
+ weeks?: number,
1134
+ ): string[] {
1135
+ const start = range.days[0]?.date;
1136
+ const end = range.days[range.days.length - 1]?.date;
1137
+ if (!start || !end) return [];
1138
+
1139
+ const gridStart = addDaysLocal(start, -mondayIndex(start));
1140
+ if (weeks === undefined) {
1141
+ const gridEnd = addDaysLocal(end, 6 - mondayIndex(end));
1142
+ const totalGridDays = countDaysInclusiveLocal(gridStart, gridEnd);
1143
+ weeks = Math.ceil(totalGridDays / 7);
1144
+ }
1145
+
1146
+ const cellWidth = Math.max(1, Math.floor(options?.cellWidth ?? 1));
1147
+ const gap = Math.max(0, Math.floor(options?.gap ?? 1));
1148
+ const block = "█".repeat(cellWidth);
1149
+ const gapString = " ".repeat(gap);
1150
+
1151
+ const metric = graphMetricForRange(range, mode);
1152
+ const denom = metric.denom;
1153
+ const labelByRow = new Map<number, string>([
1154
+ [0, "Mon"],
1155
+ [2, "Wed"],
1156
+ [4, "Fri"],
1157
+ ]);
1158
+
1159
+ const lines: string[] = [];
1160
+ for (let row = 0; row < 7; row += 1) {
1161
+ const label = labelByRow.get(row);
1162
+ let line = label ? `${padRight(label, 3)} ` : " ";
1163
+
1164
+ for (let week = 0; week < weeks; week += 1) {
1165
+ const cellDate = addDaysLocal(gridStart, week * 7 + row);
1166
+ const inRange = cellDate >= start && cellDate <= end;
1167
+ const columnGap = week < weeks - 1 ? gapString : "";
1168
+ if (!inRange) {
1169
+ line += " ".repeat(cellWidth) + columnGap;
1170
+ continue;
1171
+ }
1172
+
1173
+ const key = toLocalDayKey(cellDate);
1174
+ const day = range.dayByKey.get(key);
1175
+ const value =
1176
+ metric.kind === "tokens"
1177
+ ? (day?.tokens ?? 0)
1178
+ : metric.kind === "messages"
1179
+ ? (day?.messages ?? 0)
1180
+ : (day?.sessions ?? 0);
1181
+
1182
+ if (!day || value <= 0) {
1183
+ line += ansiFg(EMPTY_CELL_BG, block) + columnGap;
1184
+ continue;
1185
+ }
1186
+
1187
+ const hue = dayMixedColor(day, colorMap, otherColor, mode, view);
1188
+ const intensity = 0.2 + 0.8 * clamp01(denom > 0 ? Math.log1p(value) / denom : 0);
1189
+ const rgb = mixRgb(DEFAULT_BG, hue, intensity);
1190
+ line += ansiFg(rgb, block) + columnGap;
1191
+ }
1192
+
1193
+ lines.push(line);
1194
+ }
1195
+
1196
+ return lines;
1197
+ }
1198
+
1199
+ function renderDowDistributionLines(
1200
+ range: RangeAgg,
1201
+ mode: MeasurementMode,
1202
+ dowColors: Map<DowKey, RGB>,
1203
+ width: number,
1204
+ ): string[] {
1205
+ const metric = graphMetricForRange(range, mode);
1206
+ const { values: perDow, total } = metricValuesForKind(metric.kind, {
1207
+ sessions: range.dowSessions,
1208
+ messages: range.dowMessages,
1209
+ tokens: range.dowTokens,
1210
+ totalSessions: range.sessions,
1211
+ totalMessages: range.totalMessages,
1212
+ totalTokens: range.totalTokens,
1213
+ });
1214
+
1215
+ const dayWidth = 3;
1216
+ const pctWidth = 4;
1217
+ const valueWidth = metric.kind === "tokens" ? 10 : 8;
1218
+ const showValue = width >= dayWidth + 1 + 10 + 1 + pctWidth + 1 + valueWidth;
1219
+ const fixedWidth = dayWidth + 1 + 1 + pctWidth + (showValue ? 1 + valueWidth : 0);
1220
+ const barWidth = Math.max(1, width - fixedWidth);
1221
+
1222
+ const lines: string[] = [];
1223
+ for (const dow of DOW_NAMES) {
1224
+ const value = perDow.get(dow) ?? 0;
1225
+ const share = total > 0 ? value / total : 0;
1226
+ let filled = share > 0 ? Math.round(share * barWidth) : 0;
1227
+ if (share > 0) filled = Math.max(1, filled);
1228
+ filled = Math.min(barWidth, filled);
1229
+ const empty = Math.max(0, barWidth - filled);
1230
+
1231
+ const color = dowColors.get(dow) ?? MUTED_COLOR;
1232
+ const filledBar = filled > 0 ? ansiFg(color, "█".repeat(filled)) : "";
1233
+ const emptyBar = empty > 0 ? ansiFg(EMPTY_CELL_BG, "█".repeat(empty)) : "";
1234
+ const pct = padLeft(`${Math.round(share * 100)}%`, pctWidth);
1235
+
1236
+ let line = `${padRight(dow, dayWidth)} ${filledBar}${emptyBar} ${pct}`;
1237
+ if (showValue) line += ` ${padLeft(formatCount(value), valueWidth)}`;
1238
+ lines.push(line);
1239
+ }
1240
+
1241
+ return lines;
1242
+ }
1243
+
1244
+ function formatModelLabel(modelKey: string, stripProvider: boolean): string {
1245
+ if (!stripProvider) return modelKey;
1246
+ const index = modelKey.indexOf("/");
1247
+ return index === -1 ? modelKey : modelKey.slice(index + 1);
1248
+ }
1249
+
1250
+ function renderModelTable(
1251
+ range: RangeAgg,
1252
+ metric: GraphMetric,
1253
+ maxRows = 8,
1254
+ options?: { stripProvider?: boolean },
1255
+ ): string[] {
1256
+ const { values: perModel, total } = metricValuesForKind(metric.kind, {
1257
+ sessions: range.modelSessions,
1258
+ messages: range.modelMessages,
1259
+ tokens: range.modelTokens,
1260
+ totalSessions: range.sessions,
1261
+ totalMessages: range.totalMessages,
1262
+ totalTokens: range.totalTokens,
1263
+ });
1264
+
1265
+ const sorted = sortMapByValueDesc(perModel);
1266
+ const rows = sorted.slice(0, maxRows);
1267
+ const labels = rows.map((row) => formatModelLabel(row.key, options?.stripProvider ?? false));
1268
+ const valueWidth = metric.kind === "tokens" ? 10 : 8;
1269
+ const modelWidth = Math.min(52, Math.max("model".length, ...labels.map((label) => label.length)));
1270
+
1271
+ const lines: string[] = [];
1272
+ lines.push(
1273
+ `${padRight("model", modelWidth)} ${padLeft(metric.kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`,
1274
+ );
1275
+ lines.push(
1276
+ `${"-".repeat(modelWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`,
1277
+ );
1278
+
1279
+ for (let index = 0; index < rows.length; index += 1) {
1280
+ const row = rows[index]!;
1281
+ const label = labels[index] ?? row.key;
1282
+ const value = perModel.get(row.key) ?? 0;
1283
+ const cost = range.modelCost.get(row.key) ?? 0;
1284
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1285
+ lines.push(
1286
+ `${padRight(label.slice(0, modelWidth), modelWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1287
+ );
1288
+ }
1289
+
1290
+ if (sorted.length === 0) lines.push(dim("(no model data found)"));
1291
+ return lines;
1292
+ }
1293
+
1294
+ function renderCwdTable(range: RangeAgg, metric: GraphMetric, maxRows = 8): string[] {
1295
+ const { values: perCwd, total } = metricValuesForKind(metric.kind, {
1296
+ sessions: range.cwdSessions,
1297
+ messages: range.cwdMessages,
1298
+ tokens: range.cwdTokens,
1299
+ totalSessions: range.sessions,
1300
+ totalMessages: range.totalMessages,
1301
+ totalTokens: range.totalTokens,
1302
+ });
1303
+
1304
+ const sorted = sortMapByValueDesc(perCwd);
1305
+ const rows = sorted.slice(0, maxRows);
1306
+ const labels = rows.map((row) => abbreviatePath(row.key, 40));
1307
+ const valueWidth = metric.kind === "tokens" ? 10 : 8;
1308
+ const cwdWidth = Math.min(
1309
+ 42,
1310
+ Math.max("directory".length, ...labels.map((label) => label.length)),
1311
+ );
1312
+
1313
+ const lines: string[] = [];
1314
+ lines.push(
1315
+ `${padRight("directory", cwdWidth)} ${padLeft(metric.kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`,
1316
+ );
1317
+ lines.push(
1318
+ `${"-".repeat(cwdWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`,
1319
+ );
1320
+
1321
+ for (let index = 0; index < rows.length; index += 1) {
1322
+ const row = rows[index]!;
1323
+ const label = labels[index] ?? row.key;
1324
+ const value = perCwd.get(row.key) ?? 0;
1325
+ const cost = range.cwdCost.get(row.key) ?? 0;
1326
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1327
+ lines.push(
1328
+ `${padRight(label.slice(0, cwdWidth), cwdWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1329
+ );
1330
+ }
1331
+
1332
+ if (sorted.length === 0) lines.push(dim("(no directory data found)"));
1333
+ return lines;
1334
+ }
1335
+
1336
+ function renderDowTable(range: RangeAgg, metric: GraphMetric): string[] {
1337
+ const { values: perDow, total } = metricValuesForKind(metric.kind, {
1338
+ sessions: range.dowSessions,
1339
+ messages: range.dowMessages,
1340
+ tokens: range.dowTokens,
1341
+ totalSessions: range.sessions,
1342
+ totalMessages: range.totalMessages,
1343
+ totalTokens: range.totalTokens,
1344
+ });
1345
+ const valueWidth = metric.kind === "tokens" ? 10 : 8;
1346
+ const dowWidth = 5;
1347
+
1348
+ const lines: string[] = [];
1349
+ lines.push(
1350
+ `${padRight("day", dowWidth)} ${padLeft(metric.kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`,
1351
+ );
1352
+ lines.push(
1353
+ `${"-".repeat(dowWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`,
1354
+ );
1355
+
1356
+ for (const dow of DOW_NAMES) {
1357
+ const value = perDow.get(dow) ?? 0;
1358
+ const cost = range.dowCost.get(dow) ?? 0;
1359
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1360
+ lines.push(
1361
+ `${padRight(dow, dowWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1362
+ );
1363
+ }
1364
+
1365
+ return lines;
1366
+ }
1367
+
1368
+ function renderTodTable(range: RangeAgg, metric: GraphMetric): string[] {
1369
+ const { values: perTod, total } = metricValuesForKind(metric.kind, {
1370
+ sessions: range.todSessions,
1371
+ messages: range.todMessages,
1372
+ tokens: range.todTokens,
1373
+ totalSessions: range.sessions,
1374
+ totalMessages: range.totalMessages,
1375
+ totalTokens: range.totalTokens,
1376
+ });
1377
+
1378
+ const valueWidth = metric.kind === "tokens" ? 10 : 8;
1379
+ const todWidth = 22;
1380
+
1381
+ const lines: string[] = [];
1382
+ lines.push(
1383
+ `${padRight("time of day", todWidth)} ${padLeft(metric.kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`,
1384
+ );
1385
+ lines.push(
1386
+ `${"-".repeat(todWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`,
1387
+ );
1388
+
1389
+ for (const bucket of TOD_BUCKETS) {
1390
+ const value = perTod.get(bucket.key) ?? 0;
1391
+ const cost = range.todCost.get(bucket.key) ?? 0;
1392
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1393
+ lines.push(
1394
+ `${padRight(bucket.label, todWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1395
+ );
1396
+ }
1397
+
1398
+ return lines;
1399
+ }
1400
+
1401
+ function rangeSummary(range: RangeAgg, days: number, mode: MeasurementMode): string {
1402
+ const avg = range.sessions > 0 ? range.totalCost / range.sessions : 0;
1403
+ const costPart =
1404
+ range.totalCost > 0
1405
+ ? `${formatUsd(range.totalCost)} · avg ${formatUsd(avg)}/session`
1406
+ : "$0.0000";
1407
+
1408
+ if (mode === "tokens") {
1409
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalTokens)} tokens · ${costPart}`;
1410
+ }
1411
+ if (mode === "messages") {
1412
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalMessages)} messages · ${costPart}`;
1413
+ }
1414
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${costPart}`;
1415
+ }
1416
+
1417
+ function historySemanticsText(tab: BreakdownTab): string {
1418
+ if (tab.mode === "all") {
1419
+ return "history: sessions = session files; messages = all Pi message rows; tokens = assistant usage tokens";
1420
+ }
1421
+ return "history: sessions = session files where this provider appeared; messages/tokens = provider-attributed assistant usage";
1422
+ }
1423
+
1424
+ async function getLiveUsageAvailability(
1425
+ provider: UsageProviderDefinition,
1426
+ ctx: ExtensionContext,
1427
+ ): Promise<LiveUsageAvailability> {
1428
+ if (provider.getLiveUsageAvailability) {
1429
+ return provider.getLiveUsageAvailability(ctx);
1430
+ }
1431
+
1432
+ return ctx.modelRegistry.authStorage.hasAuth(provider.id)
1433
+ ? { available: true }
1434
+ : { available: false, reason: "no auth configured" };
1435
+ }
1436
+
1437
+ async function getLiveUsageAvailabilityMap(
1438
+ ctx: ExtensionContext,
1439
+ ): Promise<Map<SupportedProviderId, LiveUsageAvailability>> {
1440
+ const entries = await Promise.all(
1441
+ SUPPORTED_PROVIDERS.map(
1442
+ async (provider) => [provider.id, await getLiveUsageAvailability(provider, ctx)] as const,
1443
+ ),
1444
+ );
1445
+ return new Map(entries);
1446
+ }
1447
+
1448
+ function getLiveAvailableProviders(
1449
+ availabilityByProvider: ReadonlyMap<SupportedProviderId, LiveUsageAvailability>,
1450
+ ): Set<SupportedProviderId> {
1451
+ const providers = new Set<SupportedProviderId>();
1452
+ for (const [providerId, availability] of availabilityByProvider.entries()) {
1453
+ if (availability.available) providers.add(providerId);
1454
+ }
1455
+ return providers;
1456
+ }
1457
+
1458
+ function buildInitialLiveUsage(
1459
+ providers: readonly UsageProviderDefinition[],
1460
+ availabilityByProvider: ReadonlyMap<SupportedProviderId, LiveUsageAvailability>,
1461
+ ): Map<SupportedProviderId, LiveUsageState> {
1462
+ const liveUsage = new Map<SupportedProviderId, LiveUsageState>();
1463
+ for (const provider of providers) {
1464
+ const availability = availabilityByProvider.get(provider.id);
1465
+ if (!availability?.available) {
1466
+ liveUsage.set(provider.id, {
1467
+ status: "unavailable",
1468
+ reason: availability?.reason ?? "no auth configured",
1469
+ });
1470
+ }
1471
+ }
1472
+ return liveUsage;
1473
+ }
1474
+
1475
+ async function fetchLiveUsageForProvider(
1476
+ provider: UsageProviderDefinition,
1477
+ availabilityByProvider: ReadonlyMap<SupportedProviderId, LiveUsageAvailability>,
1478
+ ctx: ExtensionContext,
1479
+ signal?: AbortSignal,
1480
+ ): Promise<LiveUsageState> {
1481
+ const availability = availabilityByProvider.get(provider.id);
1482
+ if (!availability?.available) {
1483
+ return { status: "unavailable", reason: availability?.reason ?? "no auth configured" };
1484
+ }
1485
+
1486
+ try {
1487
+ const snapshot = await provider.fetchLiveUsage(ctx, signal);
1488
+ return { status: "ok", snapshot };
1489
+ } catch (error) {
1490
+ if (isLiveUsageUnavailableError(error)) {
1491
+ return { status: "unavailable", reason: error.message };
1492
+ }
1493
+ return {
1494
+ status: "error",
1495
+ error: error instanceof Error ? error.message : String(error),
1496
+ };
1497
+ }
1498
+ }
1499
+
1500
+ async function fetchLiveUsage(
1501
+ providers: readonly UsageProviderDefinition[],
1502
+ availabilityByProvider: ReadonlyMap<SupportedProviderId, LiveUsageAvailability>,
1503
+ ctx: ExtensionContext,
1504
+ signal?: AbortSignal,
1505
+ ): Promise<Map<SupportedProviderId, LiveUsageState>> {
1506
+ throwIfAborted(signal);
1507
+ const entries = await Promise.all(
1508
+ providers.map(
1509
+ async (provider) =>
1510
+ [
1511
+ provider.id,
1512
+ await fetchLiveUsageForProvider(provider, availabilityByProvider, ctx, signal),
1513
+ ] as const,
1514
+ ),
1515
+ );
1516
+ return new Map(entries);
1517
+ }
1518
+
1519
+ function buildTabs(
1520
+ historyProviderIds: Set<HistoryProviderId>,
1521
+ liveAvailableProviders: ReadonlySet<string>,
1522
+ ): BreakdownTab[] {
1523
+ const tabs: BreakdownTab[] = [
1524
+ { id: ALL_TAB_ID, label: "All providers", shortLabel: "all", mode: "all" },
1525
+ ];
1526
+ const added = new Set<string>();
1527
+
1528
+ for (const provider of SUPPORTED_PROVIDERS) {
1529
+ if (!historyProviderIds.has(provider.id) && !liveAvailableProviders.has(provider.id)) continue;
1530
+ tabs.push({
1531
+ id: provider.id,
1532
+ label: provider.label,
1533
+ shortLabel: provider.shortLabel,
1534
+ mode: "provider",
1535
+ liveProvider: provider,
1536
+ });
1537
+ added.add(provider.id);
1538
+ }
1539
+
1540
+ const historyOnly = [...historyProviderIds].filter((providerId) => !added.has(providerId));
1541
+ historyOnly.sort((left, right) => left.localeCompare(right));
1542
+ for (const providerId of historyOnly) {
1543
+ tabs.push({
1544
+ id: providerId,
1545
+ label: formatProviderLabel(providerId),
1546
+ shortLabel: formatProviderShortLabel(providerId),
1547
+ mode: "provider",
1548
+ });
1549
+ }
1550
+
1551
+ return tabs;
1552
+ }
1553
+
1554
+ async function computeBreakdown(
1555
+ availabilityByProvider: ReadonlyMap<SupportedProviderId, LiveUsageAvailability>,
1556
+ signal?: AbortSignal,
1557
+ onProgress?: (update: Partial<BreakdownProgressState>) => void,
1558
+ ): Promise<BreakdownData> {
1559
+ const now = new Date();
1560
+ const allRanges = new Map<number, RangeAgg>();
1561
+ for (const days of RANGE_DAYS) allRanges.set(days, buildRangeAgg(days, now));
1562
+
1563
+ const providerRangesById = new Map<HistoryProviderId, Map<number, RangeAgg>>();
1564
+ const historyProviderIds = new Set<HistoryProviderId>();
1565
+
1566
+ const range90 = allRanges.get(90)!;
1567
+ const start90 = range90.days[0]?.date ?? addDaysLocal(localMidnight(now), -89);
1568
+
1569
+ onProgress?.({
1570
+ phase: "scan",
1571
+ foundFiles: 0,
1572
+ parsedFiles: 0,
1573
+ totalFiles: 0,
1574
+ currentFile: undefined,
1575
+ });
1576
+ const candidates = await walkSessionFiles(SESSION_ROOT, start90, signal, (found) => {
1577
+ onProgress?.({ phase: "scan", foundFiles: found });
1578
+ });
1579
+
1580
+ onProgress?.({
1581
+ phase: "parse",
1582
+ foundFiles: candidates.length,
1583
+ parsedFiles: 0,
1584
+ totalFiles: candidates.length,
1585
+ currentFile: candidates[0] ? path.basename(candidates[0]) : undefined,
1586
+ });
1587
+
1588
+ const addParsedFile = (parsed: ParsedHistoricalFile): void => {
1589
+ if (parsed.all) {
1590
+ const sessionDay = localMidnight(parsed.all.startedAt);
1591
+ for (const days of RANGE_DAYS) {
1592
+ const range = allRanges.get(days)!;
1593
+ const start = range.days[0]?.date;
1594
+ const end = range.days[range.days.length - 1]?.date;
1595
+ if (!start || !end) continue;
1596
+ if (sessionDay < start || sessionDay > end) continue;
1597
+ addSessionToRange(range, parsed.all);
1598
+ }
1599
+ }
1600
+
1601
+ for (const providerSession of parsed.providers) {
1602
+ historyProviderIds.add(providerSession.provider);
1603
+ let providerRanges = providerRangesById.get(providerSession.provider);
1604
+ if (!providerRanges) {
1605
+ providerRanges = new Map<number, RangeAgg>();
1606
+ for (const days of RANGE_DAYS) providerRanges.set(days, buildRangeAgg(days, now));
1607
+ providerRangesById.set(providerSession.provider, providerRanges);
1608
+ }
1609
+
1610
+ const sessionDay = localMidnight(providerSession.startedAt);
1611
+ for (const days of RANGE_DAYS) {
1612
+ const range = providerRanges.get(days)!;
1613
+ const start = range.days[0]?.date;
1614
+ const end = range.days[range.days.length - 1]?.date;
1615
+ if (!start || !end) continue;
1616
+ if (sessionDay < start || sessionDay > end) continue;
1617
+ addSessionToRange(range, providerSession);
1618
+ }
1619
+ }
1620
+ };
1621
+
1622
+ let parsedFiles = 0;
1623
+ let nextFileIndex = 0;
1624
+ const workerCount = Math.min(FILE_PARSE_CONCURRENCY, candidates.length);
1625
+
1626
+ await Promise.all(
1627
+ Array.from({ length: workerCount }, async () => {
1628
+ while (true) {
1629
+ throwIfAborted(signal);
1630
+
1631
+ const currentIndex = nextFileIndex;
1632
+ nextFileIndex += 1;
1633
+ const filePath = candidates[currentIndex];
1634
+ if (!filePath) return;
1635
+
1636
+ const parsed = await parseHistoricalFile(filePath, signal);
1637
+ addParsedFile(parsed);
1638
+
1639
+ parsedFiles += 1;
1640
+ onProgress?.({
1641
+ phase: "parse",
1642
+ parsedFiles,
1643
+ totalFiles: candidates.length,
1644
+ currentFile: path.basename(filePath),
1645
+ });
1646
+ }
1647
+ }),
1648
+ );
1649
+
1650
+ onProgress?.({ phase: "finalize", currentFile: undefined });
1651
+
1652
+ const allPaletteRange = allRanges.get(30)!;
1653
+ const allPalette: ProviderPalette = {
1654
+ modelPalette: chooseModelPalette(allPaletteRange),
1655
+ cwdPalette: chooseCwdPalette(allPaletteRange),
1656
+ };
1657
+
1658
+ const providerPalettes = new Map<HistoryProviderId, ProviderPalette>();
1659
+ for (const [providerId, ranges] of providerRangesById.entries()) {
1660
+ const range30 = ranges.get(30)!;
1661
+ const provider = getSupportedProvider(providerId);
1662
+ providerPalettes.set(providerId, {
1663
+ modelPalette: chooseModelPalette(
1664
+ range30,
1665
+ 3,
1666
+ provider ? buildProviderModelPalette(provider.color) : PALETTE,
1667
+ ),
1668
+ cwdPalette: chooseCwdPalette(range30),
1669
+ });
1670
+ }
1671
+
1672
+ const liveAvailableProviders = getLiveAvailableProviders(availabilityByProvider);
1673
+
1674
+ return {
1675
+ tabs: buildTabs(historyProviderIds, liveAvailableProviders),
1676
+ liveUsage: buildInitialLiveUsage(SUPPORTED_PROVIDERS, availabilityByProvider),
1677
+ allRanges,
1678
+ providerRangesById,
1679
+ allPalette,
1680
+ providerPalettes,
1681
+ dowPalette: buildDowPalette(),
1682
+ todPalette: buildTodPalette(),
1683
+ };
1684
+ }
1685
+
1686
+ function renderUsageMeterBar(
1687
+ providerId: SupportedProviderId,
1688
+ meter: LiveUsageMeter,
1689
+ width: number,
1690
+ ): string {
1691
+ const barWidth = Math.max(10, Math.min(32, width));
1692
+ const filled = Math.min(barWidth, Math.max(0, Math.round((barWidth * meter.usedPercent) / 100)));
1693
+ const empty = Math.max(0, barWidth - filled);
1694
+ const color = getSupportedProvider(providerId)?.color ?? MUTED_COLOR;
1695
+ const bar = `${ansiFg(color, "█".repeat(filled))}${ansiFg(EMPTY_CELL_BG, "█".repeat(empty))}`;
1696
+ return truncateToWidth(bar, width);
1697
+ }
1698
+
1699
+ function formatUsageHeader(
1700
+ plan: string | undefined,
1701
+ fetchedAt: Date | undefined,
1702
+ ): {
1703
+ title: string;
1704
+ fetched?: string;
1705
+ } {
1706
+ return {
1707
+ title: `Usage${plan ? ` · ${plan}` : ""}`,
1708
+ fetched: fetchedAt ? `fetched ${formatFetchedAt(fetchedAt)}` : undefined,
1709
+ };
1710
+ }
1711
+
1712
+ function renderUsageHeader(
1713
+ plan: string | undefined,
1714
+ fetchedAt: Date | undefined,
1715
+ width: number,
1716
+ ): string {
1717
+ const header = formatUsageHeader(plan, fetchedAt);
1718
+ let text = bold(header.title);
1719
+ if (header.fetched) text += ` ${dim(header.fetched)}`;
1720
+ return truncateToWidth(text, width);
1721
+ }
1722
+
1723
+ type ResolvedLiveUsageState =
1724
+ | { kind: "ok"; snapshot: LiveUsageSnapshot }
1725
+ | { kind: "loading"; message: string }
1726
+ | { kind: "message"; message: string; tone: "muted" | "error" };
1727
+
1728
+ function resolveLiveUsageState(
1729
+ tab: BreakdownTab,
1730
+ liveState: LiveUsageState | undefined,
1731
+ ): ResolvedLiveUsageState {
1732
+ if (!tab.liveProvider) {
1733
+ return { kind: "message", message: "History only · no live quota integration", tone: "muted" };
1734
+ }
1735
+ if (!liveState) {
1736
+ return {
1737
+ kind: "message",
1738
+ message: "Select this tab to load live quota data on demand.",
1739
+ tone: "muted",
1740
+ };
1741
+ }
1742
+ if (liveState.status === "loading") {
1743
+ return { kind: "loading", message: "loading..." };
1744
+ }
1745
+ if (liveState.status === "unavailable") {
1746
+ return { kind: "message", message: `History only · ${liveState.reason}`, tone: "muted" };
1747
+ }
1748
+ if (liveState.status === "error") {
1749
+ return { kind: "message", message: `Usage unavailable · ${liveState.error}`, tone: "error" };
1750
+ }
1751
+ return { kind: "ok", snapshot: liveState.snapshot };
1752
+ }
1753
+
1754
+ function formatLiveUsageItemLine(
1755
+ item: LiveUsageItem,
1756
+ options?: { dimReset?: boolean; emphasizeStatValue?: boolean },
1757
+ ): string {
1758
+ if (item.kind === "meter") {
1759
+ const label = item.windowLabel ? `${item.label} (${item.windowLabel})` : item.label;
1760
+ let text = `${label}: ${formatUsedPercent(item.usedPercent)}`;
1761
+ if (item.detail) text += ` · ${item.detail}`;
1762
+ if (item.resetDescription) {
1763
+ const reset = options?.dimReset ? dim(item.resetDescription) : item.resetDescription;
1764
+ text += ` · ${reset}`;
1765
+ }
1766
+ return text;
1767
+ }
1768
+
1769
+ const value = options?.emphasizeStatValue ? bold(item.value) : item.value;
1770
+ return `${item.label}: ${value}${item.detail ? ` · ${item.detail}` : ""}`;
1771
+ }
1772
+
1773
+ function renderLiveUsageLines(
1774
+ tab: BreakdownTab,
1775
+ liveState: LiveUsageState | undefined,
1776
+ width: number,
1777
+ ): string[] {
1778
+ if (tab.mode === "all") return [];
1779
+
1780
+ const resolved = resolveLiveUsageState(tab, liveState);
1781
+ if (resolved.kind === "loading") {
1782
+ return [truncateToWidth(`${bold("Usage")} ${dim(resolved.message)}`, width)];
1783
+ }
1784
+
1785
+ const lines: string[] = [
1786
+ renderUsageHeader(
1787
+ resolved.kind === "ok" ? resolved.snapshot.plan : undefined,
1788
+ resolved.kind === "ok" ? resolved.snapshot.fetchedAt : undefined,
1789
+ width,
1790
+ ),
1791
+ ];
1792
+ if (resolved.kind === "message") {
1793
+ const message =
1794
+ resolved.tone === "error" ? ansiFg(ERROR_COLOR, resolved.message) : dim(resolved.message);
1795
+ lines.push(truncateToWidth(message, width));
1796
+ return lines;
1797
+ }
1798
+
1799
+ for (const item of resolved.snapshot.items) {
1800
+ lines.push("");
1801
+ lines.push(
1802
+ truncateToWidth(
1803
+ formatLiveUsageItemLine(item, { dimReset: true, emphasizeStatValue: true }),
1804
+ width,
1805
+ ),
1806
+ );
1807
+ if (item.kind === "meter" && tab.liveProvider) {
1808
+ lines.push(renderUsageMeterBar(tab.liveProvider.id, item, width));
1809
+ }
1810
+ }
1811
+
1812
+ return lines;
1813
+ }
1814
+
1815
+ function renderNonInteractiveSummary(data: BreakdownData): string {
1816
+ const lines: string[] = ["Usage"];
1817
+ const allRange = data.allRanges.get(30)!;
1818
+ lines.push(`All providers · ${rangeSummary(allRange, 30, "tokens")}`);
1819
+ lines.push(
1820
+ ` ${historySemanticsText({ id: ALL_TAB_ID, label: "All providers", shortLabel: "all", mode: "all" })}`,
1821
+ );
1822
+
1823
+ for (const tab of data.tabs) {
1824
+ if (tab.mode !== "provider") continue;
1825
+ lines.push("");
1826
+ lines.push(tab.label);
1827
+
1828
+ const liveState = tab.liveProvider ? data.liveUsage.get(tab.liveProvider.id) : undefined;
1829
+ const resolved = resolveLiveUsageState(tab, liveState);
1830
+ if (resolved.kind === "ok") {
1831
+ const header = formatUsageHeader(resolved.snapshot.plan, resolved.snapshot.fetchedAt);
1832
+ lines.push(` ${header.title}${header.fetched ? ` ${header.fetched}` : ""}`);
1833
+ for (const item of resolved.snapshot.items) {
1834
+ lines.push(` ${formatLiveUsageItemLine(item)}`);
1835
+ }
1836
+ } else if (resolved.kind === "loading") {
1837
+ lines.push(` Usage ${resolved.message}`);
1838
+ } else {
1839
+ lines.push(` ${resolved.message}`);
1840
+ }
1841
+
1842
+ const range = data.providerRangesById.get(tab.id)?.get(30);
1843
+ if (range) lines.push(` ${rangeSummary(range, 30, "tokens")}`);
1844
+ lines.push(` ${historySemanticsText(tab)}`);
1845
+ }
1846
+
1847
+ return lines.join("\n");
1848
+ }
1849
+
1850
+ class UsageBreakdownComponent implements Component {
1851
+ private readonly data: BreakdownData;
1852
+ private readonly tui: TUI;
1853
+ private readonly onDone: () => void;
1854
+ private readonly loadLiveUsage: (
1855
+ provider: UsageProviderDefinition,
1856
+ signal?: AbortSignal,
1857
+ ) => Promise<LiveUsageState>;
1858
+ private readonly liveUsageRequests = new Map<SupportedProviderId, Promise<void>>();
1859
+ private readonly liveUsageControllers = new Map<SupportedProviderId, AbortController>();
1860
+ private tabIndex = 0;
1861
+ private rangeIndex = 1;
1862
+ private measurement: MeasurementMode = "tokens";
1863
+ private view: BreakdownView = "model";
1864
+ private cachedWidth?: number;
1865
+ private cachedLines?: string[];
1866
+
1867
+ constructor(
1868
+ data: BreakdownData,
1869
+ tui: TUI,
1870
+ onDone: () => void,
1871
+ loadLiveUsage: (
1872
+ provider: UsageProviderDefinition,
1873
+ signal?: AbortSignal,
1874
+ ) => Promise<LiveUsageState>,
1875
+ ) {
1876
+ this.data = data;
1877
+ this.tui = tui;
1878
+ this.onDone = onDone;
1879
+ this.loadLiveUsage = loadLiveUsage;
1880
+ this.ensureLiveUsageLoaded();
1881
+ }
1882
+
1883
+ private close(): void {
1884
+ for (const controller of this.liveUsageControllers.values()) {
1885
+ controller.abort();
1886
+ }
1887
+ this.liveUsageControllers.clear();
1888
+ this.onDone();
1889
+ }
1890
+
1891
+ private ensureLiveUsageLoaded(): void {
1892
+ const tab = this.data.tabs[this.tabIndex];
1893
+ const provider = tab?.liveProvider;
1894
+ if (!provider) return;
1895
+
1896
+ const current = this.data.liveUsage.get(provider.id);
1897
+ if (current && current.status !== "loading") return;
1898
+ if (this.liveUsageRequests.has(provider.id)) return;
1899
+
1900
+ const controller = new AbortController();
1901
+ this.liveUsageControllers.set(provider.id, controller);
1902
+ this.data.liveUsage.set(provider.id, { status: "loading" });
1903
+ this.invalidate();
1904
+ this.tui.requestRender();
1905
+
1906
+ const request = this.loadLiveUsage(provider, controller.signal)
1907
+ .then((state) => {
1908
+ if (controller.signal.aborted) return;
1909
+ this.data.liveUsage.set(provider.id, state);
1910
+ })
1911
+ .catch((error) => {
1912
+ if (controller.signal.aborted) return;
1913
+ this.data.liveUsage.set(provider.id, {
1914
+ status: "error",
1915
+ error: error instanceof Error ? error.message : String(error),
1916
+ });
1917
+ })
1918
+ .finally(() => {
1919
+ this.liveUsageRequests.delete(provider.id);
1920
+ this.liveUsageControllers.delete(provider.id);
1921
+ if (!controller.signal.aborted) {
1922
+ this.invalidate();
1923
+ this.tui.requestRender();
1924
+ }
1925
+ });
1926
+
1927
+ this.liveUsageRequests.set(provider.id, request);
1928
+ }
1929
+
1930
+ invalidate(): void {
1931
+ this.cachedWidth = undefined;
1932
+ this.cachedLines = undefined;
1933
+ }
1934
+
1935
+ handleInput(data: string): void {
1936
+ if (
1937
+ matchesKey(data, Key.escape) ||
1938
+ matchesKey(data, Key.ctrl("c")) ||
1939
+ data.toLowerCase() === "q"
1940
+ ) {
1941
+ this.close();
1942
+ return;
1943
+ }
1944
+
1945
+ if (
1946
+ matchesKey(data, Key.tab) ||
1947
+ matchesKey(data, Key.shift("tab")) ||
1948
+ data.toLowerCase() === "t"
1949
+ ) {
1950
+ const order: MeasurementMode[] = ["sessions", "messages", "tokens"];
1951
+ const index = Math.max(0, order.indexOf(this.measurement));
1952
+ const direction = matchesKey(data, Key.shift("tab")) ? -1 : 1;
1953
+ this.measurement = order[(index + order.length + direction) % order.length] ?? "tokens";
1954
+ this.invalidate();
1955
+ this.tui.requestRender();
1956
+ return;
1957
+ }
1958
+
1959
+ if (
1960
+ matchesKey(data, Key.left) ||
1961
+ matchesKey(data, Key.right) ||
1962
+ data.toLowerCase() === "h" ||
1963
+ data.toLowerCase() === "l"
1964
+ ) {
1965
+ const direction = matchesKey(data, Key.left) || data.toLowerCase() === "h" ? -1 : 1;
1966
+ this.rangeIndex = (this.rangeIndex + RANGE_DAYS.length + direction) % RANGE_DAYS.length;
1967
+ this.invalidate();
1968
+ this.tui.requestRender();
1969
+ return;
1970
+ }
1971
+
1972
+ if (data === "[" || data === "]" || data.toLowerCase() === "p" || data.toLowerCase() === "n") {
1973
+ const direction = data === "[" || data.toLowerCase() === "p" ? -1 : 1;
1974
+ this.tabIndex = (this.tabIndex + this.data.tabs.length + direction) % this.data.tabs.length;
1975
+ this.ensureLiveUsageLoaded();
1976
+ this.invalidate();
1977
+ this.tui.requestRender();
1978
+ return;
1979
+ }
1980
+
1981
+ if (
1982
+ matchesKey(data, Key.up) ||
1983
+ matchesKey(data, Key.down) ||
1984
+ data.toLowerCase() === "j" ||
1985
+ data.toLowerCase() === "k"
1986
+ ) {
1987
+ const views: BreakdownView[] = ["model", "cwd", "dow", "tod"];
1988
+ const index = views.indexOf(this.view);
1989
+ const direction = matchesKey(data, Key.up) || data.toLowerCase() === "k" ? -1 : 1;
1990
+ this.view = views[(index + views.length + direction) % views.length] ?? "model";
1991
+ this.invalidate();
1992
+ this.tui.requestRender();
1993
+ return;
1994
+ }
1995
+
1996
+ const rangeByKey: Record<string, number> = { "1": 0, "2": 1, "3": 2 };
1997
+ if (Object.hasOwn(rangeByKey, data)) {
1998
+ this.rangeIndex = rangeByKey[data]!;
1999
+ this.invalidate();
2000
+ this.tui.requestRender();
2001
+ }
2002
+ }
2003
+
2004
+ render(width: number): string[] {
2005
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
2006
+
2007
+ const tab = this.data.tabs[this.tabIndex]!;
2008
+ const selectedDays = RANGE_DAYS[this.rangeIndex]!;
2009
+ const range =
2010
+ tab.mode === "all"
2011
+ ? this.data.allRanges.get(selectedDays)!
2012
+ : (this.data.providerRangesById.get(tab.id)?.get(selectedDays) ??
2013
+ getEmptyRange(selectedDays));
2014
+ const palette =
2015
+ tab.mode === "all"
2016
+ ? this.data.allPalette
2017
+ : (this.data.providerPalettes.get(tab.id) ?? {
2018
+ modelPalette: {
2019
+ modelColors: new Map<ModelKey, RGB>(),
2020
+ otherColor: MUTED_COLOR,
2021
+ orderedModels: [],
2022
+ },
2023
+ cwdPalette: {
2024
+ cwdColors: new Map<CwdKey, RGB>(),
2025
+ otherColor: MUTED_COLOR,
2026
+ orderedCwds: [],
2027
+ },
2028
+ });
2029
+ const liveState = tab.liveProvider ? this.data.liveUsage.get(tab.liveProvider.id) : undefined;
2030
+ const metric = graphMetricForRange(range, this.measurement);
2031
+
2032
+ const providerBadge = bold(`[${tab.shortLabel}]`);
2033
+ const rangeTab = (days: number, index: number): string =>
2034
+ index === this.rangeIndex ? bold(`[${days}d]`) : dim(` ${days}d `);
2035
+ const metricTab = (mode: MeasurementMode, label: string): string =>
2036
+ mode === this.measurement ? bold(`[${label}]`) : dim(` ${label} `);
2037
+ const viewTab = (candidate: BreakdownView, label: string): string =>
2038
+ candidate === this.view ? bold(`[${label}]`) : dim(` ${label} `);
2039
+
2040
+ const header =
2041
+ `${bold("Usage")} ${providerBadge}${dim(` ${this.tabIndex + 1}/${this.data.tabs.length}`)} ` +
2042
+ `${rangeTab(7, 0)}${rangeTab(30, 1)}${rangeTab(90, 2)} ` +
2043
+ `${metricTab("sessions", "sess")}${metricTab("messages", "msg")}${metricTab("tokens", "tok")} ` +
2044
+ `${viewTab("model", "model")}${viewTab("cwd", "cwd")}${viewTab("dow", "dow")}${viewTab("tod", "tod")}`;
2045
+
2046
+ const lines: string[] = [];
2047
+ lines.push(truncateToWidth(header, width));
2048
+ lines.push(
2049
+ truncateToWidth(dim("[/] provider · ←/→ range · ↑/↓ view · tab metric · q to close"), width),
2050
+ );
2051
+ lines.push(truncateToWidth(`${bold(tab.label)} ${dim(historySemanticsText(tab))}`, width));
2052
+ lines.push("");
2053
+
2054
+ const liveLines = renderLiveUsageLines(tab, liveState, width);
2055
+ if (liveLines.length > 0) {
2056
+ for (const line of liveLines) lines.push(truncateToWidth(line, width));
2057
+ lines.push("");
2058
+ }
2059
+
2060
+ const graphDescriptor =
2061
+ this.view === "dow" ? `share of ${metric.kind} by weekday` : `${metric.kind}/day`;
2062
+ lines.push(
2063
+ truncateToWidth(
2064
+ `${rangeSummary(range, selectedDays, metric.kind)}${dim(` (graph: ${graphDescriptor})`)}`,
2065
+ width,
2066
+ ),
2067
+ );
2068
+ lines.push("");
2069
+
2070
+ let activeColorMap: Map<string, RGB>;
2071
+ let activeOtherColor = MUTED_COLOR;
2072
+ const legendItems: string[] = [];
2073
+ const stripProvider = tab.mode === "provider";
2074
+
2075
+ if (this.view === "model") {
2076
+ activeColorMap = palette.modelPalette.modelColors;
2077
+ activeOtherColor = palette.modelPalette.otherColor;
2078
+ for (const model of palette.modelPalette.orderedModels) {
2079
+ const color = activeColorMap.get(model);
2080
+ if (color)
2081
+ legendItems.push(`${ansiFg(color, "█")} ${formatModelLabel(model, stripProvider)}`);
2082
+ }
2083
+ legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
2084
+ } else if (this.view === "cwd") {
2085
+ activeColorMap = palette.cwdPalette.cwdColors;
2086
+ activeOtherColor = palette.cwdPalette.otherColor;
2087
+ for (const cwd of palette.cwdPalette.orderedCwds) {
2088
+ const color = activeColorMap.get(cwd);
2089
+ if (color) legendItems.push(`${ansiFg(color, "█")} ${abbreviatePath(cwd, 30)}`);
2090
+ }
2091
+ legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
2092
+ } else if (this.view === "dow") {
2093
+ activeColorMap = this.data.dowPalette.dowColors;
2094
+ for (const dow of this.data.dowPalette.orderedDows) {
2095
+ const color = activeColorMap.get(dow);
2096
+ if (color) legendItems.push(`${ansiFg(color, "█")} ${dow}`);
2097
+ }
2098
+ } else {
2099
+ activeColorMap = this.data.todPalette.todColors;
2100
+ for (const tod of this.data.todPalette.orderedTods) {
2101
+ const color = activeColorMap.get(tod);
2102
+ if (color) legendItems.push(`${ansiFg(color, "█")} ${todBucketLabel(tod)}`);
2103
+ }
2104
+ }
2105
+
2106
+ let graphLines: string[];
2107
+ if (this.view === "dow") {
2108
+ graphLines = renderDowDistributionLines(
2109
+ range,
2110
+ this.measurement,
2111
+ this.data.dowPalette.dowColors,
2112
+ width,
2113
+ );
2114
+ } else {
2115
+ const maxScale = selectedDays === 7 ? 4 : selectedDays === 30 ? 3 : 2;
2116
+ const weeks = weeksForRange(range);
2117
+ const leftMargin = 4;
2118
+ const gap = 1;
2119
+ const graphArea = Math.max(1, width - leftMargin);
2120
+ const idealCellWidth = Math.floor((graphArea + gap) / Math.max(1, weeks)) - gap;
2121
+ const cellWidth = Math.min(maxScale, Math.max(1, idealCellWidth));
2122
+ graphLines = renderGraphLines(
2123
+ range,
2124
+ activeColorMap,
2125
+ activeOtherColor,
2126
+ this.measurement,
2127
+ { cellWidth, gap },
2128
+ this.view,
2129
+ weeks,
2130
+ );
2131
+ }
2132
+
2133
+ const tableLines =
2134
+ this.view === "model"
2135
+ ? renderModelTable(range, metric, 8, { stripProvider })
2136
+ : this.view === "cwd"
2137
+ ? renderCwdTable(range, metric, 8)
2138
+ : this.view === "dow"
2139
+ ? renderDowTable(range, metric)
2140
+ : renderTodTable(range, metric);
2141
+
2142
+ if (this.view === "dow") {
2143
+ for (const line of graphLines) lines.push(truncateToWidth(line, width));
2144
+ } else {
2145
+ const graphWidth = Math.max(0, ...graphLines.map((line) => visibleWidth(line)));
2146
+ const separator = 2;
2147
+ const legendWidth = width - graphWidth - separator;
2148
+ const showSideLegend = legendWidth >= 22;
2149
+
2150
+ if (showSideLegend) {
2151
+ const legendTitle =
2152
+ this.view === "model"
2153
+ ? "Top models (30d palette):"
2154
+ : this.view === "cwd"
2155
+ ? "Top directories (30d palette):"
2156
+ : "Time of day:";
2157
+ const legendBlock = [dim(legendTitle), ...legendItems];
2158
+ const maxLegendRows = graphLines.length;
2159
+ let legendLines = legendBlock.slice(0, maxLegendRows);
2160
+ if (legendBlock.length > maxLegendRows) {
2161
+ const remaining = legendBlock.length - (maxLegendRows - 1);
2162
+ legendLines = [...legendBlock.slice(0, maxLegendRows - 1), dim(`+${remaining} more`)];
2163
+ }
2164
+ while (legendLines.length < graphLines.length) legendLines.push("");
2165
+
2166
+ const padRightAnsi = (value: string, targetWidth: number): string => {
2167
+ const currentWidth = visibleWidth(value);
2168
+ return currentWidth >= targetWidth
2169
+ ? value
2170
+ : value + " ".repeat(targetWidth - currentWidth);
2171
+ };
2172
+
2173
+ for (let index = 0; index < graphLines.length; index += 1) {
2174
+ const left = padRightAnsi(graphLines[index] ?? "", graphWidth);
2175
+ const right = truncateToWidth(legendLines[index] ?? "", Math.max(0, legendWidth));
2176
+ lines.push(truncateToWidth(left + " ".repeat(separator) + right, width));
2177
+ }
2178
+ } else {
2179
+ for (const line of graphLines) lines.push(truncateToWidth(line, width));
2180
+ lines.push("");
2181
+ const legendTitle =
2182
+ this.view === "model"
2183
+ ? "Top models (30d palette):"
2184
+ : this.view === "cwd"
2185
+ ? "Top directories (30d palette):"
2186
+ : "Time of day:";
2187
+ lines.push(truncateToWidth(dim(legendTitle), width));
2188
+ for (const item of legendItems) lines.push(truncateToWidth(item, width));
2189
+ }
2190
+ }
2191
+
2192
+ lines.push("");
2193
+ for (const line of tableLines) lines.push(truncateToWidth(line, width));
2194
+
2195
+ this.cachedWidth = width;
2196
+ this.cachedLines = lines;
2197
+ return this.cachedLines;
2198
+ }
2199
+ }
2200
+
2201
+ export default function usageBreakdownExtension(pi: ExtensionAPI) {
2202
+ pi.registerCommand("usage", {
2203
+ description:
2204
+ "Interactive Pi usage breakdown with an all-providers historical view plus live quota details for supported providers",
2205
+ handler: async (_args, ctx: ExtensionContext) => {
2206
+ const liveUsageAvailability = await getLiveUsageAvailabilityMap(ctx);
2207
+
2208
+ if (!ctx.hasUI) {
2209
+ const [data, liveUsage] = await Promise.all([
2210
+ computeBreakdown(liveUsageAvailability, undefined),
2211
+ fetchLiveUsage(SUPPORTED_PROVIDERS, liveUsageAvailability, ctx, undefined),
2212
+ ]);
2213
+ data.liveUsage = liveUsage;
2214
+ pi.sendMessage(
2215
+ {
2216
+ customType: "usage",
2217
+ content: renderNonInteractiveSummary(data),
2218
+ display: true,
2219
+ },
2220
+ { triggerTurn: false },
2221
+ );
2222
+ return;
2223
+ }
2224
+
2225
+ let aborted = false;
2226
+ const data = await ctx.ui.custom<BreakdownData | null>((tui, theme, _kb, done) => {
2227
+ const loader = new BorderedLoader(tui, theme, "Analyzing usage…");
2228
+ const startedAt = Date.now();
2229
+ const progress: BreakdownProgressState = {
2230
+ phase: "scan",
2231
+ foundFiles: 0,
2232
+ parsedFiles: 0,
2233
+ totalFiles: 0,
2234
+ };
2235
+
2236
+ const renderMessage = (): string => {
2237
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
2238
+ if (progress.phase === "scan") {
2239
+ return `Analyzing usage… scanning (${formatCount(progress.foundFiles)} files) · ${elapsed}s`;
2240
+ }
2241
+ if (progress.phase === "parse") {
2242
+ return `Analyzing usage… parsing (${formatCount(progress.parsedFiles)}/${formatCount(progress.totalFiles)}) · ${elapsed}s`;
2243
+ }
2244
+ return `Analyzing usage… finalizing · ${elapsed}s`;
2245
+ };
2246
+
2247
+ let intervalId: NodeJS.Timeout | null = null;
2248
+ const stopTicker = () => {
2249
+ if (!intervalId) return;
2250
+ clearInterval(intervalId);
2251
+ intervalId = null;
2252
+ };
2253
+
2254
+ setBorderedLoaderMessage(loader, renderMessage());
2255
+ intervalId = setInterval(() => {
2256
+ setBorderedLoaderMessage(loader, renderMessage());
2257
+ }, 500);
2258
+
2259
+ loader.onAbort = () => {
2260
+ aborted = true;
2261
+ stopTicker();
2262
+ done(null);
2263
+ };
2264
+
2265
+ computeBreakdown(liveUsageAvailability, loader.signal, (update) =>
2266
+ Object.assign(progress, update),
2267
+ )
2268
+ .then((result) => {
2269
+ stopTicker();
2270
+ if (!aborted) done(result);
2271
+ })
2272
+ .catch((error) => {
2273
+ stopTicker();
2274
+ if (!aborted) {
2275
+ console.error("usage: failed to analyze usage", error);
2276
+ done(null);
2277
+ }
2278
+ });
2279
+
2280
+ return loader;
2281
+ });
2282
+
2283
+ if (!data) {
2284
+ ctx.ui.notify(
2285
+ aborted ? "Cancelled" : "Failed to analyze usage",
2286
+ aborted ? "info" : "error",
2287
+ );
2288
+ return;
2289
+ }
2290
+
2291
+ const allRange = data.allRanges.get(30)!;
2292
+ const hasHistory =
2293
+ allRange.sessions > 0 || allRange.totalMessages > 0 || allRange.totalTokens > 0;
2294
+ const hasLiveQuotaSources = [...liveUsageAvailability.values()].some(
2295
+ (availability) => availability.available,
2296
+ );
2297
+ if (!hasHistory && !hasLiveQuotaSources) {
2298
+ ctx.ui.notify("No live or historical usage data found.", "warning");
2299
+ return;
2300
+ }
2301
+
2302
+ await ctx.ui.custom<void>(
2303
+ (tui, _theme, _kb, done) =>
2304
+ new UsageBreakdownComponent(data, tui, done, (provider, signal) =>
2305
+ fetchLiveUsageForProvider(provider, liveUsageAvailability, ctx, signal),
2306
+ ),
2307
+ );
2308
+ },
2309
+ });
2310
+ }