@ohgodtamit/pi-usage 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/view.ts ADDED
@@ -0,0 +1,2585 @@
1
+ /**
2
+ * Interactive usage panel TUI component.
3
+ *
4
+ * Rendered via ctx.ui.custom(). Mirrors Claude Code's `/usage` screen:
5
+ * always-visible 5-hour and weekly quota bars, a selectable time window, and
6
+ * independent-characteristic breakdowns by model / skill / plugin / tool /
7
+ * project. Supports vertical scrolling for small terminals.
8
+ */
9
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
10
+ import type { TUI } from "@earendil-works/pi-tui";
11
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+ import {
13
+ type AttributionMaps,
14
+ agentStats,
15
+ agentTopModel,
16
+ availableYears,
17
+ type Bucket,
18
+ bucketTokens,
19
+ type ContribGraph,
20
+ computeStats,
21
+ contributionGraph,
22
+ dailyStats,
23
+ dayTopModel,
24
+ dayUptimeMs,
25
+ defaultWrappedYear,
26
+ hourlyStats,
27
+ hourTopModel,
28
+ metricValue,
29
+ naturalMetric,
30
+ type Report,
31
+ rangeLabel,
32
+ rangeSince,
33
+ ranked,
34
+ type StatsRange,
35
+ tokensPerSecond,
36
+ type WindowedReport,
37
+ type WindowKey,
38
+ type WrappedStats,
39
+ windowize,
40
+ wrappedStats,
41
+ } from "./aggregate.ts";
42
+ import {
43
+ formatCost,
44
+ formatDayLabel,
45
+ formatDuration,
46
+ formatHour,
47
+ formatInt,
48
+ formatTokens,
49
+ monthLabel,
50
+ percent,
51
+ shortenPath,
52
+ sparkline,
53
+ } from "./format.ts";
54
+ import {
55
+ mascotPose,
56
+ mascotQuip,
57
+ RAINBOW,
58
+ renderMascot,
59
+ renderWrappedMascot,
60
+ toolGlyph,
61
+ VIEW_ORDER,
62
+ VIEW_TABS,
63
+ type ViewKey,
64
+ wrappedMascotCaption,
65
+ } from "./mascot.ts";
66
+ import type { ProviderQuota } from "./provider.ts";
67
+
68
+ export type { ViewKey } from "./mascot.ts";
69
+
70
+ /** Sort field for the Models table. */
71
+ type SortKey = "value" | "name";
72
+
73
+ /** Sort field + direction for the Daily table. */
74
+ type DailySortField = "tokens" | "cost" | "date";
75
+ type SortDir = "asc" | "desc";
76
+
77
+ /** Semantic controls shared by terminal key handling and portable UI clients. */
78
+ export type UsageAction =
79
+ | { type: "view"; view: ViewKey }
80
+ | { type: "window"; window: WindowKey }
81
+ | { type: "modelSort"; sort: SortKey }
82
+ | { type: "dailySort"; sort: DailySortField }
83
+ | { type: "statsRange"; range: StatsRange }
84
+ | { type: "providerSort"; sort: SortKey }
85
+ | { type: "wrappedYear"; year: number }
86
+ | { type: "wrappedYearDelta"; delta: number }
87
+ | { type: "refresh" }
88
+ | { type: "configure" }
89
+ | { type: "close" };
90
+
91
+ export interface UsageViewDeps {
92
+ theme: Theme;
93
+ tui: TUI | undefined;
94
+ maps: AttributionMaps;
95
+ home: string;
96
+ getConfig: () => {
97
+ fiveHourLimit?: number;
98
+ weeklyLimit?: number;
99
+ fiveHourTokenLimit?: number;
100
+ weeklyTokenLimit?: number;
101
+ };
102
+ onClose: () => void;
103
+ onRefresh: () => void;
104
+ onConfigure: () => void;
105
+ }
106
+
107
+ interface ViewState {
108
+ report: Report | undefined;
109
+ windowKey: WindowKey;
110
+ /** Active top-level view (Overview / Models / Daily / Stats). */
111
+ view: ViewKey;
112
+ /** Sort field for the Models table. */
113
+ sortKey: SortKey;
114
+ /** Sort field + direction for the Daily table. */
115
+ dailySortField: DailySortField;
116
+ dailySortDir: SortDir;
117
+ /** Time range for the Stats view summary (All / 30d / 7d). */
118
+ statsRange: StatsRange;
119
+ /** Calendar year for the Wrapped AI view. */
120
+ wrappedYear: number;
121
+ /** Sort field for the Providers table. */
122
+ agentSortKey: SortKey;
123
+ scanProgress: { loaded: number; total: number } | null;
124
+ scroll: number;
125
+ error: string | null;
126
+ providerQuota: ProviderQuota | null;
127
+ }
128
+
129
+ export class UsageView {
130
+ private readonly deps: UsageViewDeps;
131
+ private portableRendering = false;
132
+ private state: ViewState = {
133
+ report: undefined,
134
+ windowKey: "24h",
135
+ view: "overview",
136
+ sortKey: "value",
137
+ dailySortField: "tokens",
138
+ dailySortDir: "desc",
139
+ statsRange: "all",
140
+ wrappedYear: new Date().getFullYear(),
141
+ agentSortKey: "value",
142
+ scanProgress: null,
143
+ scroll: 0,
144
+ error: null,
145
+ providerQuota: null,
146
+ };
147
+
148
+ constructor(deps: UsageViewDeps) {
149
+ this.deps = deps;
150
+ }
151
+
152
+ /** Set the initial view (used by /usage-models, /usage-daily, … shortcuts). */
153
+ setInitialView(view: ViewKey): void {
154
+ this.applyAction({ type: "view", view });
155
+ }
156
+
157
+ get activeView(): ViewKey {
158
+ return this.state.view;
159
+ }
160
+
161
+ get wrappedYears(): number[] {
162
+ return this.state.report ? availableYears(this.state.report) : [];
163
+ }
164
+
165
+ /** Apply a UI-independent dashboard action. */
166
+ applyAction(action: UsageAction): void {
167
+ switch (action.type) {
168
+ case "view":
169
+ this.setView(action.view);
170
+ break;
171
+ case "window":
172
+ this.setWindow(action.window);
173
+ break;
174
+ case "modelSort":
175
+ this.state.sortKey = action.sort;
176
+ this.state.scroll = 0;
177
+ this.deps.tui?.requestRender();
178
+ break;
179
+ case "dailySort":
180
+ this.setDailySort(action.sort);
181
+ break;
182
+ case "statsRange":
183
+ this.setStatsRange(action.range);
184
+ break;
185
+ case "providerSort":
186
+ this.state.agentSortKey = action.sort;
187
+ this.state.scroll = 0;
188
+ this.deps.tui?.requestRender();
189
+ break;
190
+ case "wrappedYear":
191
+ if (this.wrappedYears.includes(action.year)) {
192
+ this.state.wrappedYear = action.year;
193
+ this.state.scroll = 0;
194
+ this.deps.tui?.requestRender();
195
+ }
196
+ break;
197
+ case "wrappedYearDelta":
198
+ this.cycleWrappedYear(action.delta);
199
+ break;
200
+ case "refresh":
201
+ this.deps.onRefresh();
202
+ break;
203
+ case "configure":
204
+ this.deps.onConfigure();
205
+ break;
206
+ case "close":
207
+ this.deps.onClose();
208
+ break;
209
+ }
210
+ }
211
+
212
+ /** Re-bind the TUI/theme/close callback once pi's custom() factory runs. */
213
+ bind(tui: TUI, theme: Theme, onClose: () => void): void {
214
+ this.deps.tui = tui;
215
+ this.deps.theme = theme;
216
+ this.deps.onClose = onClose;
217
+ this.deps.tui?.requestRender();
218
+ }
219
+
220
+ // --- mutators used by the orchestrator (index.ts) ---
221
+
222
+ setReport(report: Report): void {
223
+ this.state.report = report;
224
+ this.state.scanProgress = null;
225
+ this.state.error = null;
226
+ this.state.wrappedYear = defaultWrappedYear(report);
227
+ this.clampScroll();
228
+ this.deps.tui?.requestRender();
229
+ }
230
+
231
+ setScanning(loaded: number, total: number): void {
232
+ this.state.scanProgress = { loaded, total };
233
+ this.deps.tui?.requestRender();
234
+ }
235
+
236
+ setError(message: string): void {
237
+ this.state.error = message;
238
+ this.state.scanProgress = null;
239
+ this.deps.tui?.requestRender();
240
+ }
241
+
242
+ setProviderQuota(quota: ProviderQuota): void {
243
+ this.state.providerQuota = quota;
244
+ this.clampScroll();
245
+ this.deps.tui?.requestRender();
246
+ }
247
+
248
+ // --- Component interface ---
249
+
250
+ handleInput(data: string): void {
251
+ if (matchesKey(data, "q") || matchesKey(data, Key.escape)) {
252
+ this.applyAction({ type: "close" });
253
+ return;
254
+ }
255
+ // View navigation: Tab / Shift+Tab + arrows + number keys.
256
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
257
+ this.cycleView(1);
258
+ return;
259
+ }
260
+ if (matchesKey(data, "shift+tab") || matchesKey(data, Key.left)) {
261
+ this.cycleView(-1);
262
+ return;
263
+ }
264
+ if (data === "1") {
265
+ this.applyAction({ type: "view", view: "overview" });
266
+ return;
267
+ }
268
+ if (data === "2") {
269
+ this.applyAction({ type: "view", view: "models" });
270
+ return;
271
+ }
272
+ if (data === "3") {
273
+ this.applyAction({ type: "view", view: "delegation" });
274
+ return;
275
+ }
276
+ if (data === "4") {
277
+ this.applyAction({ type: "view", view: "daily" });
278
+ return;
279
+ }
280
+ if (data === "6") {
281
+ this.applyAction({ type: "view", view: "hourly" });
282
+ return;
283
+ }
284
+ if (data === "7") {
285
+ this.applyAction({ type: "view", view: "providers" });
286
+ return;
287
+ }
288
+ if (data === "8") {
289
+ this.applyAction({ type: "view", view: "wrapped" });
290
+ return;
291
+ }
292
+ if (data === "5") {
293
+ if (this.state.view === "overview" || this.state.view === "models") {
294
+ this.applyAction({ type: "window", window: "5h" });
295
+ return;
296
+ }
297
+ this.applyAction({ type: "view", view: "stats" });
298
+ return;
299
+ }
300
+ // Wrapped AI: [ / ] or y cycle calendar years.
301
+ if (this.state.view === "wrapped") {
302
+ if (data === "[") {
303
+ this.applyAction({ type: "wrappedYearDelta", delta: -1 });
304
+ return;
305
+ }
306
+ if (data === "]") {
307
+ this.applyAction({ type: "wrappedYearDelta", delta: 1 });
308
+ return;
309
+ }
310
+ if (matchesKey(data, "y")) {
311
+ this.applyAction({ type: "wrappedYearDelta", delta: 1 });
312
+ return;
313
+ }
314
+ }
315
+ // Providers view: c/n sort by usage or name.
316
+ if (this.state.view === "providers") {
317
+ if (matchesKey(data, "c") || matchesKey(data, "t")) {
318
+ this.applyAction({ type: "providerSort", sort: "value" });
319
+ return;
320
+ }
321
+ if (matchesKey(data, "n")) {
322
+ this.applyAction({ type: "providerSort", sort: "name" });
323
+ return;
324
+ }
325
+ }
326
+ // Stats view: a/w/m pick the summary time range (intercept before the
327
+ // window-key handlers, since the time window doesn't apply to Stats).
328
+ if (this.state.view === "stats") {
329
+ if (matchesKey(data, "a")) {
330
+ this.applyAction({ type: "statsRange", range: "all" });
331
+ return;
332
+ }
333
+ if (matchesKey(data, "w")) {
334
+ this.applyAction({ type: "statsRange", range: "7d" });
335
+ return;
336
+ }
337
+ if (matchesKey(data, "m")) {
338
+ this.applyAction({ type: "statsRange", range: "30d" });
339
+ return;
340
+ }
341
+ }
342
+ // Daily view: t/c/d choose the sort field; pressing the same key flips
343
+ // direction. Intercept before the global sort/window handlers.
344
+ if (this.state.view === "daily") {
345
+ if (matchesKey(data, "t")) {
346
+ this.applyAction({ type: "dailySort", sort: "tokens" });
347
+ return;
348
+ }
349
+ if (matchesKey(data, "c")) {
350
+ this.applyAction({ type: "dailySort", sort: "cost" });
351
+ return;
352
+ }
353
+ if (matchesKey(data, "d")) {
354
+ this.applyAction({ type: "dailySort", sort: "date" });
355
+ return;
356
+ }
357
+ }
358
+ // Sorting (Models & Daily tables).
359
+ if (this.state.view === "models" && (matchesKey(data, "c") || matchesKey(data, "t"))) {
360
+ this.applyAction({ type: "modelSort", sort: "value" });
361
+ return;
362
+ }
363
+ if (this.state.view === "models" && matchesKey(data, "n")) {
364
+ this.applyAction({ type: "modelSort", sort: "name" });
365
+ return;
366
+ }
367
+ if (matchesKey(data, "d")) {
368
+ this.applyAction({ type: "window", window: "24h" });
369
+ return;
370
+ }
371
+ if (matchesKey(data, "w")) {
372
+ this.applyAction({ type: "window", window: "7d" });
373
+ return;
374
+ }
375
+ if (matchesKey(data, "a")) {
376
+ this.applyAction({ type: "window", window: "all" });
377
+ return;
378
+ }
379
+ if (matchesKey(data, "r")) {
380
+ this.applyAction({ type: "refresh" });
381
+ return;
382
+ }
383
+ if (matchesKey(data, "s")) {
384
+ this.applyAction({ type: "configure" });
385
+ return;
386
+ }
387
+ if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
388
+ this.scrollBy(1);
389
+ return;
390
+ }
391
+ if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
392
+ this.scrollBy(-1);
393
+ return;
394
+ }
395
+ if (matchesKey(data, Key.space) || matchesKey(data, "ctrl+d")) {
396
+ this.scrollBy(this.availableHeight() / 2);
397
+ return;
398
+ }
399
+ if (matchesKey(data, "ctrl+u") || matchesKey(data, "b")) {
400
+ this.scrollBy(-this.availableHeight() / 2);
401
+ return;
402
+ }
403
+ if (data === "g") {
404
+ this.scrollTo(0);
405
+ return;
406
+ }
407
+ if (data === "G") {
408
+ this.scrollTo(Number.MAX_SAFE_INTEGER);
409
+ return;
410
+ }
411
+ }
412
+
413
+ render(width: number): string[] {
414
+ const { theme } = this.deps;
415
+ const all = this.buildLines(width, false).map((line) => this.clampLine(line, width));
416
+ const height = this.availableHeight();
417
+
418
+ if (all.length <= height) {
419
+ this.state.scroll = 0;
420
+ return all;
421
+ }
422
+
423
+ const max = all.length - height;
424
+ if (this.state.scroll > max) this.state.scroll = max;
425
+ if (this.state.scroll < 0) this.state.scroll = 0;
426
+ const start = this.state.scroll;
427
+ const slice = all.slice(start, start + height);
428
+
429
+ // Scroll indicator (top-right) so the user knows there is more content.
430
+ const indicator = ` ${start + 1}-${Math.min(start + height, all.length)}/${all.length} `;
431
+ const indicatorW = visibleWidth(indicator);
432
+ const last = truncateToWidth(slice[slice.length - 1] ?? "", Math.max(0, width - indicatorW));
433
+ const pad = Math.max(0, width - visibleWidth(last) - indicatorW);
434
+ slice[slice.length - 1] = last + " ".repeat(pad) + theme.fg("dim", indicator);
435
+ return slice;
436
+ }
437
+
438
+ /** Render all lines without terminal viewport assumptions or key-based instructions. */
439
+ renderPortable(width: number): string[] {
440
+ this.portableRendering = true;
441
+ try {
442
+ return this.buildLines(width, true).map((line) => this.clampLine(line, width));
443
+ } finally {
444
+ this.portableRendering = false;
445
+ }
446
+ }
447
+
448
+ /** Final width clamp so one long line can never break the TUI layout. */
449
+ private clampLine(line: string, width: number): string {
450
+ return visibleWidth(line) > width ? truncateToWidth(line, width) : line;
451
+ }
452
+
453
+ invalidate(): void {
454
+ this.deps.tui?.requestRender();
455
+ }
456
+
457
+ // --- internals ---
458
+
459
+ private availableHeight(): number {
460
+ // Reserve a couple of rows for pi's footer/status. Floor at a sane minimum.
461
+ const rows = this.deps.tui?.terminal?.rows ?? 24;
462
+ return Math.max(8, rows - 2);
463
+ }
464
+
465
+ private setWindow(key: WindowKey): void {
466
+ this.state.windowKey = key;
467
+ this.state.scroll = 0;
468
+ this.deps.tui?.requestRender();
469
+ }
470
+
471
+ private setView(view: ViewKey): void {
472
+ this.state.view = view;
473
+ this.state.scroll = 0;
474
+ this.deps.tui?.requestRender();
475
+ }
476
+
477
+ private cycleView(delta: number): void {
478
+ const idx = VIEW_ORDER.indexOf(this.state.view);
479
+ const next = (idx + delta + VIEW_ORDER.length) % VIEW_ORDER.length;
480
+ this.setView(VIEW_ORDER[next]);
481
+ }
482
+
483
+ private setStatsRange(range: StatsRange): void {
484
+ this.state.statsRange = range;
485
+ this.state.scroll = 0;
486
+ this.deps.tui?.requestRender();
487
+ }
488
+
489
+ /** Set the Daily sort field; pressing the same field again flips direction. */
490
+ private setDailySort(field: DailySortField): void {
491
+ if (this.state.dailySortField === field) {
492
+ this.state.dailySortDir = this.state.dailySortDir === "desc" ? "asc" : "desc";
493
+ } else {
494
+ this.state.dailySortField = field;
495
+ this.state.dailySortDir = "desc";
496
+ }
497
+ this.state.scroll = 0;
498
+ this.deps.tui?.requestRender();
499
+ }
500
+
501
+ private cycleWrappedYear(delta: number): void {
502
+ const report = this.state.report;
503
+ if (!report) return;
504
+ const years = availableYears(report);
505
+ if (years.length === 0) return;
506
+ const cur = this.state.wrappedYear;
507
+ const idx = years.indexOf(cur);
508
+ const base = idx >= 0 ? idx : 0;
509
+ const next = (base + delta + years.length) % years.length;
510
+ this.state.wrappedYear = years[next];
511
+ this.state.scroll = 0;
512
+ this.deps.tui?.requestRender();
513
+ }
514
+
515
+ private scrollBy(delta: number): void {
516
+ this.scrollTo(this.state.scroll + delta);
517
+ }
518
+
519
+ private scrollTo(pos: number): void {
520
+ this.state.scroll = Math.max(0, Math.round(pos));
521
+ this.clampScroll();
522
+ this.deps.tui?.requestRender();
523
+ }
524
+
525
+ private clampScroll(): void {
526
+ // Re-clamped precisely in render(); keep a rough bound here.
527
+ if (this.state.scroll < 0) this.state.scroll = 0;
528
+ }
529
+
530
+ private buildLines(width: number, portable: boolean): string[] {
531
+ const { theme } = this.deps;
532
+ const lines: string[] = [];
533
+ const w = Math.max(40, width);
534
+
535
+ lines.push(theme.fg("borderMuted", "─".repeat(w)));
536
+ lines.push(this.titleLineRaw(w));
537
+ for (const menuLine of portable ? this.portableMenuLines(w) : this.menuLines(w)) {
538
+ lines.push(menuLine);
539
+ }
540
+
541
+ if (this.state.error) {
542
+ lines.push("");
543
+ lines.push(` ${theme.fg("error", this.state.error)}`);
544
+ lines.push("");
545
+ lines.push(portable ? this.portableFooterLine(w) : this.footerLine(w));
546
+ lines.push(theme.fg("borderMuted", "─".repeat(w)));
547
+ return lines;
548
+ }
549
+
550
+ if (!this.state.report) {
551
+ lines.push("");
552
+ const prog = this.state.scanProgress;
553
+ const msg = prog ? `Scanning sessions… ${prog.loaded}/${prog.total}` : "Scanning sessions…";
554
+ lines.push(` ${theme.fg("accent", msg)}`);
555
+ lines.push("");
556
+ lines.push(portable ? this.portableFooterLine(w) : this.footerLine(w));
557
+ lines.push(theme.fg("borderMuted", "─".repeat(w)));
558
+ return lines;
559
+ }
560
+
561
+ switch (this.state.view) {
562
+ case "overview":
563
+ this.renderOverview(lines, w);
564
+ break;
565
+ case "models":
566
+ this.renderModels(lines, w);
567
+ break;
568
+ case "delegation":
569
+ this.renderDelegation(lines, w);
570
+ break;
571
+ case "daily":
572
+ this.renderDaily(lines, w);
573
+ break;
574
+ case "stats":
575
+ this.renderStats(lines, w);
576
+ break;
577
+ case "hourly":
578
+ this.renderHourly(lines, w);
579
+ break;
580
+ case "providers":
581
+ this.renderProviders(lines, w);
582
+ break;
583
+ case "wrapped":
584
+ this.renderWrapped(lines, w);
585
+ break;
586
+ }
587
+
588
+ lines.push(portable ? this.portableFooterLine(w) : this.footerLine(w));
589
+ lines.push(theme.fg("borderMuted", "─".repeat(w)));
590
+ return lines;
591
+ }
592
+
593
+ /**
594
+ * Subscription-aware breakdown unit: token-priced providers (Codex, ZAI
595
+ * plans) always show tokens; otherwise USD when the window has real cost.
596
+ */
597
+ private unitForWindow(win: WindowedReport): "usd" | "tokens" {
598
+ const activeProviderName = this.state.providerQuota?.active?.provider ?? "";
599
+ const isSubscription =
600
+ activeProviderName === "openai-codex" ||
601
+ activeProviderName.startsWith("openai-codex-") ||
602
+ !!this.state.providerQuota?.planQuota;
603
+ return isSubscription || win.total.cost <= 0 ? "tokens" : "usd";
604
+ }
605
+
606
+ // ---------------------------------------------------------------- Overview
607
+
608
+ private renderOverview(lines: string[], w: number): void {
609
+ const { theme } = this.deps;
610
+ const report = this.state.report;
611
+ if (!report) return;
612
+ const win = windowize(report, this.state.windowKey, this.deps.maps);
613
+ lines.push(this.subheaderLine(win, w));
614
+ lines.push("");
615
+
616
+ const unit = this.unitForWindow(win);
617
+ this.renderQuotaBlock(lines, win, w, unit);
618
+ lines.push("");
619
+
620
+ // Headline stats for the selected window.
621
+ lines.push(this.statsLine(win, w));
622
+ if (bucketTokens(win.delegated) > 0) {
623
+ const share = percent(bucketTokens(win.delegated), bucketTokens(win.total));
624
+ const peak = win.concurrency.peak == null ? "—" : `${win.concurrency.peak}`;
625
+ // Only mark the peak as estimated when child timing was inferred from
626
+ // transcript mtimes; precise spawn/end records make it a real measurement.
627
+ const peakLabel = win.concurrency.inferred ? "est. peak" : "peak";
628
+ const childWord = win.children.length === 1 ? "child session" : "child sessions";
629
+ const detail = truncateToWidth(
630
+ `Delegated ${share} · ${win.children.length} ${childWord} · ${peakLabel} ${peak}`,
631
+ Math.max(20, w - 4),
632
+ );
633
+ lines.push(` ${theme.fg("muted", detail)}`);
634
+ }
635
+ lines.push("");
636
+ this.appendTokenComposition(lines, win, w);
637
+ lines.push("");
638
+
639
+ // Active provider + live quota (from the provider itself).
640
+ this.appendProviderSection(lines, w);
641
+
642
+ // "Top consumer" sentence (single biggest independent characteristic).
643
+ const top = this.topConsumer(win, unit);
644
+ if (top) {
645
+ lines.push(` ${theme.fg("muted", "Top consumer")}`);
646
+ lines.push(
647
+ ` ${theme.fg("text", `${top.pct} of usage came from ${top.kind} `)}${theme.fg("accent", top.name)}`,
648
+ );
649
+ lines.push("");
650
+ }
651
+
652
+ // Mini trend: last 30 active-window days as a sparkline.
653
+ this.appendTrendSparkline(lines, w);
654
+
655
+ // Compact top models for at-a-glance context (full table in Models view).
656
+ const total = unit === "tokens" ? bucketTokens(win.total) : win.total.cost;
657
+ this.appendSection(lines, "Top models", win.byModel, total, w, 5, undefined, unit);
658
+ lines.push(
659
+ ` ${theme.fg("dim", this.portableRendering ? "Use the action menu to explore other views." : "→ Tab or 1-8 to explore · ✦8 opens Wrapped AI")}`,
660
+ );
661
+ }
662
+
663
+ /** Render the always-on quota bars (plan quota or session-derived budget). */
664
+ private renderQuotaBlock(
665
+ lines: string[],
666
+ win: WindowedReport,
667
+ w: number,
668
+ unit: "usd" | "tokens",
669
+ ): void {
670
+ const { theme } = this.deps;
671
+ const cfg = this.deps.getConfig();
672
+ const activeProviderName = this.state.providerQuota?.active?.provider ?? "";
673
+ const planQuota = this.state.providerQuota?.planQuota;
674
+ if (planQuota) {
675
+ // Provider-native plan quota (ZAI GLM coding plans): the upstream reports
676
+ // the authoritative used % + live reset countdown directly. No budget
677
+ // config needed — these ARE the 5h/weekly used/remaining from upstream.
678
+ const planLabel = planQuota.plan
679
+ ? ` ${theme.fg("accent", planQuota.plan)} plan · upstream quota`
680
+ : "";
681
+ if (planQuota.session5h) {
682
+ // Session-derived cost/tokens in the same window, combined with the
683
+ // upstream percentage. The right-side text reads:
684
+ // "100% used / $02.12 · 0% left · resets 3m 9s"
685
+ lines.push(this.percentLine("5-hour quota", planQuota.session5h, w, unit, win.fiveHour));
686
+ } else if (planQuota.weekly) {
687
+ // Upstream reports weekly but not 5h — show an explicit line so the
688
+ // row doesn't silently disappear (some plans/plans-in-certain-regions
689
+ // only expose the weekly window).
690
+ lines.push(
691
+ ` ${theme.fg("text", "5-hour quota".padEnd(16))} ${theme.fg("dim", "not reported by upstream for this plan")}`,
692
+ );
693
+ }
694
+ if (planQuota.weekly) {
695
+ lines.push(this.percentLine("Weekly quota", planQuota.weekly, w, unit, win.weekly));
696
+ }
697
+ lines.push(` ${theme.fg("dim", "live from provider")}${planLabel}`);
698
+ if (planQuota.webSearches) {
699
+ const ws = planQuota.webSearches;
700
+ lines.push(
701
+ ` ${theme.fg("text", "Web searches")} ${theme.fg("muted", `${ws.used}/${ws.limit}`)}${ws.resetMs ? ` ${theme.fg("dim", `resets ${countdown(ws.resetMs)}`)}` : ""}`,
702
+ );
703
+ }
704
+ if (planQuota.credits) {
705
+ const c = planQuota.credits;
706
+ const value = c.unlimited ? "unlimited" : `${c.balance} credits`;
707
+ lines.push(` ${theme.fg("text", "Credits")} ${theme.fg("muted", value)}`);
708
+ }
709
+ } else {
710
+ // For subscription providers (OpenAI Codex, ZAI coding plans) the session-derived
711
+ // fallback is misleading — the panel should never suggest `/usage-config` for
712
+ // a subscription, because the real quota comes from the upstream. Show a
713
+ // clear action hint instead so the user knows what to do.
714
+ const isSubscriptionProvider =
715
+ activeProviderName === "openai-codex" ||
716
+ activeProviderName.startsWith("openai-codex-") ||
717
+ activeProviderName === "zai";
718
+ if (isSubscriptionProvider) {
719
+ lines.push(` ${theme.fg("warning", this.buildSubscriptionHint(activeProviderName))}`);
720
+ } else {
721
+ // Fallback: session-derived usage. Unit adapts to the provider (USD for
722
+ // priced providers, tokens for token-priced ones) against a user budget.
723
+ const unitTag = theme.fg("dim", unit === "tokens" ? "(tokens)" : "(USD)");
724
+ lines.push(
725
+ this.quotaLine(
726
+ "5-hour quota",
727
+ win.fiveHour,
728
+ unit === "usd" ? cfg.fiveHourLimit : cfg.fiveHourTokenLimit,
729
+ w,
730
+ unit,
731
+ ),
732
+ );
733
+ lines.push(
734
+ this.quotaLine(
735
+ "Weekly quota",
736
+ win.weekly,
737
+ unit === "usd" ? cfg.weeklyLimit : cfg.weeklyTokenLimit,
738
+ w,
739
+ unit,
740
+ ),
741
+ );
742
+ lines.push(
743
+ ` ${unitTag} ${theme.fg("dim", `session history · set a budget via /usage-config`)}`,
744
+ );
745
+ }
746
+ }
747
+ }
748
+
749
+ // ------------------------------------------------------------------ Models
750
+
751
+ private renderModels(lines: string[], w: number): void {
752
+ const report = this.state.report;
753
+ if (!report) return;
754
+ const win = windowize(report, this.state.windowKey, this.deps.maps);
755
+ lines.push(this.subheaderLine(win, w));
756
+ lines.push("");
757
+
758
+ const unit = this.unitForWindow(win);
759
+ // Breakdown sections use the same unit as the quota bars (tokens when
760
+ // the provider has no pricing, USD otherwise).
761
+ const total = unit === "tokens" ? bucketTokens(win.total) : win.total.cost;
762
+ this.appendModelTable(lines, win, total, w, unit);
763
+ this.appendSection(lines, "Skills", win.bySkill, total, w, 8, undefined, unit);
764
+ this.appendSection(lines, "Bundles", win.byBundle, total, w, 8, (k) => k, unit);
765
+ // Plugin usage: ranked plugins with the skills/tools that drove each, plus
766
+ // the “core” remainder (turns that used only builtin tools and no skill).
767
+ this.appendPluginUsageSection(lines, win, total, w, unit);
768
+ this.appendToolsSection(lines, win.byTool, total, w, 8, unit);
769
+ this.appendSection(
770
+ lines,
771
+ "Projects",
772
+ win.byProject,
773
+ total,
774
+ w,
775
+ 6,
776
+ (k) => shortenPath(k, this.deps.home),
777
+ unit,
778
+ );
779
+ }
780
+
781
+ /**
782
+ * Models table styled like the Skills section (name · % · bar · value), with
783
+ * an extra column for the average generation speed (estimated tok/s).
784
+ */
785
+ private appendModelTable(
786
+ lines: string[],
787
+ win: WindowedReport,
788
+ total: number,
789
+ width: number,
790
+ unit: "usd" | "tokens",
791
+ ): void {
792
+ const { theme } = this.deps;
793
+ const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
794
+ const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
795
+
796
+ const rows =
797
+ this.state.sortKey === "name"
798
+ ? [...win.byModel.entries()].sort((a, b) => a[0].localeCompare(b[0]))
799
+ : ranked(win.byModel, bucketValue);
800
+
801
+ // Match the Skills/appendSection geometry so both sections line up, plus a
802
+ // fixed-width value column so the tok/s column aligns under its header.
803
+ const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
804
+ const barW = Math.max(6, Math.min(20, width - labelW - 26));
805
+ const valueW = 9;
806
+ const unitLabel = unit === "tokens" ? "tokens" : "cost";
807
+
808
+ lines.push(this.tableHeader("Models", labelW, barW, unitLabel, "tok/s", valueW));
809
+ if (rows.length === 0) {
810
+ lines.push(` ${theme.fg("dim", "— none in this window —")}`);
811
+ lines.push("");
812
+ return;
813
+ }
814
+
815
+ const shown = rows.slice(0, 12);
816
+ for (const [key, b] of shown) {
817
+ const value = bucketValue(b);
818
+ const name = truncateToWidth(key, labelW).padEnd(labelW);
819
+ const pctStr = percent(value, total).padStart(4);
820
+ const ratio = total > 0 ? value / total : 0;
821
+ const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
822
+ const barStr =
823
+ theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
824
+ const valueStr = fmt(value).padEnd(valueW);
825
+ const tps = tokensPerSecond(b);
826
+ const rate = tps > 0 ? formatRate(tps) : "—";
827
+ lines.push(
828
+ ` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg("dim", valueStr)} ${theme.fg("success", rate)}`,
829
+ );
830
+ }
831
+ const rest = rows.length - shown.length;
832
+ if (rest > 0) {
833
+ lines.push(` ${theme.fg("dim", `… +${rest} more`)}`);
834
+ }
835
+ lines.push(` ${theme.fg("dim", "tok/s · est. avg output speed")}`);
836
+ lines.push("");
837
+ }
838
+
839
+ // ------------------------------------------------------------ Delegation
840
+
841
+ private renderDelegation(lines: string[], w: number): void {
842
+ const { theme } = this.deps;
843
+ const report = this.state.report;
844
+ if (!report) return;
845
+ const win = windowize(report, this.state.windowKey, this.deps.maps);
846
+ lines.push(this.subheaderLine(win, w));
847
+ lines.push("");
848
+ if (win.children.length === 0 && bucketTokens(win.delegated) === 0) {
849
+ lines.push(` ${theme.fg("muted", "No delegated child sessions in this window.")}`);
850
+ lines.push(
851
+ ` ${theme.fg("dim", "Generic tasks/subagents transcripts are discovered automatically.")}`,
852
+ );
853
+ lines.push("");
854
+ return;
855
+ }
856
+ const direct = bucketTokens(win.direct);
857
+ const delegated = bucketTokens(win.delegated);
858
+ const total = bucketTokens(win.total);
859
+ const unit = this.unitForWindow(win);
860
+ const delegatedValue = unit === "usd" ? win.delegated.cost : delegated;
861
+ const cutoff = win.window === "all" ? -1 : Date.now() - this.windowDuration(win.window);
862
+ const selectedTurns = report.entries.filter((turn) => cutoff === -1 || turn.ts >= cutoff);
863
+ lines.push(` ${theme.fg("accent", theme.bold("Composition"))}`);
864
+ if (w < 54) {
865
+ // Narrow terminals: stack so the delegated half is never clamped away.
866
+ lines.push(
867
+ ` ${theme.fg("text", `Direct ${formatTokens(direct)} (${percent(direct, total)})`)}`,
868
+ );
869
+ lines.push(
870
+ ` ${theme.fg("muted", `Delegated ${formatTokens(delegated)} (${percent(delegated, total)})`)}`,
871
+ );
872
+ } else {
873
+ lines.push(
874
+ ` ${theme.fg("text", `Direct ${formatTokens(direct)} (${percent(direct, total)})`)} ${theme.fg("muted", `Delegated ${formatTokens(delegated)} (${percent(delegated, total)})`)}`,
875
+ );
876
+ }
877
+ if (w >= 64) {
878
+ const detail = `direct in/out/cache ${formatTokens(win.direct.input)}/${formatTokens(win.direct.output)}/${formatTokens(win.direct.cacheRead + win.direct.cacheWrite)} · delegated ${formatTokens(win.delegated.input)}/${formatTokens(win.delegated.output)}/${formatTokens(win.delegated.cacheRead + win.delegated.cacheWrite)}`;
879
+ lines.push(` ${theme.fg("dim", truncateToWidth(detail, Math.max(20, w - 4)))}`);
880
+ }
881
+ lines.push("");
882
+ const c = win.concurrency;
883
+ const pairs: Array<[string, string]> = [
884
+ ["Child sessions", `${c.childCount}`],
885
+ ["Parents", `${c.parentCount}`],
886
+ // Recorded timing is a plain number; only inferred timing is flagged.
887
+ [
888
+ "Peak concurrency",
889
+ c.peak == null ? "—" : c.inferred ? `${c.peak} (inferred)` : `${c.peak}`,
890
+ ],
891
+ ["Union wall span", c.unionMs == null ? "—" : formatDuration(c.unionMs)],
892
+ ["Summed spans", c.summedMs == null ? "—" : formatDuration(c.summedMs)],
893
+ ["Parallelism", c.parallelism == null ? "—" : `${c.parallelism.toFixed(2)}×`],
894
+ [
895
+ "Overlap saved",
896
+ c.overlapSavedMs == null || c.overlapSavedMs <= 0
897
+ ? "—"
898
+ : `est. ${formatDuration(c.overlapSavedMs)}`,
899
+ ],
900
+ ];
901
+ lines.push(` ${theme.fg("accent", theme.bold("Concurrency"))}`);
902
+ this.appendStatGrid(lines, pairs, w);
903
+ lines.push("");
904
+
905
+ const childTurnsBySession = new Map<string, typeof selectedTurns>();
906
+ for (const turn of selectedTurns) {
907
+ if (!turn.delegated) continue;
908
+ const turns = childTurnsBySession.get(turn.sessionId) ?? [];
909
+ turns.push(turn);
910
+ childTurnsBySession.set(turn.sessionId, turns);
911
+ }
912
+
913
+ const grouped = new Map<string, Bucket>();
914
+ for (const child of win.children) {
915
+ const bucket = grouped.get(child.agentType) ?? this.zeroBucket();
916
+ for (const turn of childTurnsBySession.get(child.id) ?? []) {
917
+ this.addUsageToBucket(bucket, turn.usage);
918
+ }
919
+ grouped.set(child.agentType, bucket);
920
+ }
921
+ this.appendSection(lines, "Agent / profile", grouped, delegatedValue, w, 8, undefined, unit);
922
+ const parentGroups = new Map<string, Bucket>();
923
+ for (const turn of selectedTurns) {
924
+ if (!turn.delegated) continue;
925
+ const label =
926
+ win.children.find((child) => child.id === turn.sessionId)?.parentLabel ||
927
+ turn.parentSessionId ||
928
+ "(unknown)";
929
+ const bucket = parentGroups.get(label) ?? this.zeroBucket();
930
+ this.addUsageToBucket(bucket, turn.usage);
931
+ parentGroups.set(label, bucket);
932
+ }
933
+ this.appendSection(lines, "Parents", parentGroups, delegatedValue, w, 8, undefined, unit);
934
+ lines.push(` ${theme.fg("accent", theme.bold("Child sessions"))}`);
935
+ if (win.children.length === 0) {
936
+ lines.push(` ${theme.fg("dim", "— none in this window —")}`);
937
+ lines.push("");
938
+ return;
939
+ }
940
+ const childRow = (child: (typeof win.children)[number]) => {
941
+ const childTurns = childTurnsBySession.get(child.id) ?? [];
942
+ const tokens = childTurns.reduce(
943
+ (sum, turn) =>
944
+ sum + turn.usage.input + turn.usage.output + turn.usage.cacheRead + turn.usage.cacheWrite,
945
+ 0,
946
+ );
947
+ const cost = childTurns.reduce((sum, turn) => sum + turn.usage.cost.total, 0);
948
+ const value = unit === "usd" ? formatCost(cost) : formatTokens(tokens);
949
+ const duration =
950
+ child.endedAt > child.startedAt ? formatDuration(child.endedAt - child.startedAt) : "—";
951
+ const mode = child.isBackground == null ? "" : child.isBackground ? " bg" : " fg";
952
+ return { value, duration, status: `${child.status}${mode}` };
953
+ };
954
+ if (w < 54) {
955
+ // Narrow terminals: two-line rows keep the task name readable.
956
+ for (const child of win.children.slice(0, 20)) {
957
+ const row = childRow(child);
958
+ lines.push(` ${theme.fg("text", truncateToWidth(child.task, Math.max(10, w - 4)))}`);
959
+ lines.push(
960
+ ` ${theme.fg("dim", `${row.value} · ${row.duration} · `)}${theme.fg("success", row.status)}`,
961
+ );
962
+ }
963
+ } else {
964
+ const taskW = Math.max(6, w - 43);
965
+ lines.push(
966
+ ` ${" ".repeat(taskW)} ${theme.fg("dim", "tokens".padStart(8))} ${theme.fg("dim", "span".padStart(7))} ${theme.fg("dim", "status")}`,
967
+ );
968
+ for (const child of win.children.slice(0, 20)) {
969
+ const row = childRow(child);
970
+ lines.push(
971
+ ` ${theme.fg("text", truncateToWidth(child.task, taskW).padEnd(taskW))} ${theme.fg("muted", truncateToWidth(row.value, 8).padStart(8))} ${theme.fg("dim", truncateToWidth(row.duration, 7).padStart(7))} ${theme.fg("success", truncateToWidth(row.status, 12))}`,
972
+ );
973
+ }
974
+ }
975
+ if (win.children.length > 20)
976
+ lines.push(` ${theme.fg("dim", `… +${win.children.length - 20} more`)}`);
977
+ lines.push("");
978
+ }
979
+
980
+ private windowDuration(key: WindowKey): number {
981
+ return key === "5h"
982
+ ? 5 * 60 * 60 * 1000
983
+ : key === "24h"
984
+ ? 24 * 60 * 60 * 1000
985
+ : key === "7d"
986
+ ? 7 * 24 * 60 * 60 * 1000
987
+ : Number.POSITIVE_INFINITY;
988
+ }
989
+
990
+ private zeroBucket(): Bucket {
991
+ return {
992
+ cost: 0,
993
+ costInput: 0,
994
+ costOutput: 0,
995
+ costCacheRead: 0,
996
+ costCacheWrite: 0,
997
+ input: 0,
998
+ output: 0,
999
+ cacheRead: 0,
1000
+ cacheWrite: 0,
1001
+ cacheWrite1h: 0,
1002
+ reasoning: 0,
1003
+ turns: 0,
1004
+ genMs: 0,
1005
+ timedTurns: 0,
1006
+ };
1007
+ }
1008
+
1009
+ private addUsageToBucket(bucket: Bucket, usage: import("@earendil-works/pi-ai").Usage): void {
1010
+ bucket.cost += usage.cost.total;
1011
+ bucket.costInput += usage.cost.input ?? 0;
1012
+ bucket.costOutput += usage.cost.output ?? 0;
1013
+ bucket.costCacheRead += usage.cost.cacheRead ?? 0;
1014
+ bucket.costCacheWrite += usage.cost.cacheWrite ?? 0;
1015
+ bucket.input += usage.input;
1016
+ bucket.output += usage.output;
1017
+ bucket.cacheRead += usage.cacheRead;
1018
+ bucket.cacheWrite += usage.cacheWrite;
1019
+ bucket.cacheWrite1h += usage.cacheWrite1h ?? 0;
1020
+ bucket.reasoning += usage.reasoning ?? 0;
1021
+ bucket.turns += 1;
1022
+ }
1023
+
1024
+ // ------------------------------------------------------------ Daily Summary
1025
+
1026
+ private renderDaily(lines: string[], w: number): void {
1027
+ const { theme } = this.deps;
1028
+ const report = this.state.report;
1029
+ if (!report) return;
1030
+
1031
+ const days = dailyStats(report);
1032
+
1033
+ // Totals across all active days (uptime / tokens / cost).
1034
+ let totalCost = 0;
1035
+ let totalTokens = 0;
1036
+ let totalUptime = 0;
1037
+ for (const d of days) {
1038
+ totalCost += d.bucket.cost;
1039
+ totalTokens += bucketTokens(d.bucket);
1040
+ totalUptime += dayUptimeMs(d);
1041
+ }
1042
+
1043
+ const field = this.state.dailySortField;
1044
+ const dir = this.state.dailySortDir;
1045
+ const arrow = dir === "asc" ? "↑" : "↓";
1046
+ lines.push(
1047
+ ` ${theme.fg("muted", "Daily")} ${theme.fg("dim", `${days.length} active days · all time · sort: ${field} ${arrow}`)}`,
1048
+ );
1049
+ if (days.length > 0) {
1050
+ const totalCostStr =
1051
+ totalCost > 0
1052
+ ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", "cost")} ${theme.fg("success", formatCost(totalCost))}`
1053
+ : "";
1054
+ lines.push(
1055
+ ` ${theme.fg("dim", "uptime")} ${theme.fg("text", formatDuration(totalUptime))}` +
1056
+ ` ${theme.fg("dim", "·")} ${theme.fg("dim", "tokens")} ${theme.fg("text", formatTokens(totalTokens))}` +
1057
+ totalCostStr,
1058
+ );
1059
+ }
1060
+ lines.push("");
1061
+
1062
+ if (days.length === 0) {
1063
+ lines.push(` ${theme.fg("dim", "— no activity recorded —")}`);
1064
+ lines.push("");
1065
+ return;
1066
+ }
1067
+
1068
+ // The bar always tracks tokens (activity): most models are token-priced
1069
+ // (cost 0), so a cost-based bar would collapse to empty.
1070
+ const dayTokens = (d: (typeof days)[number]) => bucketTokens(d.bucket);
1071
+ const maxVal = days.reduce((m, d) => Math.max(m, dayTokens(d)), 0);
1072
+
1073
+ // Sort by the chosen field + direction.
1074
+ const sortValue = (d: (typeof days)[number]) =>
1075
+ field === "cost" ? d.bucket.cost : field === "date" ? d.ts : dayTokens(d);
1076
+ const sign = dir === "asc" ? 1 : -1;
1077
+ const sorted = [...days].sort((a, b) => sign * (sortValue(a) - sortValue(b)));
1078
+
1079
+ // Column geometry. The bar is the "graph"; numeric columns give the
1080
+ // exact cost / tokens / uptime, and the last column names the day's top
1081
+ // model (the model that drove most of that day's spend).
1082
+ const labelW = 14;
1083
+ const costW = 8;
1084
+ const tokW = 9;
1085
+ const upW = 7;
1086
+ const fixed = 2 + labelW + 1 + 1 + costW + 1 + tokW + 1 + upW + 1;
1087
+ const barW = Math.max(6, Math.min(12, w - fixed - 10));
1088
+ const modelW = w - fixed - barW;
1089
+ const showModel = modelW >= 8;
1090
+
1091
+ // Aligned header.
1092
+ let header = ` ${theme.fg("accent", theme.bold("Day".padEnd(labelW)))} ${" ".repeat(barW)}`;
1093
+ header += ` ${theme.fg("dim", "cost".padStart(costW))}`;
1094
+ header += ` ${theme.fg("dim", "tokens".padStart(tokW))}`;
1095
+ header += ` ${theme.fg("dim", "uptime".padStart(upW))}`;
1096
+ if (showModel) header += ` ${theme.fg("dim", "top model")}`;
1097
+ lines.push(header);
1098
+
1099
+ for (const d of sorted.slice(0, 60)) {
1100
+ const value = dayTokens(d);
1101
+ const ratio = maxVal > 0 ? value / maxVal : 0;
1102
+ const filled = Math.max(value > 0 ? 1 : 0, Math.round(ratio * barW));
1103
+ const barColor: ThemeColor = ratio > 0.66 ? "accent" : ratio > 0.33 ? "success" : "warning";
1104
+ const barStr =
1105
+ theme.fg(barColor, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
1106
+ const label = truncateToWidth(formatDayLabel(d.dateKey), labelW).padEnd(labelW);
1107
+ // Cost is "—" when the day's models are token-priced (no pricing → 0),
1108
+ // so the column doesn't read as a broken row of $0.00.
1109
+ const cost = (d.bucket.cost > 0 ? formatCost(d.bucket.cost) : "—").padStart(costW);
1110
+ const tokens = formatTokens(bucketTokens(d.bucket)).padStart(tokW);
1111
+ const uptime = formatDuration(dayUptimeMs(d)).padStart(upW);
1112
+ const costCell = d.bucket.cost > 0 ? theme.fg("success", cost) : theme.fg("dim", cost);
1113
+ let line =
1114
+ ` ${theme.fg("text", label)} ${barStr}` +
1115
+ ` ${costCell} ${theme.fg("muted", tokens)} ${theme.fg("dim", uptime)}`;
1116
+ if (showModel) {
1117
+ const top = dayTopModel(d) ?? "—";
1118
+ const m = truncateToWidth(top, modelW - 1);
1119
+ line += ` ${theme.fg("accent", m)}`;
1120
+ }
1121
+ lines.push(line);
1122
+ }
1123
+ if (sorted.length > 60) {
1124
+ lines.push(` ${theme.fg("dim", `… +${sorted.length - 60} more days`)}`);
1125
+ }
1126
+ lines.push("");
1127
+ }
1128
+
1129
+ // ---------------------------------------------------------------- Hourly
1130
+
1131
+ private renderHourly(lines: string[], w: number): void {
1132
+ const { theme } = this.deps;
1133
+ const report = this.state.report;
1134
+ if (!report) return;
1135
+
1136
+ const hours = hourlyStats(report);
1137
+ let totalTokens = 0;
1138
+ let totalTurns = 0;
1139
+ let activeHours = 0;
1140
+ for (const h of hours) {
1141
+ const tok = bucketTokens(h.bucket);
1142
+ totalTokens += tok;
1143
+ totalTurns += h.bucket.turns;
1144
+ if (tok > 0) activeHours += 1;
1145
+ }
1146
+
1147
+ lines.push(
1148
+ ` ${theme.fg("muted", "Hourly")} ${theme.fg("dim", "by time of day · all days combined")}`,
1149
+ );
1150
+ if (totalTurns > 0) {
1151
+ lines.push(
1152
+ ` ${theme.fg("dim", "turns")} ${theme.fg("text", formatInt(totalTurns))}` +
1153
+ ` ${theme.fg("dim", "·")} ${theme.fg("dim", "tokens")} ${theme.fg("text", formatTokens(totalTokens))}` +
1154
+ ` ${theme.fg("dim", "·")} ${theme.fg("dim", "active hours")} ${theme.fg("text", `${activeHours}/24`)}`,
1155
+ );
1156
+ }
1157
+ lines.push("");
1158
+
1159
+ if (totalTurns === 0) {
1160
+ lines.push(` ${theme.fg("dim", "— no activity recorded —")}`);
1161
+ lines.push("");
1162
+ return;
1163
+ }
1164
+
1165
+ const maxVal = hours.reduce((m, h) => Math.max(m, bucketTokens(h.bucket)), 0);
1166
+ const labelW = 6;
1167
+ const tokW = 9;
1168
+ const turnW = 6;
1169
+ const fixed = 2 + labelW + 1 + 1 + tokW + 1 + turnW + 1;
1170
+ const barW = Math.max(8, Math.min(24, w - fixed - 14));
1171
+ const modelW = Math.max(0, w - fixed - barW);
1172
+ const showModel = modelW >= 10;
1173
+
1174
+ let header = ` ${theme.fg("accent", theme.bold("Hour".padEnd(labelW)))} ${" ".repeat(barW)}`;
1175
+ header += ` ${theme.fg("dim", "tokens".padStart(tokW))}`;
1176
+ header += ` ${theme.fg("dim", "turns".padStart(turnW))}`;
1177
+ if (showModel) header += ` ${theme.fg("dim", "top model")}`;
1178
+ lines.push(header);
1179
+
1180
+ for (const h of hours) {
1181
+ const value = bucketTokens(h.bucket);
1182
+ const ratio = maxVal > 0 ? value / maxVal : 0;
1183
+ const filled = Math.max(value > 0 ? 1 : 0, Math.round(ratio * barW));
1184
+ const barColor: ThemeColor =
1185
+ ratio > 0.66 ? "accent" : ratio > 0.33 ? "success" : value > 0 ? "warning" : "borderMuted";
1186
+ const barStr =
1187
+ value > 0
1188
+ ? theme.fg(barColor, "█".repeat(filled)) +
1189
+ theme.fg("borderMuted", "░".repeat(barW - filled))
1190
+ : theme.fg("borderMuted", "·".repeat(barW));
1191
+ const label = formatHour(h.hour).padEnd(labelW);
1192
+ const tokens = (value > 0 ? formatTokens(value) : "—").padStart(tokW);
1193
+ const turns = (h.bucket.turns > 0 ? formatInt(h.bucket.turns) : "—").padStart(turnW);
1194
+ let line =
1195
+ ` ${theme.fg(value > 0 ? "text" : "dim", label)} ${barStr}` +
1196
+ ` ${theme.fg(value > 0 ? "text" : "dim", tokens)}` +
1197
+ ` ${theme.fg("dim", turns)}`;
1198
+ if (showModel) {
1199
+ const top = hourTopModel(h);
1200
+ const modelCell = top ? truncateToWidth(top, modelW) : theme.fg("dim", "—");
1201
+ line += ` ${theme.fg("muted", modelCell)}`;
1202
+ }
1203
+ lines.push(line);
1204
+ }
1205
+ lines.push("");
1206
+ }
1207
+
1208
+ // -------------------------------------------------------------- Providers
1209
+
1210
+ private renderProviders(lines: string[], w: number): void {
1211
+ const { theme } = this.deps;
1212
+ const report = this.state.report;
1213
+ if (!report) return;
1214
+
1215
+ const agents = agentStats(report);
1216
+ let totalTokens = 0;
1217
+ let totalCost = 0;
1218
+ for (const a of agents) {
1219
+ totalTokens += bucketTokens(a.bucket);
1220
+ totalCost += a.bucket.cost;
1221
+ }
1222
+
1223
+ const sortLabel = this.state.agentSortKey === "name" ? "name" : "usage";
1224
+ lines.push(
1225
+ ` ${theme.fg("muted", "Providers")} ${theme.fg("dim", `${agents.length} providers · sort: ${sortLabel}`)}`,
1226
+ );
1227
+ if (agents.length > 0) {
1228
+ const costStr =
1229
+ totalCost > 0
1230
+ ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", "cost")} ${theme.fg("success", formatCost(totalCost))}`
1231
+ : "";
1232
+ lines.push(
1233
+ ` ${theme.fg("dim", "tokens")} ${theme.fg("text", formatTokens(totalTokens))}${costStr}`,
1234
+ );
1235
+ }
1236
+ lines.push("");
1237
+
1238
+ if (agents.length === 0) {
1239
+ lines.push(` ${theme.fg("dim", "— no providers recorded —")}`);
1240
+ lines.push("");
1241
+ return;
1242
+ }
1243
+
1244
+ const rows =
1245
+ this.state.agentSortKey === "name"
1246
+ ? [...agents].sort((a, b) => a.provider.localeCompare(b.provider))
1247
+ : agents;
1248
+
1249
+ // The proj column is the first casualty of a narrow terminal; drop it
1250
+ // below 48 columns so the tokens column stays intact.
1251
+ const showProj = w >= 48;
1252
+ const labelW = showProj
1253
+ ? Math.max(14, Math.min(28, Math.floor((w - 36) * 0.45)))
1254
+ : Math.max(12, Math.min(28, Math.floor((w - 30) * 0.45)));
1255
+ const barW = Math.max(6, Math.min(18, w - labelW - (showProj ? 28 : 22)));
1256
+ const tokW = 9;
1257
+ const projW = 5;
1258
+
1259
+ lines.push(
1260
+ showProj
1261
+ ? this.tableHeader("Provider", labelW, barW, "tokens", "proj", tokW)
1262
+ : this.tableHeader("Provider", labelW, barW, "tokens"),
1263
+ );
1264
+
1265
+ for (const a of rows.slice(0, 16)) {
1266
+ const value = bucketTokens(a.bucket);
1267
+ const name = truncateToWidth(a.provider, labelW).padEnd(labelW);
1268
+ const pctStr = percent(value, totalTokens).padStart(4);
1269
+ const ratio = totalTokens > 0 ? value / totalTokens : 0;
1270
+ const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
1271
+ const barStr =
1272
+ theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
1273
+ const tokStr = showProj ? formatTokens(value).padEnd(tokW) : formatTokens(value);
1274
+ const top = agentTopModel(a);
1275
+ const projCell = showProj
1276
+ ? ` ${theme.fg("success", formatInt(a.projects.size).padStart(projW))}`
1277
+ : "";
1278
+ lines.push(
1279
+ ` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg("dim", tokStr)}${projCell}`,
1280
+ );
1281
+ if (top && w >= labelW + barW + 40) {
1282
+ lines.push(
1283
+ ` ${" ".repeat(labelW + barW + 8)}${theme.fg("dim", `↳ ${truncateToWidth(top, w - labelW - barW - 12)}`)}`,
1284
+ );
1285
+ }
1286
+ }
1287
+ if (rows.length > 16) {
1288
+ lines.push(` ${theme.fg("dim", `… +${rows.length - 16} more providers`)}`);
1289
+ }
1290
+ if (showProj) {
1291
+ lines.push(` ${theme.fg("dim", "proj · distinct project paths per provider")}`);
1292
+ }
1293
+ lines.push("");
1294
+ }
1295
+
1296
+ // ----------------------------------------------------------- Wrapped AI
1297
+
1298
+ private renderWrapped(lines: string[], w: number): void {
1299
+ const { theme } = this.deps;
1300
+ const report = this.state.report;
1301
+ if (!report) return;
1302
+
1303
+ const year = this.state.wrappedYear;
1304
+ const stats = wrappedStats(report, year);
1305
+ const years = availableYears(report);
1306
+ const pose = mascotPose("wrapped", stats);
1307
+ const mascot = renderWrappedMascot(pose, theme);
1308
+
1309
+ lines.push(this.wrappedBannerLine(years, w));
1310
+ lines.push("");
1311
+
1312
+ if (!stats) {
1313
+ this.appendMascotBlock(lines, mascot, w, (content) => {
1314
+ content.push(` ${theme.fg("muted", "No activity recorded")}`);
1315
+ content.push(` ${theme.fg("dim", `Nothing to summarize for ${year}.`)}`);
1316
+ if (years.length > 0) {
1317
+ const instruction = this.portableRendering
1318
+ ? "select a year from the action menu"
1319
+ : "[ ] to switch";
1320
+ content.push(
1321
+ ` ${theme.fg("dim", `Available: ${years.map(String).join(", ")} · ${instruction}`)}`,
1322
+ );
1323
+ }
1324
+ });
1325
+ lines.push("");
1326
+ return;
1327
+ }
1328
+
1329
+ this.appendMascotBlock(lines, mascot, w, (content) => {
1330
+ this.appendWrappedHero(content, stats, w);
1331
+ });
1332
+ lines.push("");
1333
+ lines.push(this.wrappedSectionHeader("Highlights", w));
1334
+ this.appendWrappedHighlights(lines, stats, w);
1335
+ lines.push("");
1336
+ lines.push(this.wrappedSectionHeader("Monthly activity", w));
1337
+ this.appendWrappedMonthly(lines, stats, w);
1338
+ lines.push("");
1339
+ lines.push(this.wrappedSectionHeader("Rankings", w));
1340
+ this.appendWrappedTops(lines, stats, w);
1341
+ lines.push("");
1342
+ const caption = wrappedMascotCaption(stats, year);
1343
+ lines.push(this.wrappedInsightBox(caption, pose, w));
1344
+ lines.push("");
1345
+ }
1346
+
1347
+ /** Hairline section label — matches Stats/Daily report rhythm. */
1348
+ private wrappedSectionHeader(title: string, width: number): string {
1349
+ const { theme } = this.deps;
1350
+ const label = ` ${theme.fg("muted", title)} `;
1351
+ const ruleW = Math.max(4, width - visibleWidth(label) - 2);
1352
+ return `${label}${theme.fg("borderMuted", "─".repeat(ruleW))}`;
1353
+ }
1354
+
1355
+ /** Pi-chan footer card — character accent, professional tone. */
1356
+ private wrappedInsightBox(
1357
+ caption: string,
1358
+ pose: ReturnType<typeof mascotPose>,
1359
+ width: number,
1360
+ ): string {
1361
+ const { theme } = this.deps;
1362
+ const face = renderWrappedMascot(pose, theme)[1] ?? "";
1363
+ const tag = theme.fg("accent", "Pi-chan");
1364
+ const body = theme.fg("text", truncateToWidth(caption, Math.max(20, width - 24)));
1365
+ const inner = `${tag} ${body}`;
1366
+ const pad = Math.max(0, width - visibleWidth(face) - visibleWidth(inner) - 6);
1367
+ return ` ${face} ${theme.fg("borderMuted", "│")} ${inner}${" ".repeat(pad)}`;
1368
+ }
1369
+
1370
+ /** Side-by-side mascot + content when the terminal is wide enough. */
1371
+ private appendMascotBlock(
1372
+ lines: string[],
1373
+ mascot: string[],
1374
+ width: number,
1375
+ renderContent: (content: string[]) => void,
1376
+ ): void {
1377
+ const { theme } = this.deps;
1378
+ const content: string[] = [];
1379
+ renderContent(content);
1380
+ const mascotW = 14;
1381
+ const gap = 2;
1382
+ const sideBySide = width >= 72 && mascot.length > 0;
1383
+
1384
+ if (!sideBySide) {
1385
+ for (const m of mascot) lines.push(m);
1386
+ lines.push("");
1387
+ lines.push(...content);
1388
+ return;
1389
+ }
1390
+
1391
+ const rule = theme.fg("borderMuted", "│");
1392
+ const rows = Math.max(mascot.length, content.length);
1393
+ for (let i = 0; i < rows; i++) {
1394
+ const left = (mascot[i] ?? "").padEnd(mascotW);
1395
+ const right = content[i] ?? "";
1396
+ if (right) {
1397
+ lines.push(`${left}${rule}${" ".repeat(gap)}${right}`);
1398
+ } else if (left.trim()) {
1399
+ lines.push(left);
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ private wrappedBannerLine(years: number[], width: number): string {
1405
+ const { theme } = this.deps;
1406
+ const year = this.state.wrappedYear;
1407
+ const title = theme.fg("accent", theme.bold(" Wrapped "));
1408
+ const yearBadge = theme.bg("selectedBg", theme.fg("text", theme.bold(` ${year} `)));
1409
+ const nav =
1410
+ years.length > 1
1411
+ ? theme.fg("dim", this.portableRendering ? " choose year below" : " ◂ [ ] ▸ · y")
1412
+ : theme.fg("dim", " single year");
1413
+ const left = ` ${title}${yearBadge}${nav}`;
1414
+ const pad = Math.max(0, width - visibleWidth(left) - 2);
1415
+ return `${left}${theme.fg("borderMuted", "─".repeat(Math.max(2, pad)))}`;
1416
+ }
1417
+
1418
+ private appendWrappedHero(lines: string[], stats: WrappedStats, w: number): void {
1419
+ const { theme } = this.deps;
1420
+ const headline =
1421
+ stats.metric === "tokens" ? formatTokens(stats.totalTokens) : formatCost(stats.totalCost);
1422
+ const unit = stats.metric === "tokens" ? "tokens" : "estimated spend";
1423
+ lines.push(` ${theme.fg("text", theme.bold(headline))} ${theme.fg("muted", unit)}`);
1424
+ const kpis = [
1425
+ `${formatInt(stats.totalTurns)} turns`,
1426
+ `${stats.activeDays} active days`,
1427
+ `${stats.modelCount} models`,
1428
+ `${stats.providerCount} providers`,
1429
+ ];
1430
+ lines.push(` ${kpis.map((k) => theme.fg("dim", k)).join(theme.fg("borderMuted", " · "))}`);
1431
+ lines.push("");
1432
+
1433
+ const pairs: Array<[string, string]> = [
1434
+ ["Favorite model", stats.favoriteModel ?? "—"],
1435
+ ["Top provider", stats.favoriteProvider ?? "—"],
1436
+ ["Busiest day", stats.busiestDay ? formatDayLabel(stats.busiestDay.dateKey) : "—"],
1437
+ ["Peak hour", stats.peakHour != null ? formatHour(stats.peakHour) : "—"],
1438
+ ["Longest streak", `${stats.longestStreak} day${stats.longestStreak === 1 ? "" : "s"}`],
1439
+ [
1440
+ "Avg / active day",
1441
+ stats.metric === "tokens"
1442
+ ? formatTokens(Math.round(stats.avgPerActiveDay))
1443
+ : formatCost(stats.avgPerActiveDay),
1444
+ ],
1445
+ ];
1446
+ this.appendStatGrid(lines, pairs, w);
1447
+ }
1448
+
1449
+ private appendWrappedMonthly(lines: string[], stats: WrappedStats, w: number): void {
1450
+ if (w < 54) {
1451
+ this.appendWrappedMonthlyRows(lines, stats, w);
1452
+ return;
1453
+ }
1454
+ this.appendWrappedMonthlyHeatmap(lines, stats, w);
1455
+ }
1456
+
1457
+ /**
1458
+ * Claude Code / Stats-style vertical month columns: graded blocks, month
1459
+ * labels, Less→More legend, and a peak-month callout.
1460
+ */
1461
+ private appendWrappedMonthlyHeatmap(lines: string[], stats: WrappedStats, width: number): void {
1462
+ const { theme } = this.deps;
1463
+ const values = stats.monthlyTokens;
1464
+ const max = Math.max(...values, 1);
1465
+ const colW = width >= 68 ? 3 : 2;
1466
+ const gap = colW === 3 ? " " : "";
1467
+ const barH = width >= 68 ? 5 : 4;
1468
+ const block = "█".repeat(colW);
1469
+ const empty = "·".repeat(colW);
1470
+
1471
+ const unitLabel = "tokens by month";
1472
+ lines.push(` ${theme.fg("dim", unitLabel)}`);
1473
+
1474
+ const levels = values.map((v) => this.heatmapLevel(v / max));
1475
+ const filledRows = values.map((v) => (v > 0 ? Math.max(1, Math.round((v / max) * barH)) : 0));
1476
+
1477
+ for (let row = 0; row < barH; row++) {
1478
+ let line = " ";
1479
+ for (let m = 0; m < 12; m++) {
1480
+ const rowFromBottom = barH - 1 - row;
1481
+ if (filledRows[m] > 0 && rowFromBottom < filledRows[m]) {
1482
+ line += theme.fg(this.heatmapColor(levels[m]), block);
1483
+ } else {
1484
+ line += theme.fg("borderMuted", empty);
1485
+ }
1486
+ if (m < 11) line += gap;
1487
+ }
1488
+ lines.push(line);
1489
+ }
1490
+
1491
+ let labelLine = " ";
1492
+ for (let m = 0; m < 12; m++) {
1493
+ const lab = colW >= 3 ? monthLabel(m + 1).slice(0, 3) : monthLabel(m + 1).slice(0, 1);
1494
+ labelLine += theme.fg("dim", lab.padEnd(colW));
1495
+ if (m < 11) labelLine += gap;
1496
+ }
1497
+ lines.push(labelLine);
1498
+
1499
+ let legend = ` ${theme.fg("dim", "Less ")}`;
1500
+ legend += theme.fg("borderMuted", empty);
1501
+ for (let l = 1; l < 5; l++) {
1502
+ legend += theme.fg(this.heatmapColor(l), block);
1503
+ }
1504
+ legend += theme.fg("dim", " More");
1505
+ lines.push(legend);
1506
+
1507
+ const activeMonths = values.filter((v) => v > 0).length;
1508
+ let peakIdx = 0;
1509
+ let peakVal = 0;
1510
+ for (let i = 0; i < 12; i++) {
1511
+ if (values[i] > peakVal) {
1512
+ peakVal = values[i];
1513
+ peakIdx = i;
1514
+ }
1515
+ }
1516
+ if (peakVal > 0) {
1517
+ lines.push(
1518
+ ` ${theme.fg("muted", "Peak month")} ${theme.fg("text", monthLabel(peakIdx + 1))}` +
1519
+ ` ${theme.fg("dim", formatTokens(peakVal))}` +
1520
+ ` ${theme.fg("borderMuted", "·")} ${theme.fg("dim", `${activeMonths} active month${activeMonths === 1 ? "" : "s"}`)}`,
1521
+ );
1522
+ } else {
1523
+ lines.push(
1524
+ ` ${theme.fg("dim", `${activeMonths} active month${activeMonths === 1 ? "" : "s"}`)}`,
1525
+ );
1526
+ }
1527
+ }
1528
+
1529
+ /** Narrow-terminal fallback: horizontal share bars (same geometry as Rankings). */
1530
+ private appendWrappedMonthlyRows(lines: string[], stats: WrappedStats, width: number): void {
1531
+ const { theme } = this.deps;
1532
+ const values = stats.monthlyTokens;
1533
+ const max = Math.max(...values, 1);
1534
+ const total = values.reduce((s, v) => s + v, 0);
1535
+ const labelW = 5;
1536
+ const barW = Math.max(6, Math.min(14, width - labelW - 16));
1537
+ const valueW = 8;
1538
+
1539
+ lines.push(` ${theme.fg("dim", "tokens by month")}`);
1540
+ for (let m = 0; m < 12; m++) {
1541
+ const v = values[m];
1542
+ const name = monthLabel(m + 1)
1543
+ .slice(0, 3)
1544
+ .padEnd(labelW);
1545
+ if (v <= 0) {
1546
+ lines.push(
1547
+ ` ${theme.fg("dim", name)} ${theme.fg("borderMuted", "·".repeat(barW))} ${theme.fg("dim", "—".padStart(valueW))}`,
1548
+ );
1549
+ continue;
1550
+ }
1551
+ const ratio = v / max;
1552
+ const filled = Math.max(1, Math.round(ratio * barW));
1553
+ const level = this.heatmapLevel(ratio);
1554
+ const bar =
1555
+ theme.fg(this.heatmapColor(level), "█".repeat(filled)) +
1556
+ theme.fg("borderMuted", "░".repeat(barW - filled));
1557
+ const pct = total > 0 ? `${Math.round((v / total) * 100)}%`.padStart(4) : " —";
1558
+ lines.push(
1559
+ ` ${theme.fg("text", name)} ${theme.fg("muted", pct)} ${bar} ${theme.fg("dim", formatTokens(v).padStart(valueW))}`,
1560
+ );
1561
+ }
1562
+ }
1563
+
1564
+ private appendWrappedHighlights(lines: string[], stats: WrappedStats, w: number): void {
1565
+ const topProj = stats.topProject
1566
+ ? truncateToWidth(shortenPath(stats.topProject, this.deps.home), Math.max(20, w - 28))
1567
+ : "—";
1568
+ const pairs: Array<[string, string]> = [
1569
+ ["Models used", `${stats.modelCount}`],
1570
+ ["Providers", `${stats.providerCount}`],
1571
+ ["Projects", `${stats.projectCount}`],
1572
+ ["Top project", topProj],
1573
+ ];
1574
+ this.appendStatGrid(lines, pairs, w);
1575
+ }
1576
+
1577
+ private appendWrappedTops(lines: string[], stats: WrappedStats, w: number): void {
1578
+ const { theme } = this.deps;
1579
+ const labelW = Math.max(16, Math.min(28, Math.floor(w * 0.38)));
1580
+ const barW = Math.max(6, Math.min(16, w - labelW - 22));
1581
+ const pctW = 5;
1582
+
1583
+ lines.push(` ${theme.fg("muted", "Models")}`);
1584
+ if (stats.topModels.length === 0) {
1585
+ lines.push(` ${theme.fg("dim", "—")}`);
1586
+ }
1587
+ for (const m of stats.topModels) {
1588
+ const name = truncateToWidth(m.name, labelW).padEnd(labelW);
1589
+ const pct = `${Math.round(m.pct)}%`.padStart(pctW);
1590
+ const filled = Math.max(m.pct > 0 ? 1 : 0, Math.round((m.pct / 100) * barW));
1591
+ const bar =
1592
+ theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
1593
+ lines.push(
1594
+ ` ${theme.fg("text", name)} ${theme.fg("muted", pct)} ${bar} ${theme.fg("dim", formatTokens(m.tokens))}`,
1595
+ );
1596
+ }
1597
+
1598
+ lines.push("");
1599
+ lines.push(` ${theme.fg("muted", "Providers")}`);
1600
+ if (stats.topProviders.length === 0) {
1601
+ lines.push(` ${theme.fg("dim", "—")}`);
1602
+ }
1603
+ for (const p of stats.topProviders) {
1604
+ const name = truncateToWidth(p.name, labelW).padEnd(labelW);
1605
+ const pct = `${Math.round(p.pct)}%`.padStart(pctW);
1606
+ const filled = Math.max(p.pct > 0 ? 1 : 0, Math.round((p.pct / 100) * barW));
1607
+ const bar =
1608
+ theme.fg("success", "█".repeat(filled)) +
1609
+ theme.fg("borderMuted", "░".repeat(barW - filled));
1610
+ lines.push(
1611
+ ` ${theme.fg("text", name)} ${theme.fg("muted", pct)} ${bar} ${theme.fg("dim", formatTokens(p.tokens))}`,
1612
+ );
1613
+ }
1614
+ }
1615
+
1616
+ // ------------------------------------------------------------------- Stats
1617
+
1618
+ private renderStats(lines: string[], w: number): void {
1619
+ const { theme } = this.deps;
1620
+ const report = this.state.report;
1621
+ if (!report) return;
1622
+
1623
+ const since = rangeSince(this.state.statsRange);
1624
+ const stats = computeStats(report, undefined, since);
1625
+ // The calendar always shows the trailing ~year (GitHub-style), regardless
1626
+ // of the summary range below it.
1627
+ const graph = contributionGraph(report, 53, stats.metric);
1628
+ const fmt = (n: number) => (stats.metric === "tokens" ? formatTokens(n) : formatCost(n));
1629
+ const headline =
1630
+ stats.metric === "tokens"
1631
+ ? `${formatTokens(stats.totalTokens)} tokens`
1632
+ : formatCost(stats.totalCost);
1633
+
1634
+ // Title + interactive range selector.
1635
+ lines.push(this.statsRangeLine());
1636
+ lines.push("");
1637
+ this.appendContribGraph(lines, graph, w);
1638
+ lines.push("");
1639
+
1640
+ const fmtKey = (k: string | null) => (k ? formatDayLabel(k) : "—");
1641
+ const dayWord = (n: number) => `${n} day${n === 1 ? "" : "s"}`;
1642
+ const pairs: Array<[string, string]> = [
1643
+ ["Total", headline],
1644
+ ["Total turns", formatInt(stats.totalTurns)],
1645
+ ["Active days", `${stats.activeDays}`],
1646
+ ["Favorite model", stats.favoriteModel ?? "—"],
1647
+ ["Current streak", dayWord(stats.currentStreak)],
1648
+ ["Longest streak", dayWord(stats.longestStreak)],
1649
+ ["Busiest day", stats.busiestDay ? fmtKey(stats.busiestDay.dateKey) : "—"],
1650
+ ["Peak hour", stats.peakHour != null ? formatHour(stats.peakHour) : "—"],
1651
+ ["First activity", fmtKey(stats.firstDay)],
1652
+ ["Avg / active day", fmt(stats.avgPerActiveDay)],
1653
+ ];
1654
+ this.appendStatGrid(lines, pairs, w);
1655
+
1656
+ const fun = this.statsFunFact(stats);
1657
+ if (fun) {
1658
+ lines.push("");
1659
+ lines.push(` ${theme.fg("accent", fun)}`);
1660
+ }
1661
+ lines.push("");
1662
+ }
1663
+
1664
+ /** Interactive range selector for the Stats view (All / 7d / 30d). */
1665
+ private statsRangeLine(): string {
1666
+ const { theme } = this.deps;
1667
+ const ranges: StatsRange[] = ["all", "7d", "30d"];
1668
+ const tabs = ranges
1669
+ .map((r) => {
1670
+ const text = ` ${rangeLabel(r)} `;
1671
+ return r === this.state.statsRange
1672
+ ? theme.bg("selectedBg", theme.fg("accent", theme.bold(text)))
1673
+ : theme.fg("dim", text);
1674
+ })
1675
+ .join(theme.fg("borderMuted", "│"));
1676
+ return ` ${theme.fg("muted", "Stats")} ${tabs}`;
1677
+ }
1678
+
1679
+ /** Render stat pairs in two aligned columns. */
1680
+ private appendStatGrid(lines: string[], pairs: Array<[string, string]>, width: number): void {
1681
+ const { theme } = this.deps;
1682
+ const step = width < 54 ? 1 : 2;
1683
+ // Single-column mode (narrow terminals) gets the full row width so values
1684
+ // like "2 (inferred)" aren't needlessly truncated to the half-width cell.
1685
+ const colW = step === 1 ? width - 2 : Math.max(24, Math.floor((width - 2) / 2));
1686
+ const labelW = 16;
1687
+ const cell = (label: string, value: string) => {
1688
+ const v = truncateToWidth(value, Math.max(6, colW - labelW - 1));
1689
+ return `${theme.fg("muted", label.padEnd(labelW))} ${theme.fg("text", v)}`;
1690
+ };
1691
+ for (let i = 0; i < pairs.length; i += step) {
1692
+ const left = cell(pairs[i][0], pairs[i][1]);
1693
+ let line = ` ${left}`;
1694
+ const next = step === 2 ? pairs[i + 1] : undefined;
1695
+ if (next) {
1696
+ const pad = Math.max(2, colW - visibleWidth(left));
1697
+ line += `${" ".repeat(pad)}${cell(next[0], next[1])}`;
1698
+ }
1699
+ lines.push(line);
1700
+ }
1701
+ }
1702
+
1703
+ /** A playful one-liner comparing total usage to a familiar reference. */
1704
+ private statsFunFact(stats: {
1705
+ totalTokens: number;
1706
+ totalCost: number;
1707
+ metric: "usd" | "tokens";
1708
+ }): string | null {
1709
+ // The Great Gatsby ≈ 47k words ≈ ~62k tokens.
1710
+ const GATSBY_TOKENS = 62000;
1711
+ if (stats.totalTokens >= GATSBY_TOKENS) {
1712
+ const ratio = stats.totalTokens / GATSBY_TOKENS;
1713
+ return `You've used ~${formatInt(ratio)}x more tokens than The Great Gatsby`;
1714
+ }
1715
+ if (stats.metric === "usd" && stats.totalCost > 0) {
1716
+ return `Total spend across these sessions: ${formatCost(stats.totalCost)}`;
1717
+ }
1718
+ return null;
1719
+ }
1720
+
1721
+ /**
1722
+ * GitHub-style contribution heatmap: a month-label header row, then 7 day
1723
+ * rows (Sun..Sat) of graded square cells, then a Less→More legend.
1724
+ */
1725
+ private heatmapColor(level: number): ThemeColor {
1726
+ switch (level) {
1727
+ case 4:
1728
+ return "accent";
1729
+ case 3:
1730
+ return "success";
1731
+ case 2:
1732
+ return "warning";
1733
+ case 1:
1734
+ return "muted";
1735
+ default:
1736
+ return "borderMuted";
1737
+ }
1738
+ }
1739
+
1740
+ /** Map a 0–1 usage ratio to heatmap intensity (matches Stats view). */
1741
+ private heatmapLevel(ratio: number): number {
1742
+ if (ratio <= 0) return 0;
1743
+ if (ratio >= 0.75) return 4;
1744
+ if (ratio >= 0.5) return 3;
1745
+ if (ratio >= 0.25) return 2;
1746
+ return 1;
1747
+ }
1748
+
1749
+ private appendContribGraph(lines: string[], graph: ContribGraph, width: number): void {
1750
+ const { theme } = this.deps;
1751
+ const colorFor = (level: number) => this.heatmapColor(level);
1752
+ // Active days are solid 2-wide blocks so consecutive activity fuses into
1753
+ // chunky, seamless squares (the tokscale / Claude Code look). Inactive
1754
+ // days stay a faint dot on the dark background — never a filled block —
1755
+ // so only real activity is colored.
1756
+ const cellW = 2;
1757
+ const block = "█".repeat(cellW);
1758
+ const empty = "·".padEnd(cellW);
1759
+
1760
+ // Left gutter holds the weekday labels; keep month header aligned to it.
1761
+ const gutter = 5;
1762
+ const leftPad = gutter + 1;
1763
+ const maxWeeks = Math.max(6, Math.floor((width - leftPad - 1) / cellW));
1764
+ const weeks =
1765
+ graph.weeks.length > maxWeeks
1766
+ ? graph.weeks.slice(graph.weeks.length - maxWeeks)
1767
+ : graph.weeks;
1768
+
1769
+ // Month-label header: place each month abbreviation at the week where it
1770
+ // first appears (GitHub-style), so the timeline reads left→right.
1771
+ const firstTs = (col: Array<{ ts: number } | null>): number | null => {
1772
+ for (const c of col) if (c) return c.ts;
1773
+ return null;
1774
+ };
1775
+ const monthRow = new Array<string>(weeks.length * cellW).fill(" ");
1776
+ let lastMonth = -1;
1777
+ for (let i = 0; i < weeks.length; i++) {
1778
+ const ts = firstTs(weeks[i]);
1779
+ if (ts == null) continue;
1780
+ const mon = new Date(ts).getMonth();
1781
+ if (mon !== lastMonth) {
1782
+ lastMonth = mon;
1783
+ const label = monthLabel(mon + 1);
1784
+ const at = i * cellW;
1785
+ for (let k = 0; k < label.length && at + k < monthRow.length; k++) {
1786
+ monthRow[at + k] = label[k];
1787
+ }
1788
+ }
1789
+ }
1790
+ lines.push(`${" ".repeat(leftPad)}${theme.fg("dim", monthRow.join(""))}`);
1791
+
1792
+ const dowLabels = ["", "Mon", "", "Wed", "", "Fri", ""];
1793
+ for (let row = 0; row < 7; row++) {
1794
+ let line = ` ${theme.fg("dim", (dowLabels[row] ?? "").padEnd(gutter - 2))} `;
1795
+ for (const col of weeks) {
1796
+ const cell = col[row];
1797
+ if (!cell) {
1798
+ line += " ".repeat(cellW);
1799
+ continue;
1800
+ }
1801
+ // Inactive day: faint dot on the dark background (no fill).
1802
+ if (cell.level === 0) {
1803
+ line += theme.fg("borderMuted", empty);
1804
+ continue;
1805
+ }
1806
+ line += theme.fg(colorFor(cell.level), block);
1807
+ }
1808
+ lines.push(line);
1809
+ }
1810
+
1811
+ let legend = ` ${theme.fg("dim", "Less ")}`;
1812
+ legend += theme.fg("borderMuted", empty);
1813
+ for (let l = 1; l < 5; l++) legend += theme.fg(colorFor(l), block);
1814
+ legend += theme.fg("dim", " More");
1815
+ lines.push(legend);
1816
+ }
1817
+
1818
+ /** Sparkline of the last 30 active-window days (Overview trend strip). */
1819
+ private appendTrendSparkline(lines: string[], _w: number): void {
1820
+ const { theme } = this.deps;
1821
+ const report = this.state.report;
1822
+ if (!report) return;
1823
+ const days = dailyStats(report);
1824
+ if (days.length === 0) return;
1825
+ const metric = naturalMetric(days);
1826
+ const recent = days.slice(-30);
1827
+ const values = recent.map((d) => metricValue(d.bucket, metric));
1828
+ const spark = sparkline(values);
1829
+ const span =
1830
+ recent.length > 1
1831
+ ? `${formatDayLabel(recent[0].dateKey).slice(4)} → ${formatDayLabel(recent[recent.length - 1].dateKey).slice(4)}`
1832
+ : formatDayLabel(recent[0].dateKey).slice(4);
1833
+ lines.push(
1834
+ ` ${theme.fg("muted", "Trend")} ${theme.fg("accent", spark)} ${theme.fg("dim", span)}`,
1835
+ );
1836
+ lines.push("");
1837
+ }
1838
+
1839
+ private portableMenuLines(width: number): string[] {
1840
+ const { theme } = this.deps;
1841
+ const current = VIEW_TABS[this.state.view].label;
1842
+ const views = VIEW_ORDER.map((key) => VIEW_TABS[key].short).join(" · ");
1843
+ return [
1844
+ ` ${theme.fg("accent", current)} ${theme.fg("dim", `(${VIEW_ORDER.indexOf(this.state.view) + 1}/${VIEW_ORDER.length})`)}`,
1845
+ ` ${theme.fg("dim", truncateToWidth(`Views: ${views}`, Math.max(10, width - 2)))}`,
1846
+ ];
1847
+ }
1848
+
1849
+ private menuLines(width: number): string[] {
1850
+ const { theme } = this.deps;
1851
+ const renderTab = (key: ViewKey, num: number, icon: boolean, long: boolean): string => {
1852
+ const tab = VIEW_TABS[key];
1853
+ const text = icon
1854
+ ? ` ${tab.icon}${num} ${long ? tab.label : tab.short} `
1855
+ : ` ${num} ${tab.short} `;
1856
+ return key === this.state.view
1857
+ ? theme.bg("selectedBg", theme.fg(tab.color, theme.bold(text)))
1858
+ : theme.fg("dim", text);
1859
+ };
1860
+ const rail = theme.fg("borderMuted", "╭─ views ");
1861
+ const close = theme.fg("borderMuted", " ─╮");
1862
+ const sep = theme.fg("borderMuted", " │ ");
1863
+
1864
+ // Pick the richest tab style that fits: full labels, then short labels,
1865
+ // then iconless short labels. An 8-tab rail simply cannot fit below ~90
1866
+ // columns, so the fallback centers the active tab with its neighbours.
1867
+ let row1 = "";
1868
+ for (const variant of [
1869
+ { icon: true, long: true },
1870
+ { icon: true, long: false },
1871
+ { icon: false, long: false },
1872
+ ]) {
1873
+ const tabs = VIEW_ORDER.map((key, i) =>
1874
+ renderTab(key, i + 1, variant.icon, variant.long),
1875
+ ).join(sep);
1876
+ const pad = Math.max(
1877
+ 0,
1878
+ width - visibleWidth(rail) - visibleWidth(tabs) - visibleWidth(close) - 2,
1879
+ );
1880
+ const candidate = `${rail}${tabs}${" ".repeat(pad)}${close}`;
1881
+ row1 = candidate;
1882
+ if (visibleWidth(candidate) <= width) break;
1883
+ row1 = "";
1884
+ }
1885
+ if (!row1) {
1886
+ const idx = VIEW_ORDER.indexOf(this.state.view);
1887
+ const prev = VIEW_TABS[VIEW_ORDER[(idx - 1 + VIEW_ORDER.length) % VIEW_ORDER.length]];
1888
+ const next = VIEW_TABS[VIEW_ORDER[(idx + 1) % VIEW_ORDER.length]];
1889
+ const cur = VIEW_TABS[this.state.view];
1890
+ const active = theme.bg(
1891
+ "selectedBg",
1892
+ theme.fg(cur.color, theme.bold(` ${cur.icon}${idx + 1} ${cur.label} `)),
1893
+ );
1894
+ row1 = ` ${theme.fg("dim", `‹ ${prev.short}`)} ${active} ${theme.fg("dim", `${next.short} ›`)} ${theme.fg("dim", `${idx + 1}/${VIEW_ORDER.length}`)}`;
1895
+ }
1896
+
1897
+ const pose = mascotPose(this.state.view);
1898
+ const miniMascot = renderMascot(pose, theme)[1] ?? "";
1899
+ const quip = mascotQuip(
1900
+ this.state.view,
1901
+ this.state.view === "wrapped" && this.state.report
1902
+ ? wrappedStats(this.state.report, this.state.wrappedYear)
1903
+ : null,
1904
+ );
1905
+ // Drop the quip on narrow terminals so the key hints survive clamping;
1906
+ // truncate it to whatever room remains left of the hint on mid widths.
1907
+ const hintRight = theme.fg("dim", width < 64 ? "Tab · ←→" : "Tab · ←→ · 1-8 jump");
1908
+ const quipRoom =
1909
+ width - visibleWidth(miniMascot) - visibleWidth("Pi-chan") - visibleWidth(hintRight) - 9;
1910
+ const quipText = width >= 64 && quipRoom > 8 ? truncateToWidth(quip, quipRoom) : "";
1911
+ const hintLeft = quipText
1912
+ ? `${theme.fg("success", "Pi-chan")} ${theme.fg("muted", quipText)}`
1913
+ : `${theme.fg("success", "Pi-chan")}`;
1914
+ const hintPad = Math.max(
1915
+ 2,
1916
+ width - visibleWidth(miniMascot) - visibleWidth(hintLeft) - visibleWidth(hintRight) - 4,
1917
+ );
1918
+ const row2 = ` ${miniMascot} ${hintLeft}${" ".repeat(hintPad)}${hintRight}`;
1919
+
1920
+ return [row1, row2];
1921
+ }
1922
+
1923
+ private titleLineRaw(width: number): string {
1924
+ const { theme } = this.deps;
1925
+ const title = theme.fg("accent", theme.bold(" Usage "));
1926
+ const tabs = this.windowTabs();
1927
+ const dots = Math.max(2, width - visibleWidth(title) - visibleWidth(tabs) - 2);
1928
+ return `${title}${theme.fg("borderMuted", "─".repeat(dots))}${tabs}`;
1929
+ }
1930
+
1931
+ private windowTabs(): string {
1932
+ const { theme } = this.deps;
1933
+ const cur = this.state.windowKey;
1934
+ const tabs: string[] = [];
1935
+ for (const key of ["5h", "24h", "7d", "all"] as WindowKey[]) {
1936
+ const label = key.toUpperCase().replace("24H", "DAY").replace("7D", "WEEK");
1937
+ const text = ` ${label} `;
1938
+ tabs.push(
1939
+ key === cur
1940
+ ? theme.bg("selectedBg", theme.fg("accent", theme.bold(text)))
1941
+ : theme.fg("dim", text),
1942
+ );
1943
+ }
1944
+ return tabs.join(theme.fg("borderMuted", "│"));
1945
+ }
1946
+
1947
+ private subheaderLine(win: WindowedReport, width: number): string {
1948
+ const { theme } = this.deps;
1949
+ const left = theme.fg("muted", ` Showing: ${labelForWindow(win.window)}`);
1950
+ const ago = win.latest > 0 ? `last activity ${relativeTime(win.latest)}` : "no activity";
1951
+ const right = theme.fg(
1952
+ "dim",
1953
+ width < 72 ? `${win.sessionCount} sessions ` : `${ago} · ${win.sessionCount} sessions `,
1954
+ );
1955
+ const pad = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
1956
+ return left + " ".repeat(pad) + right;
1957
+ }
1958
+
1959
+ private quotaLine(
1960
+ label: string,
1961
+ bucket: Bucket,
1962
+ limit: number | undefined,
1963
+ width: number,
1964
+ unit: "usd" | "tokens" = "usd",
1965
+ ): string {
1966
+ const { theme } = this.deps;
1967
+ // Pick the measured value for the chosen unit. When a provider has no
1968
+ // pricing (cost === 0 across the window) dollars are meaningless, so the
1969
+ // caller switches the unit to tokens — the real usage signal.
1970
+ const used = unit === "tokens" ? bucketTokens(bucket) : bucket.cost;
1971
+ const hasLimit = typeof limit === "number" && limit > 0;
1972
+ const ratio = hasLimit ? used / limit : 0;
1973
+ const color: ThemeColor = ratio >= 1 ? "error" : ratio >= 0.85 ? "warning" : "success";
1974
+
1975
+ const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
1976
+ const labelW = 16;
1977
+ const lbl = truncateToWidth(label, labelW).padEnd(labelW);
1978
+ const barW = Math.max(8, Math.min(28, width - labelW - 34));
1979
+ const filled = hasLimit
1980
+ ? Math.round(Math.min(1, ratio) * barW)
1981
+ : Math.min(
1982
+ barW,
1983
+ Math.max(
1984
+ used > 0 ? 1 : 0,
1985
+ Math.round(Math.sqrt(Math.max(0, used)) * (unit === "tokens" ? 0.02 : 2)),
1986
+ ),
1987
+ );
1988
+ const barStr =
1989
+ theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
1990
+
1991
+ let right: string;
1992
+ if (hasLimit) {
1993
+ right = `${fmt(used)} / ${fmt(limit as number)} (${percent(used, limit as number)})`;
1994
+ } else {
1995
+ const hint = this.portableRendering
1996
+ ? unit === "tokens"
1997
+ ? "(no token budget — choose Configure)"
1998
+ : "(no limit set — choose Configure)"
1999
+ : unit === "tokens"
2000
+ ? "(no token budget — press s)"
2001
+ : "(no limit set — press s)";
2002
+ right = `${fmt(used)} ${theme.fg("dim", hint)}`;
2003
+ }
2004
+ return ` ${theme.fg("text", lbl)} ${barStr} ${theme.fg("muted", right)}`;
2005
+ }
2006
+
2007
+ /**
2008
+ * Render an upstream-reported percentage quota bar (e.g. ZAI 5h/weekly).
2009
+ * The provider only exposes `usedPct` (0-100) + a reset countdown, so the bar
2010
+ * shows used%, remaining%, and when the window resets.
2011
+ */
2012
+ private percentLine(
2013
+ label: string,
2014
+ window: { usedPct: number; resetMs: number },
2015
+ width: number,
2016
+ unit: "usd" | "tokens",
2017
+ sessionBucket?: Bucket,
2018
+ ): string {
2019
+ const { theme } = this.deps;
2020
+ const pct = Math.max(0, Math.min(100, window.usedPct));
2021
+ const remaining = 100 - pct;
2022
+ const color: ThemeColor = pct >= 90 ? "error" : pct >= 75 ? "warning" : "success";
2023
+
2024
+ const labelW = 16;
2025
+ const lbl = truncateToWidth(label, labelW).padEnd(labelW);
2026
+
2027
+ // Session-derived cost/tokens in the same window, combined with the
2028
+ // upstream percentage. Format: "<pct>% used / $<cost> · <rem>% left · resets X".
2029
+ let sessionText = "";
2030
+ if (sessionBucket) {
2031
+ const value = unit === "tokens" ? bucketTokens(sessionBucket) : sessionBucket.cost;
2032
+ const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
2033
+ // Always show the session value, even if 0 (so the user sees the bar
2034
+ // is genuinely empty for the chosen unit, not just hidden).
2035
+ sessionText = ` / ${fmt(value)}`;
2036
+ }
2037
+
2038
+ const right = `${pct}% used${sessionText} · ${remaining}% left${
2039
+ window.resetMs ? ` · resets ${countdown(window.resetMs)}` : ""
2040
+ }`;
2041
+ const rightW = visibleWidth(right);
2042
+ const barW = Math.max(8, Math.min(28, width - labelW - rightW - 6));
2043
+ // Guarantee at least one filled cell when usedPct > 0, so a low-usage
2044
+ // window (e.g. 1% of 5h) is still visible rather than rendering as a
2045
+ // fully-empty bar that looks like "nothing".
2046
+ const filled = pct > 0 ? Math.max(1, Math.round((pct / 100) * barW)) : 0;
2047
+ const barStr =
2048
+ theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
2049
+ return ` ${theme.fg("text", lbl)} ${barStr} ${theme.fg("muted", right)}`;
2050
+ }
2051
+
2052
+ /**
2053
+ * Build a context-aware hint explaining why the subscription quota isn't
2054
+ * shown yet. Subscriptions (OpenAI Codex, ZAI coding plans) get their quota
2055
+ * from the upstream — the panel must never suggest `/usage-config` for
2056
+ * these, because that would be wrong (it would just aggregate session
2057
+ * history, not the real plan quota).
2058
+ */
2059
+ private buildSubscriptionHint(provider: string): string {
2060
+ const notes = this.state.providerQuota?.notes ?? [];
2061
+ if (provider === "zai") {
2062
+ // ZAI quota comes from the monitor endpoint. If we have notes from
2063
+ // fetchProviderQuota, surface them (e.g. token expired).
2064
+ return notes[0] ?? "No upstream quota yet — press r to refresh, or make a request to retry.";
2065
+ }
2066
+ // openai-codex: quota comes from response headers (`x-codex-*`) captured by
2067
+ // pi on every Codex request. If we haven't made one this session, no
2068
+ // headers are captured yet.
2069
+ if (notes.some((n) => n.includes("expired") || n.includes("sign in"))) {
2070
+ return "OpenAI Codex token expired — sign in to Codex CLI (`codex auth`), then press r to refresh.";
2071
+ }
2072
+ return "No Codex headers captured yet — make any request to refresh, then press r.";
2073
+ }
2074
+
2075
+ private appendTokenComposition(lines: string[], win: WindowedReport, width: number): void {
2076
+ const { theme } = this.deps;
2077
+ const t = win.total;
2078
+ lines.push(` ${theme.fg("accent", theme.bold("Token composition"))}`);
2079
+ const rows: Array<[string, number, number]> = [
2080
+ ["Input", t.input, t.costInput],
2081
+ ["Output", t.output, t.costOutput],
2082
+ ["Cache read", t.cacheRead, t.costCacheRead],
2083
+ ["Cache write", t.cacheWrite, t.costCacheWrite],
2084
+ ];
2085
+ if (t.reasoning > 0) rows.push(["Reasoning (of output)", t.reasoning, 0]);
2086
+ if (t.cacheWrite1h > 0) rows.push(["Cache write (1h)", t.cacheWrite1h, 0]);
2087
+ const compact = width < 64;
2088
+ for (const [label, tokens, cost] of rows) {
2089
+ if (tokens <= 0) continue;
2090
+ const priced = cost > 0 ? ` · ${formatCost(cost)}` : "";
2091
+ lines.push(
2092
+ ` ${theme.fg("muted", truncateToWidth(label, compact ? 18 : 24).padEnd(compact ? 18 : 24))} ${theme.fg("text", formatTokens(tokens))}${theme.fg("dim", priced)}`,
2093
+ );
2094
+ }
2095
+ const denominator = t.input + t.cacheRead;
2096
+ const reuse = denominator > 0 ? percent(t.cacheRead, denominator) : "—";
2097
+ lines.push(` ${theme.fg("dim", `cache-input reuse ratio ${reuse}`)}`);
2098
+ }
2099
+
2100
+ private statsLine(win: WindowedReport, _width: number): string {
2101
+ const { theme } = this.deps;
2102
+ const t = win.total;
2103
+ const totalTokens = bucketTokens(t);
2104
+ const parts = [
2105
+ `${theme.fg("accent", "↑")}${theme.fg("text", formatTokens(t.input))}`,
2106
+ `${theme.fg("accent", "↓")}${theme.fg("text", formatTokens(t.output))}`,
2107
+ `${theme.fg("accent", "⚡")}${theme.fg("text", formatTokens(t.cacheRead))}`,
2108
+ `${theme.fg("accent", "↥")}${theme.fg("text", formatTokens(t.cacheWrite))}`,
2109
+ ];
2110
+ // Only show $ when there's real pricing; otherwise emphasize total tokens.
2111
+ if (t.cost > 0) {
2112
+ parts.push(theme.fg("success", formatCost(t.cost)));
2113
+ } else {
2114
+ parts.push(`${theme.fg("success", formatTokens(totalTokens))} ${theme.fg("dim", "tokens")}`);
2115
+ }
2116
+ const meta = theme.fg("dim", `· ${win.turnCount} turns`);
2117
+ return ` ${parts.join(" ")} ${meta}`;
2118
+ }
2119
+
2120
+ private topConsumer(
2121
+ win: WindowedReport,
2122
+ unit: "usd" | "tokens",
2123
+ ): { kind: string; name: string; pct: string } | null {
2124
+ // Use the same subscription-aware unit as the rest of the panel. Ranking by
2125
+ // cost when a subscription provider is active would wrongly credit the one
2126
+ // priced legacy turn (e.g. $0.20 of codex) as "100% of usage" while ignoring
2127
+ // the token-heavy subscription turns (e.g. glm-5.2 with 81M tokens).
2128
+ const useTokens = unit === "tokens";
2129
+ const total = useTokens ? bucketTokens(win.total) : win.total.cost;
2130
+ if (total <= 0) return null;
2131
+ const bucketValue = (b: Bucket) => (useTokens ? bucketTokens(b) : b.cost);
2132
+ const candidates: Array<{ kind: string; name: string; value: number }> = [];
2133
+ const pick = (kind: string, map: Map<string, Bucket>) => {
2134
+ for (const [name, b] of ranked(map, bucketValue).slice(0, 1)) {
2135
+ candidates.push({ kind, name, value: bucketValue(b) });
2136
+ }
2137
+ };
2138
+ pick("model", win.byModel);
2139
+ pick("skill", win.bySkill);
2140
+ pick("plugin", win.byPlugin);
2141
+ candidates.sort((a, b) => b.value - a.value);
2142
+ const best = candidates[0];
2143
+ if (!best || best.value <= 0) return null;
2144
+ return {
2145
+ kind: best.kind,
2146
+ name: best.name,
2147
+ pct: percent(best.value, total),
2148
+ };
2149
+ }
2150
+
2151
+ /** Render the active-provider banner + live quota + rate-limit windows. */
2152
+ private appendProviderSection(lines: string[], width: number): void {
2153
+ const { theme } = this.deps;
2154
+ const quota = this.state.providerQuota;
2155
+
2156
+ lines.push(` ${theme.fg("accent", theme.bold("Active provider"))}`);
2157
+
2158
+ if (!quota?.active) {
2159
+ lines.push(` ${theme.fg("dim", "— no active model yet —")}`);
2160
+ lines.push("");
2161
+ return;
2162
+ }
2163
+
2164
+ const a = quota.active;
2165
+ const host = hostFromUrl(a.baseUrl);
2166
+ const keyBadge = a.hasKey ? theme.fg("success", "key ✓") : theme.fg("warning", "no env key");
2167
+ lines.push(
2168
+ ` ${theme.fg("text", `${a.provider} / ${a.modelId}`)} ${theme.fg("dim", host)} ${keyBadge}`,
2169
+ );
2170
+
2171
+ // Live money quota from the provider's billing API.
2172
+ if (quota.credits) {
2173
+ const c = quota.credits;
2174
+ lines.push(
2175
+ this.miniBar(
2176
+ "Account credits",
2177
+ c.remaining,
2178
+ c.total,
2179
+ `${formatCost(c.remaining)} / ${formatCost(c.total)}`,
2180
+ width,
2181
+ ),
2182
+ );
2183
+ }
2184
+ if (quota.spend5h != null || quota.spend7d != null) {
2185
+ const parts: string[] = [];
2186
+ if (quota.spend5h != null) parts.push(`5h ${formatCost(quota.spend5h)}`);
2187
+ if (quota.spend7d != null) parts.push(`7d ${formatCost(quota.spend7d)}`);
2188
+ if (quota.monthlyLimit != null) parts.push(`limit ${formatCost(quota.monthlyLimit)}/mo`);
2189
+ lines.push(
2190
+ ` ${theme.fg("muted", "Provider spend")} ${theme.fg("text", parts.join(" "))}`,
2191
+ );
2192
+ }
2193
+
2194
+ // Rate-limit windows captured from the latest provider response.
2195
+ if (quota.rateLimits.length > 0) {
2196
+ lines.push(` ${theme.fg("muted", "Rate limits (live, from last response)")}`);
2197
+ for (const rl of quota.rateLimits.slice(0, 6)) {
2198
+ const limit = rl.limit > 0 ? rl.limit : 0;
2199
+ const ratio = limit > 0 ? rl.remaining / limit : 0;
2200
+ const used = Math.max(0, limit - rl.remaining);
2201
+ const reset = rl.resetMs > 0 ? `resets in ${countdown(rl.resetMs)}` : "";
2202
+ const right = `${formatLimit(rl.remaining)}/${formatLimit(limit)}${reset ? ` ${reset}` : ""}`;
2203
+ lines.push(
2204
+ this.miniBar(rl.resource, Math.max(0, ratio), undefined, right, width, used, limit),
2205
+ );
2206
+ }
2207
+ } else if (quota.source === "none") {
2208
+ lines.push(
2209
+ ` ${theme.fg("dim", "No rate-limit headers captured yet — make a request first.")}`,
2210
+ );
2211
+ }
2212
+
2213
+ for (const note of quota.notes) {
2214
+ lines.push(` ${theme.fg("dim", `• ${note}`)}`);
2215
+ }
2216
+ if (quota.error) {
2217
+ lines.push(` ${theme.fg("error", quota.error)}`);
2218
+ }
2219
+ lines.push("");
2220
+ }
2221
+
2222
+ /** Compact single-line bar: `[label] ██████░░░░ right` */
2223
+ private miniBar(
2224
+ label: string,
2225
+ ratioOrValue: number,
2226
+ limitForRatio: number | undefined,
2227
+ right: string,
2228
+ width: number,
2229
+ used?: number,
2230
+ limitNum?: number,
2231
+ ): string {
2232
+ const { theme } = this.deps;
2233
+ const labelW = 16;
2234
+ const lbl = truncateToWidth(label, labelW).padEnd(labelW);
2235
+
2236
+ let ratio: number;
2237
+ if (limitForRatio === undefined) {
2238
+ // ratioOrValue is itself a 0..1 ratio
2239
+ ratio = Math.max(0, Math.min(1, ratioOrValue));
2240
+ } else if (limitForRatio > 0) {
2241
+ // remaining/limit → bar shows remaining; color by usage pressure
2242
+ const remaining = ratioOrValue;
2243
+ ratio = remaining / limitForRatio;
2244
+ } else {
2245
+ ratio = 0;
2246
+ }
2247
+ // Color: green when plenty remaining, yellow mid, red low.
2248
+ const color: ThemeColor = ratio >= 0.5 ? "success" : ratio >= 0.2 ? "warning" : "error";
2249
+
2250
+ const rightW = visibleWidth(right);
2251
+ const barW = Math.max(6, Math.min(22, width - labelW - rightW - 6));
2252
+ const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
2253
+ const barStr =
2254
+ theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
2255
+ void used;
2256
+ void limitNum;
2257
+ return ` ${theme.fg("text", lbl)} ${barStr} ${theme.fg("muted", right)}`;
2258
+ }
2259
+
2260
+ /**
2261
+ * One aligned table-header row: the section title fills the label column and
2262
+ * the column labels (`%`, value unit, optional extra) sit directly above
2263
+ * their data columns. Keeps every breakdown section visually consistent.
2264
+ */
2265
+ private tableHeader(
2266
+ title: string,
2267
+ labelW: number,
2268
+ barW: number,
2269
+ valueLabel: string,
2270
+ extraLabel?: string,
2271
+ valueW?: number,
2272
+ ): string {
2273
+ const { theme } = this.deps;
2274
+ const titleCell = theme.fg("accent", theme.bold(truncateToWidth(title, labelW).padEnd(labelW)));
2275
+ const pctCell = theme.fg("dim", "%".padStart(4));
2276
+ const barSpace = " ".repeat(barW);
2277
+ const valueCell = theme.fg("dim", valueW ? valueLabel.padEnd(valueW) : valueLabel);
2278
+ let line = ` ${titleCell} ${pctCell} ${barSpace} ${valueCell}`;
2279
+ if (extraLabel) line += ` ${theme.fg("dim", extraLabel)}`;
2280
+ return line;
2281
+ }
2282
+
2283
+ private appendToolsSection(
2284
+ lines: string[],
2285
+ map: Map<string, Bucket>,
2286
+ total: number,
2287
+ width: number,
2288
+ limit: number,
2289
+ unit: "usd" | "tokens",
2290
+ ): void {
2291
+ const { theme } = this.deps;
2292
+ const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
2293
+ const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
2294
+ const rows = ranked(map, bucketValue);
2295
+ const unitLabel = unit === "tokens" ? "tokens" : "cost";
2296
+
2297
+ const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
2298
+ const barW = Math.max(6, Math.min(20, width - labelW - 26));
2299
+ lines.push(
2300
+ ` ${theme.fg("warning", "⚙")} ${theme.fg("dim", "Pi-chan tracked these tool calls")}`,
2301
+ );
2302
+ lines.push(this.tableHeader("Tools", labelW, barW, unitLabel));
2303
+
2304
+ if (rows.length === 0) {
2305
+ lines.push(` ${theme.fg("dim", "— none in this window —")}`);
2306
+ lines.push("");
2307
+ return;
2308
+ }
2309
+
2310
+ const shown = rows.slice(0, limit);
2311
+ for (let i = 0; i < shown.length; i++) {
2312
+ const [key, bucket] = shown[i];
2313
+ const value = bucketValue(bucket);
2314
+ const glyph = toolGlyph(key);
2315
+ const rawName = truncateToWidth(key, labelW - 2);
2316
+ const name = `${glyph} ${rawName}`.padEnd(labelW);
2317
+ const pct = percent(value, total);
2318
+ const ratio = total > 0 ? value / total : 0;
2319
+ const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
2320
+ const color = RAINBOW[i % RAINBOW.length];
2321
+ const barStr =
2322
+ theme.fg(color, "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
2323
+ const pctStr = pct.padStart(4);
2324
+ lines.push(
2325
+ ` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg(color, fmt(value))}`,
2326
+ );
2327
+ }
2328
+
2329
+ const rest = rows.length - shown.length;
2330
+ if (rest > 0) {
2331
+ lines.push(` ${theme.fg("dim", `… +${rest} more tools`)}`);
2332
+ }
2333
+ lines.push(` ${theme.fg("dim", "glyph · tool type hint")}`);
2334
+ lines.push("");
2335
+ }
2336
+
2337
+ private appendSection(
2338
+ lines: string[],
2339
+ title: string,
2340
+ map: Map<string, Bucket>,
2341
+ total: number,
2342
+ width: number,
2343
+ limit: number,
2344
+ labelFn: (key: string) => string = (k) => k,
2345
+ unit: "usd" | "tokens" = "usd",
2346
+ ): void {
2347
+ const { theme } = this.deps;
2348
+ const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
2349
+ const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
2350
+ const rows = ranked(map, bucketValue);
2351
+ const unitLabel = unit === "tokens" ? "tokens" : "cost";
2352
+
2353
+ // Column geometry (shared with the row layout below) so the header labels
2354
+ // sit directly above their columns instead of drifting to the far right.
2355
+ const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
2356
+ const barW = Math.max(6, Math.min(20, width - labelW - 26));
2357
+ lines.push(this.tableHeader(title, labelW, barW, unitLabel));
2358
+
2359
+ if (rows.length === 0) {
2360
+ lines.push(` ${theme.fg("dim", "— none in this window —")}`);
2361
+ lines.push("");
2362
+ return;
2363
+ }
2364
+
2365
+ const shown = rows.slice(0, limit);
2366
+
2367
+ for (const [key, bucket] of shown) {
2368
+ const value = bucketValue(bucket);
2369
+ const name = truncateToWidth(labelFn(key), labelW).padEnd(labelW);
2370
+ const pct = percent(value, total);
2371
+ const ratio = total > 0 ? value / total : 0;
2372
+ const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
2373
+ const barStr =
2374
+ theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
2375
+ const pctStr = pct.padStart(4);
2376
+ lines.push(
2377
+ ` ${theme.fg("text", name)} ${theme.fg("muted", pctStr)} ${barStr} ${theme.fg("dim", fmt(value))}`,
2378
+ );
2379
+ }
2380
+
2381
+ const rest = rows.length - shown.length;
2382
+ if (rest > 0) {
2383
+ lines.push(` ${theme.fg("dim", `… +${rest} more`)}`);
2384
+ }
2385
+ lines.push("");
2386
+ }
2387
+
2388
+ /**
2389
+ * Plugin usage section: ranks plugins by their attributed usage and shows the
2390
+ * specific skills/tools that drove each one, plus the "core" remainder
2391
+ * (turns with only builtin tools and no skill — i.e. plain pi usage).
2392
+ *
2393
+ * Plugins are independent characteristics: a single turn can credit several
2394
+ * plugins, so the percentages need not sum to 100. The core line is the
2395
+ * complement (turns attributed to NO plugin).
2396
+ */
2397
+ private appendPluginUsageSection(
2398
+ lines: string[],
2399
+ win: WindowedReport,
2400
+ total: number,
2401
+ width: number,
2402
+ unit: "usd" | "tokens",
2403
+ ): void {
2404
+ const { theme } = this.deps;
2405
+ const fmt = (n: number) => (unit === "tokens" ? formatTokens(n) : formatCost(n));
2406
+ const bucketValue = (b: Bucket) => (unit === "tokens" ? bucketTokens(b) : b.cost);
2407
+
2408
+ const rows = ranked(win.pluginDetail, (c) => bucketValue(c.bucket));
2409
+ const coreValue = bucketValue(win.byCore);
2410
+
2411
+ // Share the geometry with the other breakdown sections so columns and bars
2412
+ // line up, and give the value a fixed width so the "via" detail aligns.
2413
+ const labelW = Math.max(16, Math.min(36, Math.floor((width - 30) * 0.6)));
2414
+ const barW = Math.max(6, Math.min(20, width - labelW - 26));
2415
+ const valueW = 9;
2416
+ const unitLabel = unit === "tokens" ? "tokens" : "cost";
2417
+
2418
+ lines.push(this.tableHeader("Plugin usage", labelW, barW, unitLabel, "via", valueW));
2419
+ if (rows.length === 0 && coreValue <= 0) {
2420
+ lines.push(` ${theme.fg("dim", "— none in this window —")}`);
2421
+ lines.push("");
2422
+ return;
2423
+ }
2424
+
2425
+ const renderRow = (name: string, value: number, detail?: string) => {
2426
+ const pct = total > 0 ? percent(value, total) : "0%";
2427
+ const ratio = total > 0 ? value / total : 0;
2428
+ const filled = Math.max(ratio > 0 ? 1 : 0, Math.round(ratio * barW));
2429
+ const barStr =
2430
+ theme.fg("accent", "█".repeat(filled)) + theme.fg("borderMuted", "░".repeat(barW - filled));
2431
+ const nm = truncateToWidth(name, labelW).padEnd(labelW);
2432
+ const valueStr = fmt(value).padEnd(valueW);
2433
+ // The "via" detail trails the fixed name/%/bar/value columns plus one
2434
+ // separating space (2-indent + labelW + 1 + 4 + 1 + barW + 1 + valueW + 1
2435
+ // = labelW + barW + valueW + 10). Tool/skill names can be long (e.g.
2436
+ // "firecrawl_firecrawl_scrape"), so truncate to the remaining width to
2437
+ // keep the row within the terminal and avoid a TUI width-overflow crash.
2438
+ const viaW = width - (labelW + barW + valueW) - 10;
2439
+ const detailStr =
2440
+ detail && viaW > 0 ? ` ${theme.fg("dim", truncateToWidth(detail, viaW))}` : "";
2441
+ lines.push(
2442
+ ` ${theme.fg("text", nm)} ${theme.fg("muted", pct.padStart(4))} ${barStr} ${theme.fg("dim", valueStr)}${detailStr}`,
2443
+ );
2444
+ };
2445
+
2446
+ for (const [name, contrib] of rows.slice(0, 8)) {
2447
+ // Summarize which skills/tools of this plugin contributed.
2448
+ const parts: string[] = [];
2449
+ const topSkills = ranked(contrib.skills, (b) => bucketValue(b)).slice(0, 2);
2450
+ for (const [s] of topSkills) parts.push(s);
2451
+ const topTools = ranked(contrib.tools, (b) => bucketValue(b)).slice(0, 2);
2452
+ for (const [t] of topTools) parts.push(t);
2453
+ const detail = parts.length > 0 ? parts.join(", ") : undefined;
2454
+ renderRow(name, bucketValue(contrib.bucket), detail);
2455
+ }
2456
+ if (rows.length > 8) {
2457
+ lines.push(` ${theme.fg("dim", `… +${rows.length - 8} more`)}`);
2458
+ }
2459
+ // Core remainder: turns with no plugin attribution (builtin tools only).
2460
+ if (coreValue > 0) {
2461
+ renderRow("(core / no plugin)", coreValue, "builtin tools only");
2462
+ }
2463
+ lines.push("");
2464
+ }
2465
+
2466
+ private portableFooterLine(width: number): string {
2467
+ const text = "Choose an action below to navigate, refresh, configure, page, or close.";
2468
+ return ` ${this.deps.theme.fg("dim", truncateToWidth(text, Math.max(10, width - 2)))}`;
2469
+ }
2470
+
2471
+ private footerLine(width: number): string {
2472
+ const { theme } = this.deps;
2473
+ const view = this.state.view;
2474
+ const keys: Array<[string, string]> = [["⇥/←→", "views"]];
2475
+ if (view === "overview" || view === "models") {
2476
+ keys.push(["5/d/w/a", "window"]);
2477
+ } else if (view === "delegation") {
2478
+ // Delegation honours the window too (5 stays reserved for the Stats tab).
2479
+ keys.push(["d/w/a", "window"]);
2480
+ }
2481
+ if (view === "models") {
2482
+ keys.push(["c/n", "sort"]);
2483
+ }
2484
+ if (view === "daily") {
2485
+ keys.push(["t/c/d", "sort ±"]);
2486
+ }
2487
+ if (view === "stats") {
2488
+ keys.push(["a/w/m", "range"]);
2489
+ }
2490
+ if (view === "providers") {
2491
+ keys.push(["c/n", "sort"]);
2492
+ }
2493
+ if (view === "wrapped") {
2494
+ keys.push(["[ ]/y", "year"]);
2495
+ }
2496
+ keys.push(
2497
+ ["1-8", "jump"],
2498
+ ["r", "refresh"],
2499
+ ["s", "limits"],
2500
+ ["j/k", "scroll"],
2501
+ ["q", "close"],
2502
+ );
2503
+ const sep = theme.fg("borderMuted", " · ");
2504
+ const render = (parts: Array<[string, string]>, labels: boolean) =>
2505
+ ` ${parts
2506
+ .map(([k, label]) =>
2507
+ labels ? `${theme.fg("accent", k)} ${theme.fg("dim", label)}` : theme.fg("accent", k),
2508
+ )
2509
+ .join(sep)}`;
2510
+ let parts = keys;
2511
+ let line = render(parts, true);
2512
+ // Drop the least-critical hints until the footer fits the terminal.
2513
+ for (const key of ["j/k", "1-8", "s"]) {
2514
+ if (visibleWidth(line) <= width) break;
2515
+ parts = parts.filter(([k]) => k !== key);
2516
+ line = render(parts, true);
2517
+ }
2518
+ if (visibleWidth(line) > width) {
2519
+ line = render(parts, false);
2520
+ }
2521
+ return line;
2522
+ }
2523
+ }
2524
+
2525
+ function labelForWindow(key: WindowKey): string {
2526
+ switch (key) {
2527
+ case "5h":
2528
+ return "last 5 hours";
2529
+ case "24h":
2530
+ return "last 24 hours";
2531
+ case "7d":
2532
+ return "last 7 days";
2533
+ case "all":
2534
+ return "all time";
2535
+ }
2536
+ }
2537
+
2538
+ function relativeTime(ts: number): string {
2539
+ const diff = Date.now() - ts;
2540
+ const min = 60 * 1000;
2541
+ const hour = 60 * min;
2542
+ const day = 24 * hour;
2543
+ if (diff < min) return "just now";
2544
+ if (diff < hour) return `${Math.floor(diff / min)}m ago`;
2545
+ if (diff < day) return `${Math.floor(diff / hour)}h ago`;
2546
+ return `${Math.floor(diff / day)}d ago`;
2547
+ }
2548
+
2549
+ /** Extract a short host from a base URL for the provider banner. */
2550
+ function hostFromUrl(url: string): string {
2551
+ if (!url) return "";
2552
+ try {
2553
+ const u = new URL(url);
2554
+ return u.host;
2555
+ } catch {
2556
+ return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
2557
+ }
2558
+ }
2559
+
2560
+ /** Human countdown to a future epoch-ms timestamp. */
2561
+ function countdown(resetMs: number): string {
2562
+ const diff = resetMs - Date.now();
2563
+ if (diff <= 0) return "now";
2564
+ const s = Math.round(diff / 1000);
2565
+ if (s < 60) return `${s}s`;
2566
+ const m = Math.floor(s / 60);
2567
+ if (m < 60) return `${m}m${s % 60 ? ` ${s % 60}s` : ""}`;
2568
+ const h = Math.floor(m / 60);
2569
+ return `${h}h${m % 60 ? ` ${m % 60}m` : ""}`;
2570
+ }
2571
+
2572
+ /** Format a tokens/second rate compactly (e.g. "47", "8.2", "1.2k"). */
2573
+ function formatRate(n: number): string {
2574
+ if (!Number.isFinite(n) || n <= 0) return "0";
2575
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
2576
+ if (n >= 100) return `${Math.round(n)}`;
2577
+ return n.toFixed(1);
2578
+ }
2579
+
2580
+ /** Format a rate-limit count (tokens use k/M suffixes). */
2581
+ function formatLimit(n: number): string {
2582
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
2583
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
2584
+ return `${Math.round(n)}`;
2585
+ }