@codersbrew/pi-tools 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.
@@ -0,0 +1,1629 @@
1
+ /**
2
+ * /session-breakdown
3
+ *
4
+ * Interactive TUI that analyzes ~/.pi/agent/sessions (recursively, *.jsonl) and shows
5
+ * last 7/30/90 days of:
6
+ * - sessions/day
7
+ * - messages/day
8
+ * - tokens/day (if available)
9
+ * - cost/day (if available)
10
+ * - model breakdown (sessions/messages/tokens + cost)
11
+ *
12
+ * Graph:
13
+ * - GitHub-contributions-style calendar (weeks x weekdays)
14
+ * - Hue: weighted mix of popular model colors (weighted by the selected metric)
15
+ * - Brightness: selected metric per day (log-scaled)
16
+ */
17
+
18
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
19
+ import { BorderedLoader } from "@mariozechner/pi-coding-agent";
20
+ import {
21
+ Key,
22
+ matchesKey,
23
+ type Component,
24
+ type TUI,
25
+ truncateToWidth,
26
+ visibleWidth,
27
+ } from "@mariozechner/pi-tui";
28
+ import { sliceByColumn } from "@mariozechner/pi-tui/dist/utils.js";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import fs from "node:fs/promises";
32
+ import { createReadStream, type Dirent } from "node:fs";
33
+ import readline from "node:readline";
34
+
35
+ type ModelKey = string; // `${provider}/${model}`
36
+ type CwdKey = string; // normalized cwd path
37
+ type DowKey = string; // "Mon", "Tue", etc.
38
+ type TodKey = string; // "after-midnight", "morning", "afternoon", "evening", "night"
39
+ type BreakdownView = "model" | "cwd" | "dow" | "tod";
40
+
41
+ const DOW_NAMES: DowKey[] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
42
+
43
+ const TOD_BUCKETS: { key: TodKey; label: string; from: number; to: number }[] = [
44
+ { key: "after-midnight", label: "After midnight (0–5)", from: 0, to: 5 },
45
+ { key: "morning", label: "Morning (6–11)", from: 6, to: 11 },
46
+ { key: "afternoon", label: "Afternoon (12–16)", from: 12, to: 16 },
47
+ { key: "evening", label: "Evening (17–21)", from: 17, to: 21 },
48
+ { key: "night", label: "Night (22–23)", from: 22, to: 23 },
49
+ ];
50
+
51
+ function todBucketForHour(hour: number): TodKey {
52
+ for (const b of TOD_BUCKETS) {
53
+ if (hour >= b.from && hour <= b.to) return b.key;
54
+ }
55
+ return "after-midnight";
56
+ }
57
+
58
+ function todBucketLabel(key: TodKey): string {
59
+ return TOD_BUCKETS.find((b) => b.key === key)?.label ?? key;
60
+ }
61
+
62
+ interface ParsedSession {
63
+ filePath: string;
64
+ startedAt: Date;
65
+ dayKeyLocal: string; // YYYY-MM-DD (local)
66
+ cwd: CwdKey | null;
67
+ dow: DowKey;
68
+ tod: TodKey;
69
+ modelsUsed: Set<ModelKey>;
70
+ messages: number;
71
+ tokens: number;
72
+ totalCost: number;
73
+ costByModel: Map<ModelKey, number>;
74
+ messagesByModel: Map<ModelKey, number>;
75
+ tokensByModel: Map<ModelKey, number>;
76
+ }
77
+
78
+ interface DayAgg {
79
+ date: Date; // local midnight
80
+ dayKeyLocal: string;
81
+ sessions: number;
82
+ messages: number;
83
+ tokens: number;
84
+ totalCost: number;
85
+ costByModel: Map<ModelKey, number>;
86
+ sessionsByModel: Map<ModelKey, number>;
87
+ messagesByModel: Map<ModelKey, number>;
88
+ tokensByModel: Map<ModelKey, number>;
89
+ sessionsByCwd: Map<CwdKey, number>;
90
+ messagesByCwd: Map<CwdKey, number>;
91
+ tokensByCwd: Map<CwdKey, number>;
92
+ costByCwd: Map<CwdKey, number>;
93
+ sessionsByTod: Map<TodKey, number>;
94
+ messagesByTod: Map<TodKey, number>;
95
+ tokensByTod: Map<TodKey, number>;
96
+ costByTod: Map<TodKey, number>;
97
+ }
98
+
99
+ interface RangeAgg {
100
+ days: DayAgg[];
101
+ dayByKey: Map<string, DayAgg>;
102
+ sessions: number;
103
+ totalMessages: number;
104
+ totalTokens: number;
105
+ totalCost: number;
106
+ modelCost: Map<ModelKey, number>;
107
+ modelSessions: Map<ModelKey, number>; // number of sessions where model was used
108
+ modelMessages: Map<ModelKey, number>;
109
+ modelTokens: Map<ModelKey, number>;
110
+ cwdCost: Map<CwdKey, number>;
111
+ cwdSessions: Map<CwdKey, number>;
112
+ cwdMessages: Map<CwdKey, number>;
113
+ cwdTokens: Map<CwdKey, number>;
114
+ dowCost: Map<DowKey, number>;
115
+ dowSessions: Map<DowKey, number>;
116
+ dowMessages: Map<DowKey, number>;
117
+ dowTokens: Map<DowKey, number>;
118
+ todCost: Map<TodKey, number>;
119
+ todSessions: Map<TodKey, number>;
120
+ todMessages: Map<TodKey, number>;
121
+ todTokens: Map<TodKey, number>;
122
+ }
123
+
124
+ interface RGB {
125
+ r: number;
126
+ g: number;
127
+ b: number;
128
+ }
129
+
130
+ interface BreakdownData {
131
+ generatedAt: Date;
132
+ ranges: Map<number, RangeAgg>;
133
+ palette: {
134
+ modelColors: Map<ModelKey, RGB>;
135
+ otherColor: RGB;
136
+ orderedModels: ModelKey[];
137
+ };
138
+ cwdPalette: {
139
+ cwdColors: Map<CwdKey, RGB>;
140
+ otherColor: RGB;
141
+ orderedCwds: CwdKey[];
142
+ };
143
+ dowPalette: {
144
+ dowColors: Map<DowKey, RGB>;
145
+ orderedDows: DowKey[];
146
+ };
147
+ todPalette: {
148
+ todColors: Map<TodKey, RGB>;
149
+ orderedTods: TodKey[];
150
+ };
151
+ }
152
+
153
+ const SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
154
+ const RANGE_DAYS = [7, 30, 90] as const;
155
+
156
+ type MeasurementMode = "sessions" | "messages" | "tokens";
157
+
158
+ type BreakdownProgressPhase = "scan" | "parse" | "finalize";
159
+
160
+ interface BreakdownProgressState {
161
+ phase: BreakdownProgressPhase;
162
+ foundFiles: number;
163
+ parsedFiles: number;
164
+ totalFiles: number;
165
+ currentFile?: string;
166
+ }
167
+
168
+ function setBorderedLoaderMessage(loader: BorderedLoader, message: string) {
169
+ // BorderedLoader wraps a (Cancellable)Loader which supports setMessage(),
170
+ // but it doesn't expose it publicly. Access the inner loader for progress updates.
171
+ const inner = (loader as any)["loader"]; // eslint-disable-line @typescript-eslint/no-explicit-any
172
+ if (inner && typeof inner.setMessage === "function") {
173
+ inner.setMessage(message);
174
+ }
175
+ }
176
+
177
+ // Dark-ish background and empty cell color (close to GitHub dark)
178
+ const DEFAULT_BG: RGB = { r: 13, g: 17, b: 23 };
179
+ const EMPTY_CELL_BG: RGB = { r: 22, g: 27, b: 34 };
180
+
181
+ // Default palette (assigned to top models)
182
+ const PALETTE: RGB[] = [
183
+ { r: 64, g: 196, b: 99 }, // green
184
+ { r: 47, g: 129, b: 247 }, // blue
185
+ { r: 163, g: 113, b: 247 }, // purple
186
+ { r: 255, g: 159, b: 10 }, // orange
187
+ { r: 244, g: 67, b: 54 }, // red
188
+ ];
189
+
190
+ function clamp01(x: number): number {
191
+ return Math.max(0, Math.min(1, x));
192
+ }
193
+
194
+ function lerp(a: number, b: number, t: number): number {
195
+ return a + (b - a) * t;
196
+ }
197
+
198
+ function mixRgb(a: RGB, b: RGB, t: number): RGB {
199
+ return {
200
+ r: Math.round(lerp(a.r, b.r, t)),
201
+ g: Math.round(lerp(a.g, b.g, t)),
202
+ b: Math.round(lerp(a.b, b.b, t)),
203
+ };
204
+ }
205
+
206
+ function weightedMix(colors: Array<{ color: RGB; weight: number }>): RGB {
207
+ let total = 0;
208
+ let r = 0;
209
+ let g = 0;
210
+ let b = 0;
211
+ for (const c of colors) {
212
+ if (!Number.isFinite(c.weight) || c.weight <= 0) continue;
213
+ total += c.weight;
214
+ r += c.color.r * c.weight;
215
+ g += c.color.g * c.weight;
216
+ b += c.color.b * c.weight;
217
+ }
218
+ if (total <= 0) return EMPTY_CELL_BG;
219
+ return { r: Math.round(r / total), g: Math.round(g / total), b: Math.round(b / total) };
220
+ }
221
+
222
+ function ansiBg(rgb: RGB, text: string): string {
223
+ return `\x1b[48;2;${rgb.r};${rgb.g};${rgb.b}m${text}\x1b[0m`;
224
+ }
225
+
226
+ function ansiFg(rgb: RGB, text: string): string {
227
+ return `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m${text}\x1b[0m`;
228
+ }
229
+
230
+ function dim(text: string): string {
231
+ return `\x1b[2m${text}\x1b[0m`;
232
+ }
233
+
234
+ function bold(text: string): string {
235
+ return `\x1b[1m${text}\x1b[0m`;
236
+ }
237
+
238
+ function formatCount(n: number): string {
239
+ if (!Number.isFinite(n) || n === 0) return "0";
240
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
241
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
242
+ if (n >= 10_000) return `${(n / 1_000).toFixed(1)}K`;
243
+ return n.toLocaleString("en-US");
244
+ }
245
+
246
+ function formatUsd(cost: number): string {
247
+ if (!Number.isFinite(cost)) return "$0.00";
248
+ if (cost >= 1) return `$${cost.toFixed(2)}`;
249
+ if (cost >= 0.1) return `$${cost.toFixed(3)}`;
250
+ return `$${cost.toFixed(4)}`;
251
+ }
252
+
253
+ /**
254
+ * Abbreviate a path for display. Strategy:
255
+ * - Replace home dir with ~
256
+ * - If still too long, keep first segment + last N segments with … in between
257
+ * Examples:
258
+ * /Users/mitsuhiko/Development/agent-stuff → ~/Development/agent-stuff
259
+ * /Users/mitsuhiko/Development/minijinja/minijinja-go → ~/…/minijinja/minijinja-go
260
+ */
261
+ function abbreviatePath(p: string, maxWidth = 40): string {
262
+ const home = os.homedir();
263
+ let display = p;
264
+ if (display.startsWith(home)) {
265
+ display = "~" + display.slice(home.length);
266
+ }
267
+ if (display.length <= maxWidth) return display;
268
+
269
+ const parts = display.split("/").filter(Boolean);
270
+ // Always keep the first part (~ or root indicator) and try to keep as many trailing parts as possible
271
+ if (parts.length <= 2) return display;
272
+
273
+ const prefix = parts[0]; // typically "~"
274
+ // Try keeping last N parts, increasing until it fits
275
+ for (let keep = parts.length - 1; keep >= 1; keep--) {
276
+ const tail = parts.slice(parts.length - keep);
277
+ const candidate = prefix + "/…/" + tail.join("/");
278
+ if (candidate.length <= maxWidth || keep === 1) return candidate;
279
+ }
280
+ return display;
281
+ }
282
+
283
+ function padRight(s: string, n: number): string {
284
+ const delta = n - s.length;
285
+ return delta > 0 ? s + " ".repeat(delta) : s;
286
+ }
287
+
288
+ function padLeft(s: string, n: number): string {
289
+ const delta = n - s.length;
290
+ return delta > 0 ? " ".repeat(delta) + s : s;
291
+ }
292
+
293
+ function toLocalDayKey(d: Date): string {
294
+ const yyyy = d.getFullYear();
295
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
296
+ const dd = String(d.getDate()).padStart(2, "0");
297
+ return `${yyyy}-${mm}-${dd}`;
298
+ }
299
+
300
+ function localMidnight(d: Date): Date {
301
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
302
+ }
303
+
304
+ function addDaysLocal(d: Date, days: number): Date {
305
+ const x = new Date(d);
306
+ x.setDate(x.getDate() + days);
307
+ return x;
308
+ }
309
+
310
+ function countDaysInclusiveLocal(start: Date, end: Date): number {
311
+ // Avoid ms-based day math because DST transitions can make a “day” 23/25h in local time.
312
+ let n = 0;
313
+ for (let d = new Date(start); d <= end; d = addDaysLocal(d, 1)) n++;
314
+ return n;
315
+ }
316
+
317
+ function mondayIndex(date: Date): number {
318
+ // Mon=0 .. Sun=6
319
+ return (date.getDay() + 6) % 7;
320
+ }
321
+
322
+ function modelKeyFromParts(provider?: unknown, model?: unknown): ModelKey | null {
323
+ const p = typeof provider === "string" ? provider.trim() : "";
324
+ const m = typeof model === "string" ? model.trim() : "";
325
+ if (!p && !m) return null;
326
+ if (!p) return m;
327
+ if (!m) return p;
328
+ return `${p}/${m}`;
329
+ }
330
+
331
+ function parseSessionStartFromFilename(name: string): Date | null {
332
+ // Example: 2026-02-02T21-52-28-774Z_<uuid>.jsonl
333
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z_/);
334
+ if (!m) return null;
335
+ const iso = `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`;
336
+ const d = new Date(iso);
337
+ return Number.isFinite(d.getTime()) ? d : null;
338
+ }
339
+
340
+ function extractProviderModelAndUsage(obj: any): { provider?: any; model?: any; modelId?: any; usage?: any } {
341
+ // Session format varies across versions.
342
+ // - Newer: { provider, model, usage } on the message wrapper
343
+ // - Older: { message: { provider, model, usage } }
344
+ const msg = obj?.message;
345
+ return {
346
+ provider: obj?.provider ?? msg?.provider,
347
+ model: obj?.model ?? msg?.model,
348
+ modelId: obj?.modelId ?? msg?.modelId,
349
+ usage: obj?.usage ?? msg?.usage,
350
+ };
351
+ }
352
+
353
+ function extractCostTotal(usage: any): number {
354
+ if (!usage) return 0;
355
+ const c = usage?.cost;
356
+ if (typeof c === "number") return Number.isFinite(c) ? c : 0;
357
+ if (typeof c === "string") {
358
+ const n = Number(c);
359
+ return Number.isFinite(n) ? n : 0;
360
+ }
361
+ const t = c?.total;
362
+ if (typeof t === "number") return Number.isFinite(t) ? t : 0;
363
+ if (typeof t === "string") {
364
+ const n = Number(t);
365
+ return Number.isFinite(n) ? n : 0;
366
+ }
367
+ return 0;
368
+ }
369
+
370
+ function extractTokensTotal(usage: any): number {
371
+ // Usage format varies across providers and pi versions.
372
+ // We try a few common shapes:
373
+ // - { totalTokens }
374
+ // - { total_tokens }
375
+ // - { promptTokens, completionTokens }
376
+ // - { prompt_tokens, completion_tokens }
377
+ // - { input_tokens, output_tokens }
378
+ // - { inputTokens, outputTokens }
379
+ // - { tokens: number | { total } }
380
+ if (!usage) return 0;
381
+
382
+ const readNum = (v: any): number => {
383
+ if (typeof v === "number") return Number.isFinite(v) ? v : 0;
384
+ if (typeof v === "string") {
385
+ const n = Number(v);
386
+ return Number.isFinite(n) ? n : 0;
387
+ }
388
+ return 0;
389
+ };
390
+
391
+ let total = 0;
392
+ // direct totals
393
+ total =
394
+ readNum(usage?.totalTokens) ||
395
+ readNum(usage?.total_tokens) ||
396
+ readNum(usage?.tokens) ||
397
+ readNum(usage?.tokenCount) ||
398
+ readNum(usage?.token_count);
399
+ if (total > 0) return total;
400
+
401
+ // nested tokens object
402
+ total = readNum(usage?.tokens?.total) || readNum(usage?.tokens?.totalTokens) || readNum(usage?.tokens?.total_tokens);
403
+ if (total > 0) return total;
404
+
405
+ // sum of parts
406
+ const a =
407
+ readNum(usage?.promptTokens) ||
408
+ readNum(usage?.prompt_tokens) ||
409
+ readNum(usage?.inputTokens) ||
410
+ readNum(usage?.input_tokens);
411
+ const b =
412
+ readNum(usage?.completionTokens) ||
413
+ readNum(usage?.completion_tokens) ||
414
+ readNum(usage?.outputTokens) ||
415
+ readNum(usage?.output_tokens);
416
+ const sum = a + b;
417
+ return sum > 0 ? sum : 0;
418
+ }
419
+
420
+ async function walkSessionFiles(
421
+ root: string,
422
+ startCutoffLocal: Date,
423
+ signal?: AbortSignal,
424
+ onFound?: (found: number) => void,
425
+ ): Promise<string[]> {
426
+ const out: string[] = [];
427
+ const stack: string[] = [root];
428
+ while (stack.length) {
429
+ if (signal?.aborted) break;
430
+ const dir = stack.pop()!;
431
+ let entries: Dirent[] = [];
432
+ try {
433
+ entries = await fs.readdir(dir, { withFileTypes: true });
434
+ } catch {
435
+ continue;
436
+ }
437
+
438
+ for (const ent of entries) {
439
+ if (signal?.aborted) break;
440
+ const p = path.join(dir, ent.name);
441
+ if (ent.isDirectory()) {
442
+ stack.push(p);
443
+ continue;
444
+ }
445
+ if (!ent.isFile() || !ent.name.endsWith(".jsonl")) continue;
446
+
447
+ // Prefer filename timestamp, else fall back to mtime.
448
+ const startedAt = parseSessionStartFromFilename(ent.name);
449
+ if (startedAt) {
450
+ if (localMidnight(startedAt) >= startCutoffLocal) {
451
+ out.push(p);
452
+ if (onFound && out.length % 10 === 0) onFound(out.length);
453
+ }
454
+ continue;
455
+ }
456
+
457
+ try {
458
+ const st = await fs.stat(p);
459
+ const approx = new Date(st.mtimeMs);
460
+ if (localMidnight(approx) >= startCutoffLocal) {
461
+ out.push(p);
462
+ if (onFound && out.length % 10 === 0) onFound(out.length);
463
+ }
464
+ } catch {
465
+ // ignore
466
+ }
467
+ }
468
+ }
469
+ onFound?.(out.length);
470
+ return out;
471
+ }
472
+
473
+ async function parseSessionFile(filePath: string, signal?: AbortSignal): Promise<ParsedSession | null> {
474
+ const fileName = path.basename(filePath);
475
+ let startedAt = parseSessionStartFromFilename(fileName);
476
+ let currentModel: ModelKey | null = null;
477
+ let cwd: CwdKey | null = null;
478
+
479
+ const modelsUsed = new Set<ModelKey>();
480
+ let messages = 0;
481
+ let tokens = 0;
482
+ let totalCost = 0;
483
+ const costByModel = new Map<ModelKey, number>();
484
+ const messagesByModel = new Map<ModelKey, number>();
485
+ const tokensByModel = new Map<ModelKey, number>();
486
+
487
+ const stream = createReadStream(filePath, { encoding: "utf8" });
488
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
489
+
490
+ try {
491
+ for await (const line of rl) {
492
+ if (signal?.aborted) {
493
+ rl.close();
494
+ stream.destroy();
495
+ return null;
496
+ }
497
+ if (!line) continue;
498
+ let obj: any;
499
+ try {
500
+ obj = JSON.parse(line);
501
+ } catch {
502
+ continue;
503
+ }
504
+
505
+ if (obj?.type === "session") {
506
+ if (!startedAt && typeof obj?.timestamp === "string") {
507
+ const d = new Date(obj.timestamp);
508
+ if (Number.isFinite(d.getTime())) startedAt = d;
509
+ }
510
+ if (typeof obj?.cwd === "string" && obj.cwd.trim()) {
511
+ cwd = obj.cwd.trim();
512
+ }
513
+ continue;
514
+ }
515
+
516
+ if (obj?.type === "model_change") {
517
+ const mk = modelKeyFromParts(obj.provider, obj.modelId);
518
+ if (mk) {
519
+ currentModel = mk;
520
+ modelsUsed.add(mk);
521
+ }
522
+ continue;
523
+ }
524
+
525
+ if (obj?.type !== "message") continue;
526
+
527
+ const { provider, model, modelId, usage } = extractProviderModelAndUsage(obj);
528
+ const mk =
529
+ modelKeyFromParts(provider, model) ??
530
+ modelKeyFromParts(provider, modelId) ??
531
+ currentModel ??
532
+ "unknown";
533
+ modelsUsed.add(mk);
534
+
535
+ messages += 1;
536
+ messagesByModel.set(mk, (messagesByModel.get(mk) ?? 0) + 1);
537
+
538
+ const tok = extractTokensTotal(usage);
539
+ if (tok > 0) {
540
+ tokens += tok;
541
+ tokensByModel.set(mk, (tokensByModel.get(mk) ?? 0) + tok);
542
+ }
543
+
544
+ const cost = extractCostTotal(usage);
545
+ if (cost > 0) {
546
+ totalCost += cost;
547
+ costByModel.set(mk, (costByModel.get(mk) ?? 0) + cost);
548
+ }
549
+ }
550
+ } finally {
551
+ rl.close();
552
+ stream.destroy();
553
+ }
554
+
555
+ if (!startedAt) return null;
556
+ const dayKeyLocal = toLocalDayKey(startedAt);
557
+ const dow = DOW_NAMES[mondayIndex(startedAt)];
558
+ const tod = todBucketForHour(startedAt.getHours());
559
+ return {
560
+ filePath,
561
+ startedAt,
562
+ dayKeyLocal,
563
+ cwd,
564
+ dow,
565
+ tod,
566
+ modelsUsed,
567
+ messages,
568
+ tokens,
569
+ totalCost,
570
+ costByModel,
571
+ messagesByModel,
572
+ tokensByModel,
573
+ };
574
+ }
575
+
576
+ function buildRangeAgg(days: number, now: Date): RangeAgg {
577
+ const end = localMidnight(now);
578
+ const start = addDaysLocal(end, -(days - 1));
579
+ const outDays: DayAgg[] = [];
580
+ const dayByKey = new Map<string, DayAgg>();
581
+
582
+ for (let i = 0; i < days; i++) {
583
+ const d = addDaysLocal(start, i);
584
+ const dayKeyLocal = toLocalDayKey(d);
585
+ const day: DayAgg = {
586
+ date: d,
587
+ dayKeyLocal,
588
+ sessions: 0,
589
+ messages: 0,
590
+ tokens: 0,
591
+ totalCost: 0,
592
+ costByModel: new Map(),
593
+ sessionsByModel: new Map(),
594
+ messagesByModel: new Map(),
595
+ tokensByModel: new Map(),
596
+ sessionsByCwd: new Map(),
597
+ messagesByCwd: new Map(),
598
+ tokensByCwd: new Map(),
599
+ costByCwd: new Map(),
600
+ sessionsByTod: new Map(),
601
+ messagesByTod: new Map(),
602
+ tokensByTod: new Map(),
603
+ costByTod: new Map(),
604
+ };
605
+ outDays.push(day);
606
+ dayByKey.set(dayKeyLocal, day);
607
+ }
608
+
609
+ return {
610
+ days: outDays,
611
+ dayByKey,
612
+ sessions: 0,
613
+ totalMessages: 0,
614
+ totalTokens: 0,
615
+ totalCost: 0,
616
+ modelCost: new Map(),
617
+ modelSessions: new Map(),
618
+ modelMessages: new Map(),
619
+ modelTokens: new Map(),
620
+ cwdCost: new Map(),
621
+ cwdSessions: new Map(),
622
+ cwdMessages: new Map(),
623
+ cwdTokens: new Map(),
624
+ dowCost: new Map(),
625
+ dowSessions: new Map(),
626
+ dowMessages: new Map(),
627
+ dowTokens: new Map(),
628
+ todCost: new Map(),
629
+ todSessions: new Map(),
630
+ todMessages: new Map(),
631
+ todTokens: new Map(),
632
+ };
633
+ }
634
+
635
+ function addSessionToRange(range: RangeAgg, session: ParsedSession): void {
636
+ const day = range.dayByKey.get(session.dayKeyLocal);
637
+ if (!day) return;
638
+
639
+ range.sessions += 1;
640
+ range.totalMessages += session.messages;
641
+ range.totalTokens += session.tokens;
642
+ range.totalCost += session.totalCost;
643
+ day.sessions += 1;
644
+ day.messages += session.messages;
645
+ day.tokens += session.tokens;
646
+ day.totalCost += session.totalCost;
647
+
648
+ // Sessions-per-model (presence)
649
+ for (const mk of session.modelsUsed) {
650
+ day.sessionsByModel.set(mk, (day.sessionsByModel.get(mk) ?? 0) + 1);
651
+ range.modelSessions.set(mk, (range.modelSessions.get(mk) ?? 0) + 1);
652
+ }
653
+
654
+ // Messages-per-model
655
+ for (const [mk, n] of session.messagesByModel.entries()) {
656
+ day.messagesByModel.set(mk, (day.messagesByModel.get(mk) ?? 0) + n);
657
+ range.modelMessages.set(mk, (range.modelMessages.get(mk) ?? 0) + n);
658
+ }
659
+
660
+ // Tokens-per-model
661
+ for (const [mk, n] of session.tokensByModel.entries()) {
662
+ day.tokensByModel.set(mk, (day.tokensByModel.get(mk) ?? 0) + n);
663
+ range.modelTokens.set(mk, (range.modelTokens.get(mk) ?? 0) + n);
664
+ }
665
+
666
+ // Cost-per-model
667
+ for (const [mk, cost] of session.costByModel.entries()) {
668
+ day.costByModel.set(mk, (day.costByModel.get(mk) ?? 0) + cost);
669
+ range.modelCost.set(mk, (range.modelCost.get(mk) ?? 0) + cost);
670
+ }
671
+
672
+ // CWD aggregation
673
+ const cwd = session.cwd;
674
+ if (cwd) {
675
+ day.sessionsByCwd.set(cwd, (day.sessionsByCwd.get(cwd) ?? 0) + 1);
676
+ range.cwdSessions.set(cwd, (range.cwdSessions.get(cwd) ?? 0) + 1);
677
+ day.messagesByCwd.set(cwd, (day.messagesByCwd.get(cwd) ?? 0) + session.messages);
678
+ range.cwdMessages.set(cwd, (range.cwdMessages.get(cwd) ?? 0) + session.messages);
679
+ day.tokensByCwd.set(cwd, (day.tokensByCwd.get(cwd) ?? 0) + session.tokens);
680
+ range.cwdTokens.set(cwd, (range.cwdTokens.get(cwd) ?? 0) + session.tokens);
681
+ day.costByCwd.set(cwd, (day.costByCwd.get(cwd) ?? 0) + session.totalCost);
682
+ range.cwdCost.set(cwd, (range.cwdCost.get(cwd) ?? 0) + session.totalCost);
683
+ }
684
+
685
+ // Day-of-week aggregation
686
+ const dow = session.dow;
687
+ range.dowSessions.set(dow, (range.dowSessions.get(dow) ?? 0) + 1);
688
+ range.dowMessages.set(dow, (range.dowMessages.get(dow) ?? 0) + session.messages);
689
+ range.dowTokens.set(dow, (range.dowTokens.get(dow) ?? 0) + session.tokens);
690
+ range.dowCost.set(dow, (range.dowCost.get(dow) ?? 0) + session.totalCost);
691
+
692
+ // Time-of-day aggregation
693
+ const tod = session.tod;
694
+ day.sessionsByTod.set(tod, (day.sessionsByTod.get(tod) ?? 0) + 1);
695
+ day.messagesByTod.set(tod, (day.messagesByTod.get(tod) ?? 0) + session.messages);
696
+ day.tokensByTod.set(tod, (day.tokensByTod.get(tod) ?? 0) + session.tokens);
697
+ day.costByTod.set(tod, (day.costByTod.get(tod) ?? 0) + session.totalCost);
698
+ range.todSessions.set(tod, (range.todSessions.get(tod) ?? 0) + 1);
699
+ range.todMessages.set(tod, (range.todMessages.get(tod) ?? 0) + session.messages);
700
+ range.todTokens.set(tod, (range.todTokens.get(tod) ?? 0) + session.tokens);
701
+ range.todCost.set(tod, (range.todCost.get(tod) ?? 0) + session.totalCost);
702
+ }
703
+
704
+ function sortMapByValueDesc<K extends string>(m: Map<K, number>): Array<{ key: K; value: number }> {
705
+ return [...m.entries()]
706
+ .map(([key, value]) => ({ key, value }))
707
+ .sort((a, b) => b.value - a.value);
708
+ }
709
+
710
+ function choosePaletteFromLast30Days(range30: RangeAgg, topN = 4): {
711
+ modelColors: Map<ModelKey, RGB>;
712
+ otherColor: RGB;
713
+ orderedModels: ModelKey[];
714
+ } {
715
+ // Prefer cost if any cost exists, else tokens, else messages, else sessions.
716
+ const costSum = [...range30.modelCost.values()].reduce((a, b) => a + b, 0);
717
+ const popularity =
718
+ costSum > 0
719
+ ? range30.modelCost
720
+ : range30.totalTokens > 0
721
+ ? range30.modelTokens
722
+ : range30.totalMessages > 0
723
+ ? range30.modelMessages
724
+ : range30.modelSessions;
725
+
726
+ const sorted = sortMapByValueDesc(popularity);
727
+ const orderedModels = sorted.slice(0, topN).map((x) => x.key);
728
+ const modelColors = new Map<ModelKey, RGB>();
729
+ for (let i = 0; i < orderedModels.length; i++) {
730
+ modelColors.set(orderedModels[i], PALETTE[i % PALETTE.length]);
731
+ }
732
+ return {
733
+ modelColors,
734
+ otherColor: { r: 160, g: 160, b: 160 },
735
+ orderedModels,
736
+ };
737
+ }
738
+
739
+ function chooseCwdPaletteFromLast30Days(range30: RangeAgg, topN = 4): {
740
+ cwdColors: Map<CwdKey, RGB>;
741
+ otherColor: RGB;
742
+ orderedCwds: CwdKey[];
743
+ } {
744
+ const costSum = [...range30.cwdCost.values()].reduce((a, b) => a + b, 0);
745
+ const popularity =
746
+ costSum > 0
747
+ ? range30.cwdCost
748
+ : range30.totalTokens > 0
749
+ ? range30.cwdTokens
750
+ : range30.totalMessages > 0
751
+ ? range30.cwdMessages
752
+ : range30.cwdSessions;
753
+
754
+ const sorted = sortMapByValueDesc(popularity);
755
+ const orderedCwds = sorted.slice(0, topN).map((x) => x.key);
756
+ const cwdColors = new Map<CwdKey, RGB>();
757
+ for (let i = 0; i < orderedCwds.length; i++) {
758
+ cwdColors.set(orderedCwds[i], PALETTE[i % PALETTE.length]);
759
+ }
760
+ return {
761
+ cwdColors,
762
+ otherColor: { r: 160, g: 160, b: 160 },
763
+ orderedCwds,
764
+ };
765
+ }
766
+
767
+ // Fixed palette for day-of-week: weekdays get cool tones, weekend gets warm
768
+ const DOW_PALETTE: RGB[] = [
769
+ { r: 47, g: 129, b: 247 }, // Mon – blue
770
+ { r: 64, g: 196, b: 99 }, // Tue – green
771
+ { r: 163, g: 113, b: 247 }, // Wed – purple
772
+ { r: 47, g: 175, b: 200 }, // Thu – teal
773
+ { r: 100, g: 200, b: 150 }, // Fri – mint
774
+ { r: 255, g: 159, b: 10 }, // Sat – orange
775
+ { r: 244, g: 67, b: 54 }, // Sun – red
776
+ ];
777
+
778
+ function buildDowPalette(): { dowColors: Map<DowKey, RGB>; orderedDows: DowKey[] } {
779
+ const dowColors = new Map<DowKey, RGB>();
780
+ for (let i = 0; i < DOW_NAMES.length; i++) {
781
+ dowColors.set(DOW_NAMES[i], DOW_PALETTE[i]);
782
+ }
783
+ return { dowColors, orderedDows: [...DOW_NAMES] };
784
+ }
785
+
786
+ // Fixed palette for time-of-day buckets
787
+ const TOD_PALETTE: Map<TodKey, RGB> = new Map([
788
+ ["after-midnight", { r: 100, g: 60, b: 180 }], // deep purple
789
+ ["morning", { r: 255, g: 200, b: 50 }], // golden yellow
790
+ ["afternoon", { r: 64, g: 196, b: 99 }], // green
791
+ ["evening", { r: 47, g: 129, b: 247 }], // blue
792
+ ["night", { r: 60, g: 40, b: 140 }], // dark indigo
793
+ ]);
794
+
795
+ function buildTodPalette(): { todColors: Map<TodKey, RGB>; orderedTods: TodKey[] } {
796
+ const todColors = new Map<TodKey, RGB>();
797
+ const orderedTods: TodKey[] = [];
798
+ for (const b of TOD_BUCKETS) {
799
+ const c = TOD_PALETTE.get(b.key);
800
+ if (c) todColors.set(b.key, c);
801
+ orderedTods.push(b.key);
802
+ }
803
+ return { todColors, orderedTods };
804
+ }
805
+
806
+ function dayMixedColor(
807
+ day: DayAgg,
808
+ colorMap: Map<string, RGB>,
809
+ otherColor: RGB,
810
+ mode: MeasurementMode,
811
+ view: BreakdownView = "model",
812
+ ): RGB {
813
+ const parts: Array<{ color: RGB; weight: number }> = [];
814
+ let otherWeight = 0;
815
+
816
+ let map: Map<string, number>;
817
+ if (view === "dow") {
818
+ // For dow, each day IS a single dow – use the dow color directly
819
+ const dowKey = DOW_NAMES[mondayIndex(day.date)];
820
+ const c = colorMap.get(dowKey);
821
+ return c ?? otherColor;
822
+ } else if (view === "tod") {
823
+ if (mode === "tokens") {
824
+ map = day.tokens > 0 ? day.tokensByTod : day.messages > 0 ? day.messagesByTod : day.sessionsByTod;
825
+ } else if (mode === "messages") {
826
+ map = day.messages > 0 ? day.messagesByTod : day.sessionsByTod;
827
+ } else {
828
+ map = day.sessionsByTod;
829
+ }
830
+ } else if (view === "cwd") {
831
+ if (mode === "tokens") {
832
+ map = day.tokens > 0 ? day.tokensByCwd : day.messages > 0 ? day.messagesByCwd : day.sessionsByCwd;
833
+ } else if (mode === "messages") {
834
+ map = day.messages > 0 ? day.messagesByCwd : day.sessionsByCwd;
835
+ } else {
836
+ map = day.sessionsByCwd;
837
+ }
838
+ } else {
839
+ if (mode === "tokens") {
840
+ map = day.tokens > 0 ? day.tokensByModel : day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
841
+ } else if (mode === "messages") {
842
+ map = day.messages > 0 ? day.messagesByModel : day.sessionsByModel;
843
+ } else {
844
+ map = day.sessionsByModel;
845
+ }
846
+ }
847
+
848
+ for (const [mk, w] of map.entries()) {
849
+ const c = colorMap.get(mk);
850
+ if (c) parts.push({ color: c, weight: w });
851
+ else otherWeight += w;
852
+ }
853
+ if (otherWeight > 0) parts.push({ color: otherColor, weight: otherWeight });
854
+ return weightedMix(parts);
855
+ }
856
+
857
+ function graphMetricForRange(
858
+ range: RangeAgg,
859
+ mode: MeasurementMode,
860
+ ): { kind: "sessions" | "messages" | "tokens"; max: number; denom: number } {
861
+ if (mode === "tokens") {
862
+ const maxTokens = Math.max(0, ...range.days.map((d) => d.tokens));
863
+ if (maxTokens > 0) return { kind: "tokens", max: maxTokens, denom: Math.log1p(maxTokens) };
864
+ // fall back if tokens aren't available
865
+ mode = "messages";
866
+ }
867
+
868
+ if (mode === "messages") {
869
+ const maxMessages = Math.max(0, ...range.days.map((d) => d.messages));
870
+ if (maxMessages > 0) return { kind: "messages", max: maxMessages, denom: Math.log1p(maxMessages) };
871
+ // fall back if messages aren't available
872
+ mode = "sessions";
873
+ }
874
+
875
+ const maxSessions = Math.max(0, ...range.days.map((d) => d.sessions));
876
+ return { kind: "sessions", max: maxSessions, denom: Math.log1p(maxSessions) };
877
+ }
878
+
879
+ function weeksForRange(range: RangeAgg): number {
880
+ const days = range.days;
881
+ const start = days[0].date;
882
+ const end = days[days.length - 1].date;
883
+ const gridStart = addDaysLocal(start, -mondayIndex(start));
884
+ const gridEnd = addDaysLocal(end, 6 - mondayIndex(end));
885
+ const totalGridDays = countDaysInclusiveLocal(gridStart, gridEnd);
886
+ return Math.ceil(totalGridDays / 7);
887
+ }
888
+
889
+ function renderGraphLines(
890
+ range: RangeAgg,
891
+ colorMap: Map<string, RGB>,
892
+ otherColor: RGB,
893
+ mode: MeasurementMode,
894
+ options?: { cellWidth?: number; gap?: number },
895
+ view: BreakdownView = "model",
896
+ ): string[] {
897
+ const days = range.days;
898
+ const start = days[0].date;
899
+ const end = days[days.length - 1].date;
900
+
901
+ const gridStart = addDaysLocal(start, -mondayIndex(start));
902
+ const gridEnd = addDaysLocal(end, 6 - mondayIndex(end));
903
+ const totalGridDays = countDaysInclusiveLocal(gridStart, gridEnd);
904
+ const weeks = Math.ceil(totalGridDays / 7);
905
+
906
+ const cellWidth = Math.max(1, Math.floor(options?.cellWidth ?? 1));
907
+ const gap = Math.max(0, Math.floor(options?.gap ?? 1));
908
+ const block = "█".repeat(cellWidth);
909
+ const gapStr = " ".repeat(gap);
910
+
911
+ const metric = graphMetricForRange(range, mode);
912
+ const denom = metric.denom;
913
+
914
+ // Label only Mon/Wed/Fri like GitHub (saves space)
915
+ const labelByRow = new Map<number, string>([
916
+ [0, "Mon"],
917
+ [2, "Wed"],
918
+ [4, "Fri"],
919
+ ]);
920
+
921
+ const lines: string[] = [];
922
+ for (let row = 0; row < 7; row++) {
923
+ const label = labelByRow.get(row);
924
+ let line = label ? padRight(label, 3) + " " : " ";
925
+
926
+ for (let w = 0; w < weeks; w++) {
927
+ const cellDate = addDaysLocal(gridStart, w * 7 + row);
928
+ const inRange = cellDate >= start && cellDate <= end;
929
+ const colGap = w < weeks - 1 ? gapStr : "";
930
+ if (!inRange) {
931
+ line += " ".repeat(cellWidth) + colGap;
932
+ continue;
933
+ }
934
+
935
+ const key = toLocalDayKey(cellDate);
936
+ const day = range.dayByKey.get(key);
937
+ const value =
938
+ metric.kind === "tokens"
939
+ ? (day?.tokens ?? 0)
940
+ : metric.kind === "messages"
941
+ ? (day?.messages ?? 0)
942
+ : (day?.sessions ?? 0);
943
+
944
+ if (!day || value <= 0) {
945
+ line += ansiFg(EMPTY_CELL_BG, block) + colGap;
946
+ continue;
947
+ }
948
+
949
+ const hue = dayMixedColor(day, colorMap, otherColor, mode, view);
950
+ let t = denom > 0 ? Math.log1p(value) / denom : 0;
951
+ t = clamp01(t);
952
+ const minVisible = 0.2;
953
+ const intensity = minVisible + (1 - minVisible) * t;
954
+ const rgb = mixRgb(DEFAULT_BG, hue, intensity);
955
+ line += ansiFg(rgb, block) + colGap;
956
+ }
957
+
958
+ lines.push(line);
959
+ }
960
+
961
+ return lines;
962
+ }
963
+
964
+ function displayModelName(modelKey: string): string {
965
+ const idx = modelKey.indexOf("/");
966
+ return idx === -1 ? modelKey : modelKey.slice(idx + 1);
967
+ }
968
+
969
+ function renderLegendItems(modelColors: Map<ModelKey, RGB>, orderedModels: ModelKey[], otherColor: RGB): string[] {
970
+ const items: string[] = [];
971
+ for (const mk of orderedModels) {
972
+ const c = modelColors.get(mk);
973
+ if (!c) continue;
974
+ items.push(`${ansiFg(c, "█")} ${displayModelName(mk)}`);
975
+ }
976
+ items.push(`${ansiFg(otherColor, "█")} other`);
977
+ return items;
978
+ }
979
+
980
+ function fitRight(text: string, width: number): string {
981
+ if (width <= 0) return "";
982
+ let w = visibleWidth(text);
983
+ let t = text;
984
+ if (w > width) {
985
+ t = sliceByColumn(t, w - width, width, true);
986
+ w = visibleWidth(t);
987
+ }
988
+ return " ".repeat(Math.max(0, width - w)) + t;
989
+ }
990
+
991
+ function renderLegendBlock(leftLabel: string, items: string[], width: number): string[] {
992
+ if (width <= 0) return [];
993
+ if (items.length === 0) return [truncateToWidth(leftLabel, width)];
994
+
995
+ const lines: string[] = [];
996
+ // First line: label on left, first item right-aligned into remaining space.
997
+ const leftW = visibleWidth(leftLabel);
998
+ if (leftW >= width) {
999
+ lines.push(truncateToWidth(leftLabel, width));
1000
+ // Put all items on their own lines right-aligned.
1001
+ for (const it of items) lines.push(fitRight(it, width));
1002
+ return lines;
1003
+ }
1004
+
1005
+ const remaining = Math.max(0, width - leftW);
1006
+ lines.push(leftLabel + fitRight(items[0], remaining));
1007
+
1008
+ for (let i = 1; i < items.length; i++) {
1009
+ lines.push(fitRight(items[i], width));
1010
+ }
1011
+ return lines;
1012
+ }
1013
+
1014
+ function renderModelTable(range: RangeAgg, mode: MeasurementMode, maxRows = 8): string[] {
1015
+ // Keep this relatively narrow: model + selected metric + cost + share.
1016
+ const metric = graphMetricForRange(range, mode);
1017
+ const kind = metric.kind;
1018
+
1019
+ let perModel: Map<ModelKey, number>;
1020
+ let total = 0;
1021
+ let label = kind;
1022
+
1023
+ if (kind === "tokens") {
1024
+ perModel = range.modelTokens;
1025
+ total = range.totalTokens;
1026
+ } else if (kind === "messages") {
1027
+ perModel = range.modelMessages;
1028
+ total = range.totalMessages;
1029
+ } else {
1030
+ perModel = range.modelSessions;
1031
+ total = range.sessions;
1032
+ }
1033
+
1034
+ const sorted = sortMapByValueDesc(perModel);
1035
+ const rows = sorted.slice(0, maxRows);
1036
+
1037
+ const valueWidth = kind === "tokens" ? 10 : 8;
1038
+ const modelWidth = Math.min(52, Math.max("model".length, ...rows.map((r) => r.key.length)));
1039
+
1040
+ const lines: string[] = [];
1041
+ lines.push(`${padRight("model", modelWidth)} ${padLeft(label, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
1042
+ lines.push(`${"-".repeat(modelWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
1043
+
1044
+ for (const r of rows) {
1045
+ const value = perModel.get(r.key) ?? 0;
1046
+ const cost = range.modelCost.get(r.key) ?? 0;
1047
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1048
+ lines.push(
1049
+ `${padRight(r.key.slice(0, modelWidth), modelWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1050
+ );
1051
+ }
1052
+
1053
+ if (sorted.length === 0) {
1054
+ lines.push(dim("(no model data found)"));
1055
+ }
1056
+
1057
+ return lines;
1058
+ }
1059
+
1060
+ function renderCwdTable(range: RangeAgg, mode: MeasurementMode, maxRows = 8): string[] {
1061
+ const metric = graphMetricForRange(range, mode);
1062
+ const kind = metric.kind;
1063
+
1064
+ let perCwd: Map<CwdKey, number>;
1065
+ let total = 0;
1066
+ let label = kind;
1067
+
1068
+ if (kind === "tokens") {
1069
+ perCwd = range.cwdTokens;
1070
+ total = range.totalTokens;
1071
+ } else if (kind === "messages") {
1072
+ perCwd = range.cwdMessages;
1073
+ total = range.totalMessages;
1074
+ } else {
1075
+ perCwd = range.cwdSessions;
1076
+ total = range.sessions;
1077
+ }
1078
+
1079
+ const sorted = sortMapByValueDesc(perCwd);
1080
+ const rows = sorted.slice(0, maxRows);
1081
+
1082
+ const valueWidth = kind === "tokens" ? 10 : 8;
1083
+ const displayPaths = rows.map((r) => abbreviatePath(r.key, 40));
1084
+ const cwdWidth = Math.min(42, Math.max("directory".length, ...displayPaths.map((p) => p.length)));
1085
+
1086
+ const lines: string[] = [];
1087
+ lines.push(`${padRight("directory", cwdWidth)} ${padLeft(label, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
1088
+ lines.push(`${"-".repeat(cwdWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
1089
+
1090
+ for (let i = 0; i < rows.length; i++) {
1091
+ const r = rows[i];
1092
+ const value = perCwd.get(r.key) ?? 0;
1093
+ const cost = range.cwdCost.get(r.key) ?? 0;
1094
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1095
+ lines.push(
1096
+ `${padRight(displayPaths[i].slice(0, cwdWidth), cwdWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1097
+ );
1098
+ }
1099
+
1100
+ if (sorted.length === 0) {
1101
+ lines.push(dim("(no directory data found)"));
1102
+ }
1103
+
1104
+ return lines;
1105
+ }
1106
+
1107
+ function dowMetricForRange(
1108
+ range: RangeAgg,
1109
+ mode: MeasurementMode,
1110
+ ): { kind: "sessions" | "messages" | "tokens"; perDow: Map<DowKey, number>; total: number } {
1111
+ const metric = graphMetricForRange(range, mode);
1112
+ const kind = metric.kind;
1113
+
1114
+ if (kind === "tokens") {
1115
+ return { kind, perDow: range.dowTokens, total: range.totalTokens };
1116
+ }
1117
+ if (kind === "messages") {
1118
+ return { kind, perDow: range.dowMessages, total: range.totalMessages };
1119
+ }
1120
+ return { kind, perDow: range.dowSessions, total: range.sessions };
1121
+ }
1122
+
1123
+ function renderDowDistributionLines(
1124
+ range: RangeAgg,
1125
+ mode: MeasurementMode,
1126
+ dowColors: Map<DowKey, RGB>,
1127
+ width: number,
1128
+ ): string[] {
1129
+ const { kind, perDow, total } = dowMetricForRange(range, mode);
1130
+ const dayWidth = 3;
1131
+ const pctWidth = 4; // "100%"
1132
+ const valueWidth = kind === "tokens" ? 10 : 8;
1133
+ const showValue = width >= dayWidth + 1 + 10 + 1 + pctWidth + 1 + valueWidth;
1134
+ const fixedWidth = dayWidth + 1 + 1 + pctWidth + (showValue ? 1 + valueWidth : 0);
1135
+ const barWidth = Math.max(1, width - fixedWidth);
1136
+ const fallbackColor: RGB = { r: 160, g: 160, b: 160 };
1137
+
1138
+ const lines: string[] = [];
1139
+ for (const dow of DOW_NAMES) {
1140
+ const value = perDow.get(dow) ?? 0;
1141
+ const share = total > 0 ? value / total : 0;
1142
+ let filled = share > 0 ? Math.round(share * barWidth) : 0;
1143
+ if (share > 0) filled = Math.max(1, filled);
1144
+ filled = Math.min(barWidth, filled);
1145
+ const empty = Math.max(0, barWidth - filled);
1146
+
1147
+ const color = dowColors.get(dow) ?? fallbackColor;
1148
+ const filledBar = filled > 0 ? ansiFg(color, "█".repeat(filled)) : "";
1149
+ const emptyBar = empty > 0 ? ansiFg(EMPTY_CELL_BG, "█".repeat(empty)) : "";
1150
+ const pct = padLeft(`${Math.round(share * 100)}%`, pctWidth);
1151
+
1152
+ let line = `${padRight(dow, dayWidth)} ${filledBar}${emptyBar} ${pct}`;
1153
+ if (showValue) line += ` ${padLeft(formatCount(value), valueWidth)}`;
1154
+ lines.push(line);
1155
+ }
1156
+
1157
+ return lines;
1158
+ }
1159
+
1160
+ function renderDowTable(range: RangeAgg, mode: MeasurementMode): string[] {
1161
+ const { kind, perDow, total } = dowMetricForRange(range, mode);
1162
+ const valueWidth = kind === "tokens" ? 10 : 8;
1163
+ const dowWidth = 5; // "day "
1164
+
1165
+ const lines: string[] = [];
1166
+ lines.push(`${padRight("day", dowWidth)} ${padLeft(kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
1167
+ lines.push(`${"-".repeat(dowWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
1168
+
1169
+ // Always show in Mon–Sun order
1170
+ for (const dow of DOW_NAMES) {
1171
+ const value = perDow.get(dow) ?? 0;
1172
+ const cost = range.dowCost.get(dow) ?? 0;
1173
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1174
+ lines.push(
1175
+ `${padRight(dow, dowWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1176
+ );
1177
+ }
1178
+
1179
+ return lines;
1180
+ }
1181
+
1182
+ function renderTodTable(range: RangeAgg, mode: MeasurementMode): string[] {
1183
+ const metric = graphMetricForRange(range, mode);
1184
+ const kind = metric.kind;
1185
+
1186
+ let perTod: Map<TodKey, number>;
1187
+ let total = 0;
1188
+
1189
+ if (kind === "tokens") {
1190
+ perTod = range.todTokens;
1191
+ total = range.totalTokens;
1192
+ } else if (kind === "messages") {
1193
+ perTod = range.todMessages;
1194
+ total = range.totalMessages;
1195
+ } else {
1196
+ perTod = range.todSessions;
1197
+ total = range.sessions;
1198
+ }
1199
+
1200
+ const valueWidth = kind === "tokens" ? 10 : 8;
1201
+ const todWidth = 22; // widest label
1202
+
1203
+ const lines: string[] = [];
1204
+ lines.push(`${padRight("time of day", todWidth)} ${padLeft(kind, valueWidth)} ${padLeft("cost", 10)} ${padLeft("share", 6)}`);
1205
+ lines.push(`${"-".repeat(todWidth)} ${"-".repeat(valueWidth)} ${"-".repeat(10)} ${"-".repeat(6)}`);
1206
+
1207
+ // Always show in chronological order
1208
+ for (const b of TOD_BUCKETS) {
1209
+ const value = perTod.get(b.key) ?? 0;
1210
+ const cost = range.todCost.get(b.key) ?? 0;
1211
+ const share = total > 0 ? `${Math.round((value / total) * 100)}%` : "0%";
1212
+ lines.push(
1213
+ `${padRight(b.label, todWidth)} ${padLeft(formatCount(value), valueWidth)} ${padLeft(formatUsd(cost), 10)} ${padLeft(share, 6)}`,
1214
+ );
1215
+ }
1216
+
1217
+ return lines;
1218
+ }
1219
+
1220
+ function renderLeftRight(left: string, right: string, width: number): string {
1221
+ const leftW = visibleWidth(left);
1222
+ if (width <= 0) return "";
1223
+ if (leftW >= width) return truncateToWidth(left, width);
1224
+
1225
+ const remaining = width - leftW;
1226
+ let rightText = right;
1227
+ const rightW = visibleWidth(rightText);
1228
+ if (rightW > remaining) {
1229
+ // Keep the *rightmost* part visible.
1230
+ rightText = sliceByColumn(rightText, rightW - remaining, remaining, true);
1231
+ }
1232
+ const pad = Math.max(0, remaining - visibleWidth(rightText));
1233
+ return left + " ".repeat(pad) + rightText;
1234
+ }
1235
+
1236
+ function rangeSummary(range: RangeAgg, days: number, mode: MeasurementMode): string {
1237
+ const avg = range.sessions > 0 ? range.totalCost / range.sessions : 0;
1238
+ const costPart = range.totalCost > 0 ? `${formatUsd(range.totalCost)} · avg ${formatUsd(avg)}/session` : `$0.0000`;
1239
+
1240
+ if (mode === "tokens") {
1241
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalTokens)} tokens · ${costPart}`;
1242
+ }
1243
+ if (mode === "messages") {
1244
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalMessages)} messages · ${costPart}`;
1245
+ }
1246
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${costPart}`;
1247
+ }
1248
+
1249
+ async function computeBreakdown(
1250
+ signal?: AbortSignal,
1251
+ onProgress?: (update: Partial<BreakdownProgressState>) => void,
1252
+ ): Promise<BreakdownData> {
1253
+ const now = new Date();
1254
+ const ranges = new Map<number, RangeAgg>();
1255
+ for (const d of RANGE_DAYS) ranges.set(d, buildRangeAgg(d, now));
1256
+ const range90 = ranges.get(90)!;
1257
+ const start90 = range90.days[0].date;
1258
+
1259
+ onProgress?.({ phase: "scan", foundFiles: 0, parsedFiles: 0, totalFiles: 0, currentFile: undefined });
1260
+
1261
+ const candidates = await walkSessionFiles(SESSION_ROOT, start90, signal, (found) => {
1262
+ onProgress?.({ phase: "scan", foundFiles: found });
1263
+ });
1264
+
1265
+ const totalFiles = candidates.length;
1266
+ onProgress?.({
1267
+ phase: "parse",
1268
+ foundFiles: totalFiles,
1269
+ totalFiles,
1270
+ parsedFiles: 0,
1271
+ currentFile: totalFiles > 0 ? path.basename(candidates[0]!) : undefined,
1272
+ });
1273
+
1274
+ let parsedFiles = 0;
1275
+ for (const filePath of candidates) {
1276
+ if (signal?.aborted) break;
1277
+ parsedFiles += 1;
1278
+ onProgress?.({ phase: "parse", parsedFiles, totalFiles, currentFile: path.basename(filePath) });
1279
+
1280
+ const session = await parseSessionFile(filePath, signal);
1281
+ if (!session) continue;
1282
+
1283
+ const sessionDay = localMidnight(session.startedAt);
1284
+ for (const d of RANGE_DAYS) {
1285
+ const range = ranges.get(d)!;
1286
+ const start = range.days[0].date;
1287
+ const end = range.days[range.days.length - 1].date;
1288
+ if (sessionDay < start || sessionDay > end) continue;
1289
+ addSessionToRange(range, session);
1290
+ }
1291
+ }
1292
+
1293
+ onProgress?.({ phase: "finalize", currentFile: undefined });
1294
+
1295
+ const palette = choosePaletteFromLast30Days(ranges.get(30)!, 4);
1296
+ const cwdPalette = chooseCwdPaletteFromLast30Days(ranges.get(30)!, 4);
1297
+ const dowPalette = buildDowPalette();
1298
+ const todPalette = buildTodPalette();
1299
+ return { generatedAt: now, ranges, palette, cwdPalette, dowPalette, todPalette };
1300
+ }
1301
+
1302
+ class BreakdownComponent implements Component {
1303
+ private data: BreakdownData;
1304
+ private tui: TUI;
1305
+ private onDone: () => void;
1306
+ private rangeIndex = 1; // default 30d
1307
+ private measurement: MeasurementMode = "sessions";
1308
+ private view: BreakdownView = "model";
1309
+ private cachedWidth?: number;
1310
+ private cachedLines?: string[];
1311
+
1312
+ constructor(data: BreakdownData, tui: TUI, onDone: () => void) {
1313
+ this.data = data;
1314
+ this.tui = tui;
1315
+ this.onDone = onDone;
1316
+ }
1317
+
1318
+ invalidate(): void {
1319
+ this.cachedWidth = undefined;
1320
+ this.cachedLines = undefined;
1321
+ }
1322
+
1323
+ handleInput(data: string): void {
1324
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data.toLowerCase() === "q") {
1325
+ this.onDone();
1326
+ return;
1327
+ }
1328
+
1329
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")) || data.toLowerCase() === "t") {
1330
+ const order: MeasurementMode[] = ["sessions", "messages", "tokens"];
1331
+ const idx = Math.max(0, order.indexOf(this.measurement));
1332
+ const dir = matchesKey(data, Key.shift("tab")) ? -1 : 1;
1333
+ this.measurement = order[(idx + order.length + dir) % order.length] ?? "sessions";
1334
+ this.invalidate();
1335
+ this.tui.requestRender();
1336
+ return;
1337
+ }
1338
+
1339
+ const prev = () => {
1340
+ this.rangeIndex = (this.rangeIndex + RANGE_DAYS.length - 1) % RANGE_DAYS.length;
1341
+ this.invalidate();
1342
+ this.tui.requestRender();
1343
+ };
1344
+ const next = () => {
1345
+ this.rangeIndex = (this.rangeIndex + 1) % RANGE_DAYS.length;
1346
+ this.invalidate();
1347
+ this.tui.requestRender();
1348
+ };
1349
+
1350
+ if (matchesKey(data, Key.left) || data.toLowerCase() === "h") prev();
1351
+ if (matchesKey(data, Key.right) || data.toLowerCase() === "l") next();
1352
+
1353
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down) || data.toLowerCase() === "j" || data.toLowerCase() === "k") {
1354
+ const views: BreakdownView[] = ["model", "cwd", "dow", "tod"];
1355
+ const idx = views.indexOf(this.view);
1356
+ const dir = matchesKey(data, Key.up) || data.toLowerCase() === "k" ? -1 : 1;
1357
+ this.view = views[(idx + views.length + dir) % views.length] ?? "model";
1358
+ this.invalidate();
1359
+ this.tui.requestRender();
1360
+ return;
1361
+ }
1362
+
1363
+ if (data === "1") {
1364
+ this.rangeIndex = 0;
1365
+ this.invalidate();
1366
+ this.tui.requestRender();
1367
+ }
1368
+ if (data === "2") {
1369
+ this.rangeIndex = 1;
1370
+ this.invalidate();
1371
+ this.tui.requestRender();
1372
+ }
1373
+ if (data === "3") {
1374
+ this.rangeIndex = 2;
1375
+ this.invalidate();
1376
+ this.tui.requestRender();
1377
+ }
1378
+ }
1379
+
1380
+ render(width: number): string[] {
1381
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
1382
+
1383
+ const selectedDays = RANGE_DAYS[this.rangeIndex];
1384
+ const range = this.data.ranges.get(selectedDays)!;
1385
+ const metric = graphMetricForRange(range, this.measurement);
1386
+
1387
+ const tab = (days: number, idx: number): string => {
1388
+ const selected = idx === this.rangeIndex;
1389
+ const label = `${days}d`;
1390
+ return selected ? bold(`[${label}]`) : dim(` ${label} `);
1391
+ };
1392
+
1393
+ const metricTab = (mode: MeasurementMode, label: string): string => {
1394
+ const selected = mode === this.measurement;
1395
+ return selected ? bold(`[${label}]`) : dim(` ${label} `);
1396
+ };
1397
+
1398
+ const viewTab = (v: BreakdownView, label: string): string => {
1399
+ const selected = v === this.view;
1400
+ return selected ? bold(`[${label}]`) : dim(` ${label} `);
1401
+ };
1402
+
1403
+ const header =
1404
+ `${bold("Session breakdown")} ${tab(7, 0)}${tab(30, 1)}${tab(90, 2)} ` +
1405
+ `${metricTab("sessions", "sess")}${metricTab("messages", "msg")}${metricTab("tokens", "tok")} ` +
1406
+ `${viewTab("model", "model")}${viewTab("cwd", "cwd")}${viewTab("dow", "dow")}${viewTab("tod", "tod")}`;
1407
+
1408
+ // Choose colors and legend based on current view
1409
+ let activeColorMap: Map<string, RGB>;
1410
+ let activeOtherColor: RGB = { r: 160, g: 160, b: 160 };
1411
+ const legendItems: string[] = [];
1412
+
1413
+ if (this.view === "model") {
1414
+ activeColorMap = this.data.palette.modelColors;
1415
+ activeOtherColor = this.data.palette.otherColor;
1416
+ for (const mk of this.data.palette.orderedModels) {
1417
+ const c = activeColorMap.get(mk);
1418
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${displayModelName(mk)}`);
1419
+ }
1420
+ legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
1421
+ } else if (this.view === "cwd") {
1422
+ activeColorMap = this.data.cwdPalette.cwdColors;
1423
+ activeOtherColor = this.data.cwdPalette.otherColor;
1424
+ for (const cwd of this.data.cwdPalette.orderedCwds) {
1425
+ const c = activeColorMap.get(cwd);
1426
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${abbreviatePath(cwd, 30)}`);
1427
+ }
1428
+ legendItems.push(`${ansiFg(activeOtherColor, "█")} other`);
1429
+ } else if (this.view === "dow") {
1430
+ activeColorMap = this.data.dowPalette.dowColors;
1431
+ for (const dow of this.data.dowPalette.orderedDows) {
1432
+ const c = activeColorMap.get(dow);
1433
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${dow}`);
1434
+ }
1435
+ } else {
1436
+ activeColorMap = this.data.todPalette.todColors;
1437
+ for (const tod of this.data.todPalette.orderedTods) {
1438
+ const c = activeColorMap.get(tod);
1439
+ if (c) legendItems.push(`${ansiFg(c, "█")} ${todBucketLabel(tod)}`);
1440
+ }
1441
+ }
1442
+
1443
+ const graphDescriptor = this.view === "dow" ? `share of ${metric.kind} by weekday` : `${metric.kind}/day`;
1444
+ const summary = rangeSummary(range, selectedDays, metric.kind) + dim(` (graph: ${graphDescriptor})`);
1445
+
1446
+ let graphLines: string[];
1447
+ if (this.view === "dow") {
1448
+ graphLines = renderDowDistributionLines(range, this.measurement, this.data.dowPalette.dowColors, width);
1449
+ } else {
1450
+ const maxScale = selectedDays === 7 ? 4 : selectedDays === 30 ? 3 : 2;
1451
+ const weeks = weeksForRange(range);
1452
+ const leftMargin = 4; // "Mon " (or 4 spaces)
1453
+ const gap = 1;
1454
+ const graphArea = Math.max(1, width - leftMargin);
1455
+ // Each week column uses: cellWidth + gap. Last column also gets gap (fine; we truncate anyway).
1456
+ const idealCellWidth = Math.floor((graphArea + gap) / Math.max(1, weeks)) - gap;
1457
+ const cellWidth = Math.min(maxScale, Math.max(1, idealCellWidth));
1458
+
1459
+ graphLines = renderGraphLines(
1460
+ range,
1461
+ activeColorMap,
1462
+ activeOtherColor,
1463
+ this.measurement,
1464
+ { cellWidth, gap },
1465
+ this.view,
1466
+ );
1467
+ }
1468
+ const tableLines =
1469
+ this.view === "model" ? renderModelTable(range, metric.kind, 8)
1470
+ : this.view === "cwd" ? renderCwdTable(range, metric.kind, 8)
1471
+ : this.view === "dow" ? renderDowTable(range, metric.kind)
1472
+ : renderTodTable(range, metric.kind);
1473
+
1474
+ const lines: string[] = [];
1475
+ lines.push(truncateToWidth(header, width));
1476
+ lines.push(truncateToWidth(dim("←/→ range · ↑/↓ view · tab metric · q to close"), width));
1477
+ lines.push("");
1478
+ lines.push(truncateToWidth(summary, width));
1479
+ lines.push("");
1480
+
1481
+ if (this.view === "dow") {
1482
+ for (const gl of graphLines) lines.push(truncateToWidth(gl, width));
1483
+ } else {
1484
+ // Render legend on the RIGHT of the graph if there is space.
1485
+ const graphWidth = Math.max(0, ...graphLines.map((l) => visibleWidth(l)));
1486
+ const sep = 2;
1487
+ const legendWidth = width - graphWidth - sep;
1488
+ const showSideLegend = legendWidth >= 22;
1489
+
1490
+ if (showSideLegend) {
1491
+ const legendBlock: string[] = [];
1492
+ const legendTitle =
1493
+ this.view === "model" ? "Top models (30d palette):"
1494
+ : this.view === "cwd" ? "Top directories (30d palette):"
1495
+ : "Time of day:";
1496
+ legendBlock.push(dim(legendTitle));
1497
+ legendBlock.push(...legendItems);
1498
+ // Fit into 7 rows (same as graph). If too many, show a final "+N more" line.
1499
+ const maxLegendRows = graphLines.length;
1500
+ let legendLines = legendBlock.slice(0, maxLegendRows);
1501
+ if (legendBlock.length > maxLegendRows) {
1502
+ const remaining = legendBlock.length - (maxLegendRows - 1);
1503
+ legendLines = [...legendBlock.slice(0, maxLegendRows - 1), dim(`+${remaining} more`)];
1504
+ }
1505
+ while (legendLines.length < graphLines.length) legendLines.push("");
1506
+
1507
+ const padRightAnsi = (s: string, target: number): string => {
1508
+ const w = visibleWidth(s);
1509
+ return w >= target ? s : s + " ".repeat(target - w);
1510
+ };
1511
+
1512
+ for (let i = 0; i < graphLines.length; i++) {
1513
+ const left = padRightAnsi(graphLines[i] ?? "", graphWidth);
1514
+ const right = truncateToWidth(legendLines[i] ?? "", Math.max(0, legendWidth));
1515
+ lines.push(truncateToWidth(left + " ".repeat(sep) + right, width));
1516
+ }
1517
+ } else {
1518
+ // Fallback: graph only (legend will be shown below).
1519
+ for (const gl of graphLines) lines.push(truncateToWidth(gl, width));
1520
+ lines.push("");
1521
+ // Compact legend below, left-aligned.
1522
+ const legendTitleBelow =
1523
+ this.view === "model" ? "Top models (30d palette):"
1524
+ : this.view === "cwd" ? "Top directories (30d palette):"
1525
+ : "Time of day:";
1526
+ lines.push(truncateToWidth(dim(legendTitleBelow), width));
1527
+ for (const it of legendItems) lines.push(truncateToWidth(it, width));
1528
+ }
1529
+ }
1530
+
1531
+ lines.push("");
1532
+ for (const tl of tableLines) lines.push(truncateToWidth(tl, width));
1533
+
1534
+ // Ensure no overly long lines (truncateToWidth already), but keep at least 1 line.
1535
+ this.cachedWidth = width;
1536
+ this.cachedLines = lines.map((l) => (visibleWidth(l) > width ? truncateToWidth(l, width) : l));
1537
+ return this.cachedLines;
1538
+ }
1539
+ }
1540
+
1541
+ export default function sessionBreakdownExtension(pi: ExtensionAPI) {
1542
+ pi.registerCommand("session-breakdown", {
1543
+ description: "Interactive breakdown of last 7/30/90 days of ~/.pi session usage (sessions/messages/tokens + cost by model)",
1544
+ handler: async (_args, ctx: ExtensionContext) => {
1545
+ if (!ctx.hasUI) {
1546
+ // Non-interactive fallback: just notify.
1547
+ const data = await computeBreakdown(undefined);
1548
+ const range = data.ranges.get(30)!;
1549
+ pi.sendMessage(
1550
+ {
1551
+ customType: "session-breakdown",
1552
+ content: `Session breakdown (non-interactive)\n${rangeSummary(range, 30, "sessions")}`,
1553
+ display: true,
1554
+ },
1555
+ { triggerTurn: false },
1556
+ );
1557
+ return;
1558
+ }
1559
+
1560
+ let aborted = false;
1561
+ const data = await ctx.ui.custom<BreakdownData | null>((tui, theme, _kb, done) => {
1562
+ const baseMessage = "Analyzing sessions (last 90 days)…";
1563
+ const loader = new BorderedLoader(tui, theme, baseMessage);
1564
+
1565
+ const startedAt = Date.now();
1566
+ const progress: BreakdownProgressState = {
1567
+ phase: "scan",
1568
+ foundFiles: 0,
1569
+ parsedFiles: 0,
1570
+ totalFiles: 0,
1571
+ currentFile: undefined,
1572
+ };
1573
+
1574
+ const renderMessage = (): string => {
1575
+ const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
1576
+ if (progress.phase === "scan") {
1577
+ return `${baseMessage} scanning (${formatCount(progress.foundFiles)} files) · ${elapsed}s`;
1578
+ }
1579
+ if (progress.phase === "parse") {
1580
+ return `${baseMessage} parsing (${formatCount(progress.parsedFiles)}/${formatCount(progress.totalFiles)}) · ${elapsed}s`;
1581
+ }
1582
+ return `${baseMessage} finalizing · ${elapsed}s`;
1583
+ };
1584
+
1585
+ let intervalId: NodeJS.Timeout | null = null;
1586
+ const stopTicker = () => {
1587
+ if (intervalId) {
1588
+ clearInterval(intervalId);
1589
+ intervalId = null;
1590
+ }
1591
+ };
1592
+
1593
+ // Update every 0.5s so long-running scans show some visible progress.
1594
+ setBorderedLoaderMessage(loader, renderMessage());
1595
+ intervalId = setInterval(() => {
1596
+ setBorderedLoaderMessage(loader, renderMessage());
1597
+ }, 500);
1598
+
1599
+ loader.onAbort = () => {
1600
+ aborted = true;
1601
+ stopTicker();
1602
+ done(null);
1603
+ };
1604
+
1605
+ computeBreakdown(loader.signal, (update) => Object.assign(progress, update))
1606
+ .then((d) => {
1607
+ stopTicker();
1608
+ if (!aborted) done(d);
1609
+ })
1610
+ .catch((err) => {
1611
+ stopTicker();
1612
+ console.error("session-breakdown: failed to analyze sessions", err);
1613
+ if (!aborted) done(null);
1614
+ });
1615
+
1616
+ return loader;
1617
+ });
1618
+
1619
+ if (!data) {
1620
+ ctx.ui.notify(aborted ? "Cancelled" : "Failed to analyze sessions", aborted ? "info" : "error");
1621
+ return;
1622
+ }
1623
+
1624
+ await ctx.ui.custom<void>((tui, _theme, _kb, done) => {
1625
+ return new BreakdownComponent(data, tui, done);
1626
+ });
1627
+ },
1628
+ });
1629
+ }