@po.dev/pi-usage-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +11 -0
  2. package/data.ts +1859 -0
  3. package/export.ts +146 -0
  4. package/graph.ts +399 -0
  5. package/index.ts +1231 -0
  6. package/package.json +13 -0
package/index.ts ADDED
@@ -0,0 +1,1231 @@
1
+ /**
2
+ * /usage - Usage statistics dashboard
3
+ *
4
+ * Shows an inline view with usage stats grouped by provider.
5
+ * - Tab cycles: Today → This Week → Last Week → All Time
6
+ * - Arrow keys navigate providers
7
+ * - Enter expands/collapses to show models
8
+ *
9
+ * Data collection and caching live in ./data.ts.
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
13
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
14
+ import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
+
16
+ import { AUXILIARY_PROVIDER, collectUsageData, getAgentDir, splitHourlyKey, TAB_ORDER } from "./data";
17
+ import type { CollectProgress } from "./data";
18
+ import type { BaseStats, ProviderStats, TabName, TotalStats, UsageData } from "./data";
19
+ import {
20
+ buildGraphModel,
21
+ renderChart,
22
+ GROUP_LABELS,
23
+ GROUP_ORDER,
24
+ METRIC_LABELS,
25
+ METRIC_ORDER,
26
+ TOTAL_SERIES_KEY,
27
+ } from "./graph";
28
+ import type { GraphGroupBy, GraphMetric, GraphModel } from "./graph";
29
+ import {
30
+ buildGraphCsv,
31
+ buildInsightsJson,
32
+ buildTableCsv,
33
+ exportFileName,
34
+ parseExportDirSetting,
35
+ resolveExportDir,
36
+ } from "./export";
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
38
+ import { homedir, tmpdir } from "node:os";
39
+ import { join } from "node:path";
40
+
41
+ type ViewMode = "table" | "insights" | "history" | "graph";
42
+
43
+ const VIEW_CYCLE: ViewMode[] = ["graph", "table", "insights", "history"];
44
+
45
+ const VIEW_LABELS: Record<ViewMode, string> = {
46
+ graph: "Overview",
47
+ table: "Usage",
48
+ insights: "Insights",
49
+ history: "History",
50
+ };
51
+
52
+ type PromptItem = { label: string; chars: number; text?: string };
53
+ type PromptSection = PromptItem & { children: PromptItem[] };
54
+
55
+ // Pi stores the assembled prompt only for the running session. Count its actual
56
+ // source spans (not guessed model-usage tokens) so percentages remain useful
57
+ // across providers.
58
+ function snapshotPath(id: string): string {
59
+ return join(getAgentDir(), "token-dashboard", "prompt-snapshots", `${id}.json`);
60
+ }
61
+
62
+ function savePromptSnapshot(id: string, prompt: string): void {
63
+ if (!id || !prompt) return;
64
+ const path = snapshotPath(id);
65
+ mkdirSync(join(getAgentDir(), "token-dashboard", "prompt-snapshots"), { recursive: true });
66
+ writeFileSync(path, JSON.stringify({ prompt }), "utf8");
67
+ }
68
+
69
+ function loadPromptSnapshot(id: string): string | null {
70
+ try {
71
+ const value: unknown = JSON.parse(readFileSync(snapshotPath(id), "utf8"));
72
+ return typeof value === "object" && value !== null && typeof (value as { prompt?: unknown }).prompt === "string" ? (value as { prompt: string }).prompt : null;
73
+ } catch { return null; }
74
+ }
75
+
76
+ function topChildren(children: PromptItem[], max = 8): PromptItem[] {
77
+ if (children.length <= max) return children;
78
+ return [...children.slice(0, max - 1), { label: "Other", chars: children.slice(max - 1).reduce((sum, child) => sum + child.chars, 0), text: children.slice(max - 1).map((child) => child.text).filter(Boolean).join("\n") }];
79
+ }
80
+
81
+ function promptSections(prompt: string): PromptSection[] {
82
+ const skillsStart = prompt.indexOf("The following skills provide specialized instructions");
83
+ const skillsEnd = prompt.indexOf("</available_skills>");
84
+ const contextStart = prompt.indexOf("# Project Context");
85
+ const metadataStart = Math.max(prompt.lastIndexOf("\nCurrent date:"), prompt.lastIndexOf("\nCurrent date and time:"));
86
+ const boundary = [contextStart, skillsStart, metadataStart].filter((n) => n >= 0).sort((a, b) => a - b)[0] ?? prompt.length;
87
+ const base = prompt.slice(0, boundary);
88
+ const splitBase = (label: string, start: number, end: number): PromptSection | null => end > start ? { label, chars: end - start, text: base.slice(start, end), children: [] } : null;
89
+ const guidelines = base.indexOf("\nGuidelines:");
90
+ const piDocs = base.indexOf("\nPi documentation");
91
+ const baseParts = guidelines >= 0
92
+ ? [
93
+ splitBase("System", 0, guidelines),
94
+ splitBase("AGENTS.md / rules", guidelines + 1, piDocs >= 0 ? piDocs : base.length),
95
+ splitBase("Pi docs hints", piDocs >= 0 ? piDocs + 1 : base.length, base.length),
96
+ ].filter((p): p is PromptSection => p !== null)
97
+ : [splitBase("System", 0, base.length)].filter((p): p is PromptSection => p !== null);
98
+ const sections: PromptSection[] = [...baseParts];
99
+ if (contextStart >= 0) {
100
+ const end = [skillsStart, metadataStart, prompt.length].filter((n) => n > contextStart).sort((a, b) => a - b)[0]!;
101
+ const text = prompt.slice(contextStart, end);
102
+ const matches = [...text.matchAll(/^## (.+)$/gm)];
103
+ sections.push({ label: "Context files", chars: text.length, text, children: topChildren(matches.map((m, i) => {
104
+ const start = m.index!, end = matches[i + 1]?.index ?? text.length;
105
+ return { label: m[1]!, chars: end - start, text: text.slice(start, end) };
106
+ })) });
107
+ }
108
+ if (skillsStart >= 0) {
109
+ const end = skillsEnd >= 0 ? skillsEnd + "</available_skills>".length : (metadataStart >= 0 ? metadataStart : prompt.length);
110
+ const text = prompt.slice(skillsStart, end);
111
+ const children = [...text.matchAll(/<skill>([\s\S]*?)<\/skill>/g)].map((m) => ({ label: m[1]!.match(/<name>([\s\S]*?)<\/name>/)?.[1]?.trim() ?? "skill", chars: m[0].length, text: m[0] }));
112
+ sections.push({ label: "Skills", chars: text.length, text, children: topChildren(children) });
113
+ }
114
+ if (metadataStart >= 0) sections.push({ label: "Metadata", chars: prompt.length - metadataStart, text: prompt.slice(metadataStart), children: [] });
115
+ return sections.filter((section) => section.chars > 0);
116
+ }
117
+
118
+ // =============================================================================
119
+ // Column Configuration
120
+ // =============================================================================
121
+
122
+ interface DataColumn {
123
+ label: string;
124
+ width: number;
125
+ dimmed?: boolean;
126
+ getValue: (stats: BaseStats & { sessions: Set<string> | number }) => string;
127
+ }
128
+
129
+ interface TableLayoutCandidate {
130
+ columns: DataColumn[];
131
+ minNameWidth: number;
132
+ compact?: boolean;
133
+ }
134
+
135
+ interface TableLayout {
136
+ columns: DataColumn[];
137
+ nameWidth: number;
138
+ tableWidth: number;
139
+ compact: boolean;
140
+ }
141
+
142
+ const MAX_NAME_COL_WIDTH = 26;
143
+
144
+ const SESSIONS_COLUMN: DataColumn = {
145
+ label: "Sessions",
146
+ width: 9,
147
+ getValue: (s) => formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size),
148
+ };
149
+
150
+ const MSGS_COLUMN: DataColumn = {
151
+ label: "Msgs",
152
+ width: 9,
153
+ getValue: (s) => formatNumber(s.messages),
154
+ };
155
+
156
+ const COST_COLUMN: DataColumn = {
157
+ label: "Cost",
158
+ width: 9,
159
+ getValue: (s) => formatCost(s.cost),
160
+ };
161
+
162
+ const TOKENS_COLUMN: DataColumn = {
163
+ label: "Tokens",
164
+ width: 9,
165
+ getValue: (s) => formatTokens(s.tokens.total),
166
+ };
167
+
168
+ const INPUT_COLUMN: DataColumn = {
169
+ label: "↑In",
170
+ width: 8,
171
+ dimmed: true,
172
+ // Include cacheWrite so this reflects fresh input tokens sent this turn,
173
+ // even for providers like Anthropic that split cached prompt creation out
174
+ // from the regular input token count.
175
+ getValue: (s) => formatTokens(s.tokens.input + s.tokens.cacheWrite),
176
+ };
177
+
178
+ const OUTPUT_COLUMN: DataColumn = {
179
+ label: "↓Out",
180
+ width: 8,
181
+ dimmed: true,
182
+ getValue: (s) => formatTokens(s.tokens.output),
183
+ };
184
+
185
+ const CACHE_COLUMN: DataColumn = {
186
+ label: "Cache",
187
+ width: 8,
188
+ dimmed: true,
189
+ getValue: (s) => formatTokens(s.tokens.cacheRead + s.tokens.cacheWrite),
190
+ };
191
+
192
+ const FULL_DATA_COLUMNS: DataColumn[] = [
193
+ SESSIONS_COLUMN,
194
+ MSGS_COLUMN,
195
+ COST_COLUMN,
196
+ TOKENS_COLUMN,
197
+ INPUT_COLUMN,
198
+ OUTPUT_COLUMN,
199
+ CACHE_COLUMN,
200
+ ];
201
+
202
+ const TABLE_LAYOUTS: TableLayoutCandidate[] = [
203
+ { columns: FULL_DATA_COLUMNS, minNameWidth: MAX_NAME_COL_WIDTH },
204
+ { columns: [SESSIONS_COLUMN, MSGS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 14, compact: true },
205
+ { columns: [SESSIONS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 12, compact: true },
206
+ { columns: [COST_COLUMN, TOKENS_COLUMN], minNameWidth: 10, compact: true },
207
+ { columns: [COST_COLUMN], minNameWidth: 8, compact: true },
208
+ ];
209
+
210
+ // =============================================================================
211
+ // Formatting Helpers
212
+ // =============================================================================
213
+
214
+ function formatCost(cost: number): string {
215
+ if (cost === 0) return "-";
216
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
217
+ if (cost < 1) return `$${cost.toFixed(2)}`;
218
+ if (cost < 10) return `$${cost.toFixed(2)}`;
219
+ if (cost < 100) return `$${cost.toFixed(1)}`;
220
+ return `$${Math.round(cost)}`;
221
+ }
222
+
223
+ function formatTokens(count: number): string {
224
+ if (count === 0) return "-";
225
+ if (count < 1000) return count.toString();
226
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
227
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
228
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
229
+ return `${Math.round(count / 1000000)}M`;
230
+ }
231
+
232
+ function formatNumber(n: number): string {
233
+ if (n === 0) return "-";
234
+ return n.toLocaleString();
235
+ }
236
+
237
+ // Compact axis/legend formatters for the graph view.
238
+ function formatAxisCost(v: number): string {
239
+ if (v === 0) return "$0";
240
+ if (v < 1) return `$${v.toFixed(2)}`;
241
+ if (v < 100) return `$${v.toFixed(1)}`;
242
+ if (v < 10_000) return `$${Math.round(v)}`;
243
+ if (v < 1_000_000) return `$${(v / 1000).toFixed(1)}k`;
244
+ return `$${(v / 1_000_000).toFixed(2)}M`;
245
+ }
246
+
247
+ function formatAxisCount(v: number): string {
248
+ if (v === 0) return "0";
249
+ if (v < 1000) return String(Math.round(v));
250
+ if (v < 1_000_000) return `${(v / 1000).toFixed(v < 10_000 ? 1 : 0)}k`;
251
+ if (v < 1_000_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
252
+ return `${(v / 1_000_000_000).toFixed(1)}B`;
253
+ }
254
+
255
+ // Bright ANSI palette for graph series (Total uses index 0).
256
+ const SERIES_COLORS = ["\x1b[97m", "\x1b[96m", "\x1b[95m", "\x1b[93m", "\x1b[92m", "\x1b[94m", "\x1b[91m", "\x1b[90m"];
257
+ const COLOR_RESET = "\x1b[39m";
258
+
259
+ function seriesColor(index: number): string {
260
+ return SERIES_COLORS[index % SERIES_COLORS.length]!;
261
+ }
262
+
263
+ /** "14:32" if the timestamp is today, otherwise "16 Jul" (with year if not this year). */
264
+ function formatSinceDate(ms: number): string {
265
+ const d = new Date(ms);
266
+ const now = new Date();
267
+ if (d.toDateString() === now.toDateString()) {
268
+ return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
269
+ }
270
+ const opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" };
271
+ if (d.getFullYear() !== now.getFullYear()) opts.year = "numeric";
272
+ return d.toLocaleDateString(undefined, opts);
273
+ }
274
+
275
+ function padLeft(s: string, len: number): string {
276
+ const vis = visibleWidth(s);
277
+ if (vis >= len) return s;
278
+ return " ".repeat(len - vis) + s;
279
+ }
280
+
281
+ function padRight(s: string, len: number): string {
282
+ const vis = visibleWidth(s);
283
+ if (vis >= len) return s;
284
+ return s + " ".repeat(len - vis);
285
+ }
286
+
287
+ function sumColumnWidths(columns: DataColumn[]): number {
288
+ return columns.reduce((sum, col) => sum + col.width, 0);
289
+ }
290
+
291
+ function fitCell(s: string, len: number, align: "left" | "right" = "left"): string {
292
+ if (len <= 0) return "";
293
+ const truncated = truncateToWidth(s, len);
294
+ return align === "right" ? padLeft(truncated, len) : padRight(truncated, len);
295
+ }
296
+
297
+ function clampLines(lines: string[], width: number): string[] {
298
+ return lines.map((line) => truncateToWidth(line, Math.max(width, 0)));
299
+ }
300
+
301
+ function pickFittingText(width: number, variants: string[]): string {
302
+ for (const variant of variants) {
303
+ if (visibleWidth(variant) <= width) return variant;
304
+ }
305
+ return variants[variants.length - 1] || "";
306
+ }
307
+
308
+ function previewText(text: string | undefined, width: number, limit = 18): string[] {
309
+ if (!text) return [];
310
+ const raw = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
311
+ const useful = raw
312
+ .filter((line) => !/^(You are an expert coding assistant|Available tools:|In addition to the tools|The following skills|Use the read tool|When a skill file|<\/?available_skills>|<\/?skill>|<location>|<description>|<\/description>)/.test(line))
313
+ .filter((line) => !/^- (read|bash|edit|write): /.test(line))
314
+ .map((line) => line.replace(/<\/?name>/g, ""))
315
+ .filter(Boolean);
316
+ return (useful.length ? useful : raw)
317
+ .slice(0, limit)
318
+ .flatMap((line) => wrapTextWithAnsi(line, Math.max(20, width - 4)).slice(0, 2));
319
+ }
320
+
321
+ function getTableLayout(width: number): TableLayout {
322
+ const safeWidth = Math.max(width, 0);
323
+
324
+ for (const candidate of TABLE_LAYOUTS) {
325
+ const columnsWidth = sumColumnWidths(candidate.columns);
326
+ const nameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - columnsWidth, 0));
327
+ if (nameWidth >= candidate.minNameWidth) {
328
+ return {
329
+ columns: candidate.columns,
330
+ nameWidth,
331
+ tableWidth: nameWidth + columnsWidth,
332
+ compact: candidate.compact ?? false,
333
+ };
334
+ }
335
+ }
336
+
337
+ const fallback = TABLE_LAYOUTS[TABLE_LAYOUTS.length - 1]!;
338
+ const fallbackColumnsWidth = sumColumnWidths(fallback.columns);
339
+ const fallbackNameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - fallbackColumnsWidth, 0));
340
+ return {
341
+ columns: fallback.columns,
342
+ nameWidth: fallbackNameWidth,
343
+ tableWidth: fallbackNameWidth + fallbackColumnsWidth,
344
+ compact: fallback.compact ?? false,
345
+ };
346
+ }
347
+
348
+ // =============================================================================
349
+ // Component
350
+ // =============================================================================
351
+
352
+ const TAB_LABELS: Record<TabName, string> = {
353
+ today: "Today",
354
+ thisWeek: "This Week",
355
+ lastWeek: "Last Week",
356
+ last30Days: "Last 30 Days",
357
+ allTime: "All Time",
358
+ };
359
+
360
+ class UsageComponent {
361
+ private activeTab: TabName = "allTime";
362
+ private viewMode: ViewMode = "graph";
363
+ private data: UsageData;
364
+ private selectedIndex = 0;
365
+ private expanded = new Set<string>();
366
+ private providerOrder: string[] = [];
367
+ private theme: Theme;
368
+ private requestRender: () => void;
369
+ private done: () => void;
370
+
371
+ // Graph explorer state.
372
+ private graphMetric: GraphMetric = "cost";
373
+ private graphGroupBy: GraphGroupBy = "provider";
374
+ private graphCumulative = true;
375
+ private exportNote: { text: string; ok: boolean } | null = null;
376
+ private tableHidden = new Set<string>();
377
+ private tableFilter = "";
378
+ private tableFilterEditing = false;
379
+ private graphHidden = new Set<string>();
380
+ private graphLegendIndex = 0;
381
+ private promptSections: PromptSection[];
382
+ private insightIndex = 0;
383
+ private insightDetail: PromptSection | null = null;
384
+ private insightContent: PromptItem | null = null;
385
+ private historyIndex = 0;
386
+ private historySelected: string | null = null;
387
+ private currentSessionId: string;
388
+ private currentPrompt: string;
389
+
390
+ constructor(theme: Theme, data: UsageData, prompt: string, currentSessionId: string, requestRender: () => void, done: () => void) {
391
+ this.theme = theme;
392
+ this.requestRender = requestRender;
393
+ this.done = done;
394
+ this.data = data;
395
+ this.currentSessionId = currentSessionId;
396
+ this.currentPrompt = prompt;
397
+ this.promptSections = promptSections(prompt);
398
+ this.updateProviderOrder();
399
+ }
400
+
401
+ private updateProviderOrder(): void {
402
+ const stats = this.data[this.activeTab];
403
+ this.providerOrder = Array.from(stats.providers.entries())
404
+ .filter(([name]) => name !== AUXILIARY_PROVIDER)
405
+ .sort((a, b) => b[1].cost - a[1].cost)
406
+ .map(([name]) => name);
407
+ this.clampTableSelection();
408
+ }
409
+
410
+ private clampTableSelection(): void {
411
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.visibleTable().providers.size - 1));
412
+ }
413
+
414
+ /**
415
+ * The table slice after hides and the text filter. A filter matches a
416
+ * provider name (whole provider stays) or individual model names, in which
417
+ * case the provider row is synthesized from just the matching models so
418
+ * the totals row and exports reflect exactly what is on screen.
419
+ */
420
+ private visibleTable(): { providers: Map<string, ProviderStats>; totals: TotalStats } {
421
+ const stats = this.data[this.activeTab];
422
+ const q = this.tableFilter.trim().toLowerCase();
423
+ // Always iterate providerOrder so the map is cost-sorted — selection
424
+ // indexes and rendered rows must agree on ordering.
425
+ const providers = new Map<string, ProviderStats>();
426
+ for (const name of this.providerOrder) {
427
+ if (this.tableHidden.has(name)) continue;
428
+ const full = stats.providers.get(name)!;
429
+ if (!q || name.toLowerCase().includes(q)) {
430
+ providers.set(name, full);
431
+ continue;
432
+ }
433
+ const models = new Map(Array.from(full.models).filter(([model]) => model.toLowerCase().includes(q)));
434
+ if (models.size === 0) continue;
435
+ const synth: ProviderStats = {
436
+ messages: 0,
437
+ cost: 0,
438
+ tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
439
+ sessions: new Set<string>(),
440
+ models,
441
+ };
442
+ for (const model of models.values()) {
443
+ synth.messages += model.messages;
444
+ synth.cost += model.cost;
445
+ synth.tokens.total += model.tokens.total;
446
+ synth.tokens.input += model.tokens.input;
447
+ synth.tokens.output += model.tokens.output;
448
+ synth.tokens.cacheRead += model.tokens.cacheRead;
449
+ synth.tokens.cacheWrite += model.tokens.cacheWrite;
450
+ for (const s of model.sessions) synth.sessions.add(s);
451
+ }
452
+ providers.set(name, synth);
453
+ }
454
+ const totals: TotalStats = {
455
+ sessions: 0,
456
+ messages: 0,
457
+ cost: 0,
458
+ tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
459
+ };
460
+ const sessions = new Set<string>();
461
+ for (const provider of providers.values()) {
462
+ totals.messages += provider.messages;
463
+ totals.cost += provider.cost;
464
+ totals.tokens.total += provider.tokens.total;
465
+ totals.tokens.input += provider.tokens.input;
466
+ totals.tokens.output += provider.tokens.output;
467
+ totals.tokens.cacheRead += provider.tokens.cacheRead;
468
+ totals.tokens.cacheWrite += provider.tokens.cacheWrite;
469
+ for (const s of provider.sessions) sessions.add(s);
470
+ }
471
+ totals.sessions = sessions.size;
472
+ return { providers, totals };
473
+ }
474
+
475
+ handleInput(data: string): void {
476
+ // Filter typing captures printable keys, so it runs before everything.
477
+ if (this.viewMode === "table" && this.tableFilterEditing) {
478
+ if (matchesKey(data, "escape")) {
479
+ this.tableFilter = "";
480
+ this.tableFilterEditing = false;
481
+ } else if (matchesKey(data, "enter")) {
482
+ this.tableFilterEditing = false;
483
+ } else if (matchesKey(data, "backspace")) {
484
+ this.tableFilter = this.tableFilter.slice(0, -1);
485
+ } else if (data.length === 1 && data >= " " && data !== "\x7f") {
486
+ this.tableFilter += data;
487
+ }
488
+ this.clampTableSelection();
489
+ this.requestRender();
490
+ return;
491
+ }
492
+
493
+ if (matchesKey(data, "escape") && (this.insightContent || this.insightDetail || (this.viewMode === "history" && this.historySelected))) {
494
+ if (this.insightContent) this.insightContent = null;
495
+ else if (this.insightDetail) this.insightDetail = null;
496
+ else this.historySelected = null;
497
+ this.requestRender();
498
+ return;
499
+ }
500
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
501
+ this.done();
502
+ return;
503
+ }
504
+
505
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
506
+ const step = matchesKey(data, "shift+tab") ? -1 : 1;
507
+ const idx = VIEW_CYCLE.indexOf(this.viewMode);
508
+ this.viewMode = VIEW_CYCLE[(idx + step + VIEW_CYCLE.length) % VIEW_CYCLE.length]!;
509
+ this.exportNote = null;
510
+ this.requestRender();
511
+ return;
512
+ }
513
+
514
+ if (matchesKey(data, "e")) {
515
+ this.exportCurrentView();
516
+ this.requestRender();
517
+ return;
518
+ }
519
+
520
+ if (this.viewMode === "graph" && this.handleGraphInput(data)) {
521
+ return;
522
+ }
523
+ if (this.viewMode === "insights" && this.handleInsightInput(data)) return;
524
+ if (this.viewMode === "history" && this.handleHistoryInput(data)) return;
525
+
526
+ if (matchesKey(data, "right")) {
527
+ const idx = TAB_ORDER.indexOf(this.activeTab);
528
+ this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
529
+ this.updateProviderOrder();
530
+ this.exportNote = null;
531
+ this.requestRender();
532
+ } else if (matchesKey(data, "left")) {
533
+ const idx = TAB_ORDER.indexOf(this.activeTab);
534
+ this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
535
+ this.updateProviderOrder();
536
+ this.exportNote = null;
537
+ this.requestRender();
538
+ } else if (this.viewMode === "graph") {
539
+ // Graph-specific keys were handled above; swallow table-only keys.
540
+ } else if (this.viewMode === "table" && data === "/") {
541
+ this.tableFilterEditing = true;
542
+ this.requestRender();
543
+ } else if (this.viewMode === "table" && data === "x") {
544
+ const visible = Array.from(this.visibleTable().providers.keys());
545
+ const provider = visible[this.selectedIndex];
546
+ if (provider) {
547
+ this.tableHidden.add(provider);
548
+ this.clampTableSelection();
549
+ this.requestRender();
550
+ }
551
+ } else if (this.viewMode === "table" && data === "a") {
552
+ this.tableHidden.clear();
553
+ this.tableFilter = "";
554
+ this.tableFilterEditing = false;
555
+ this.clampTableSelection();
556
+ this.requestRender();
557
+ } else if (this.viewMode === "table" && matchesKey(data, "up")) {
558
+ if (this.selectedIndex > 0) {
559
+ this.selectedIndex--;
560
+ this.requestRender();
561
+ }
562
+ } else if (this.viewMode === "table" && matchesKey(data, "down")) {
563
+ if (this.selectedIndex < this.visibleTable().providers.size - 1) {
564
+ this.selectedIndex++;
565
+ this.requestRender();
566
+ }
567
+ } else if (this.viewMode === "table" && (matchesKey(data, "enter") || matchesKey(data, "space"))) {
568
+ const provider = Array.from(this.visibleTable().providers.keys())[this.selectedIndex];
569
+ if (provider) {
570
+ if (this.expanded.has(provider)) {
571
+ this.expanded.delete(provider);
572
+ } else {
573
+ this.expanded.add(provider);
574
+ }
575
+ this.requestRender();
576
+ }
577
+ }
578
+ }
579
+
580
+ // -------------------------------------------------------------------------
581
+ // Render Methods
582
+ // -------------------------------------------------------------------------
583
+
584
+ private handleGraphInput(data: string): boolean {
585
+ if (matchesKey(data, "m")) {
586
+ const idx = METRIC_ORDER.indexOf(this.graphMetric);
587
+ this.graphMetric = METRIC_ORDER[(idx + 1) % METRIC_ORDER.length]!;
588
+ } else if (matchesKey(data, "g")) {
589
+ const idx = GROUP_ORDER.indexOf(this.graphGroupBy);
590
+ this.graphGroupBy = GROUP_ORDER[(idx + 1) % GROUP_ORDER.length]!;
591
+ this.graphHidden.clear();
592
+ this.graphLegendIndex = 0;
593
+ } else if (matchesKey(data, "c")) {
594
+ this.graphCumulative = !this.graphCumulative;
595
+ } else if (matchesKey(data, "a")) {
596
+ this.graphHidden.clear();
597
+ } else if (matchesKey(data, "up")) {
598
+ this.graphLegendIndex = Math.max(0, this.graphLegendIndex - 1);
599
+ } else if (matchesKey(data, "down")) {
600
+ const count = this.buildDailyProviderModel().providers.length;
601
+ this.graphLegendIndex = Math.min(Math.max(count - 1, 0), this.graphLegendIndex + 1);
602
+ } else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
603
+ const target = this.buildDailyProviderModel().providers[this.graphLegendIndex];
604
+ if (target) {
605
+ if (this.graphHidden.has(target.name)) this.graphHidden.delete(target.name);
606
+ else this.graphHidden.add(target.name);
607
+ }
608
+ } else {
609
+ return false;
610
+ }
611
+ this.requestRender();
612
+ return true;
613
+ }
614
+
615
+ private handleInsightInput(data: string): boolean {
616
+ if (this.insightContent) return false;
617
+ const rows = this.insightDetail ? this.insightDetail.children : this.promptSections;
618
+ if (matchesKey(data, "up")) this.insightIndex = Math.max(0, this.insightIndex - 1);
619
+ else if (matchesKey(data, "down")) this.insightIndex = Math.min(Math.max(rows.length - 1, 0), this.insightIndex + 1);
620
+ else if (matchesKey(data, "enter")) {
621
+ const row = rows[this.insightIndex];
622
+ if (!row) return true;
623
+ if (!this.insightDetail && "children" in row && row.children.length) {
624
+ this.insightDetail = row;
625
+ this.insightIndex = 0;
626
+ } else {
627
+ this.insightContent = row;
628
+ }
629
+ } else return false;
630
+ this.requestRender();
631
+ return true;
632
+ }
633
+
634
+ private handleHistoryInput(data: string): boolean {
635
+ if (this.historySelected && (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "enter"))) return this.handleInsightInput(data);
636
+ const sessions = Array.from(this.data.sessions.values()).sort((a, b) => b.timestamp - a.timestamp);
637
+ if (matchesKey(data, "up")) this.historyIndex = Math.max(0, this.historyIndex - 1);
638
+ else if (matchesKey(data, "down")) this.historyIndex = Math.min(Math.max(0, sessions.length - 1), this.historyIndex + 1);
639
+ else if (matchesKey(data, "enter")) {
640
+ const session = sessions[this.historyIndex];
641
+ const prompt = session ? loadPromptSnapshot(session.id) ?? this.currentPrompt : null;
642
+ if (!session || !prompt) return true;
643
+ this.historySelected = session.id;
644
+ this.promptSections = promptSections(prompt);
645
+ this.insightDetail = null;
646
+ this.insightContent = null;
647
+ this.insightIndex = 0;
648
+ } else return false;
649
+ this.requestRender();
650
+ return true;
651
+ }
652
+
653
+ private exportCurrentView(): void {
654
+ const now = new Date();
655
+ let name: string;
656
+ let content: string;
657
+ const stats = this.data[this.activeTab];
658
+ if (this.viewMode === "graph") {
659
+ const slice = `${this.graphCumulative ? "cumulative" : "per-bucket"}-${this.graphMetric}-by-${this.graphGroupBy}`;
660
+ name = exportFileName("graph", this.activeTab, slice, "csv", now);
661
+ content = buildGraphCsv(this.buildGraphModelForView());
662
+ } else if (this.viewMode === "insights") {
663
+ name = exportFileName("insights", this.activeTab, null, "json", now);
664
+ content = buildInsightsJson(this.activeTab, stats.totals, stats.insights.insights);
665
+ } else {
666
+ const visible = this.visibleTable();
667
+ const sliced = this.tableFilter.trim() !== "" || this.tableHidden.size > 0;
668
+ name = exportFileName("table", this.activeTab, sliced ? "filtered" : null, "csv", now);
669
+ content = buildTableCsv(visible.providers, visible.totals);
670
+ }
671
+ try {
672
+ let configured: string | null = null;
673
+ try {
674
+ configured = parseExportDirSetting(readFileSync(join(getAgentDir(), "settings.json"), "utf8"));
675
+ } catch {
676
+ // No settings file or unreadable: fall through to the default dir.
677
+ }
678
+ const home = homedir();
679
+ const dir = resolveExportDir(configured, home, existsSync("/tmp"), tmpdir());
680
+ mkdirSync(dir, { recursive: true });
681
+ const path = join(dir, name);
682
+ writeFileSync(path, content);
683
+ const shown = path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
684
+ this.exportNote = { text: `Saved ${shown}`, ok: true };
685
+ } catch (err) {
686
+ this.exportNote = { text: `Export failed: ${err instanceof Error ? err.message : String(err)}`, ok: false };
687
+ }
688
+ }
689
+
690
+ private buildGraphModelForView(): GraphModel {
691
+ const hourly = new Map(Array.from(this.data.hourly, ([hour, cells]) => [
692
+ hour,
693
+ new Map(Array.from(cells).filter(([key]) => splitHourlyKey(key).provider !== AUXILIARY_PROVIDER)),
694
+ ]));
695
+ return buildGraphModel(hourly, {
696
+ period: this.activeTab,
697
+ metric: this.graphMetric,
698
+ groupBy: this.graphGroupBy,
699
+ cumulative: this.graphCumulative,
700
+ hidden: this.graphHidden,
701
+ bounds: this.data.bounds,
702
+ });
703
+ }
704
+
705
+ render(width: number): string[] {
706
+ if (this.viewMode === "graph") {
707
+ return clampLines(
708
+ [...this.renderTitle(width), ...this.renderTabs(width, getTableLayout(width)), ...this.renderGraph(width), ...this.renderHelp(width)],
709
+ width
710
+ );
711
+ }
712
+
713
+ if (this.viewMode === "insights") {
714
+ return clampLines([...this.renderTitle(width), ...this.renderInsights(width), ...this.renderHelp(width)], width);
715
+ }
716
+
717
+ if (this.viewMode === "history") {
718
+ return clampLines([...this.renderTitle(width), ...this.renderHistory(width)], width);
719
+ }
720
+
721
+ const layout = getTableLayout(width);
722
+ return clampLines(
723
+ [
724
+ ...this.renderTitle(width),
725
+ ...this.renderTabs(width, layout),
726
+ ...this.renderHeader(layout),
727
+ ...this.renderRows(layout),
728
+ ...this.renderTotals(layout),
729
+ ...this.renderFormulaNote(width),
730
+ ...this.renderHelp(width),
731
+ ],
732
+ width
733
+ );
734
+ }
735
+
736
+ private renderTitle(width: number): string[] {
737
+ const th = this.theme;
738
+ const fullStrip = VIEW_CYCLE.map((view) =>
739
+ view === this.viewMode ? th.fg("accent", `[${VIEW_LABELS[view]}]`) : th.fg("dim", ` ${VIEW_LABELS[view]} `)
740
+ ).join(" ");
741
+ const activeOnly = th.fg("accent", `[${VIEW_LABELS[this.viewMode]}]`);
742
+ const line = pickFittingText(width, [fullStrip, activeOnly]);
743
+ return [line, ""];
744
+ }
745
+
746
+ private renderOverviewSummary(width: number): string[] {
747
+ const th = this.theme, totals = this.visibleTable().totals;
748
+ const summary = th.fg("thinkingHigh", "Usage:") + " " + th.fg("accent", formatCost(totals.cost)) + " · " + th.fg("text", `${formatTokens(totals.tokens.total)} tokens`) + " · " + th.fg("success", `↑${formatTokens(totals.tokens.input + totals.tokens.cacheWrite)}`) + " · " + th.fg("warning", `↓${formatTokens(totals.tokens.output)}`) + " · " + th.fg("thinkingHigh", `${formatTokens(totals.tokens.cacheRead + totals.tokens.cacheWrite)} cache`);
749
+ const stats = th.fg("dim", "Total cost ") + th.fg("accent", formatCost(totals.cost)) + th.fg("dim", " Tokens ") + th.fg("warning", formatTokens(totals.tokens.total)) + th.fg("dim", " Messages ") + th.fg("success", formatNumber(totals.messages)) + th.fg("dim", " Sessions ") + th.fg("thinkingHigh", formatNumber(totals.sessions));
750
+ return [truncateToWidth(summary, width), truncateToWidth(stats, width), ""];
751
+ }
752
+
753
+ private buildDailyProviderModel(): { days: { label: string; total: number; providers: Map<string, number> }[]; providers: { name: string; total: number }[]; max: number; total: number } {
754
+ const now = this.data.bounds.nowMs;
755
+ const start = this.activeTab === "today" ? this.data.bounds.todayMs : this.activeTab === "thisWeek" ? this.data.bounds.weekStartMs : this.activeTab === "lastWeek" ? this.data.bounds.lastWeekStartMs : this.activeTab === "last30Days" ? this.data.bounds.last30DaysStartMs : Math.min(...this.data.hourly.keys(), this.data.bounds.todayMs);
756
+ const end = this.activeTab === "lastWeek" ? this.data.bounds.weekStartMs : now;
757
+ const dayMs = 24 * 3_600_000;
758
+ const days: { label: string; total: number; providers: Map<string, number> }[] = [];
759
+ for (let t = start; t < end; t += dayMs) days.push({ label: new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" }), total: 0, providers: new Map() });
760
+ const providerTotals = new Map<string, number>();
761
+ for (const [hour, cells] of this.data.hourly) {
762
+ if (hour < start || hour >= end) continue;
763
+ const day = days[Math.min(days.length - 1, Math.floor((hour - start) / dayMs))];
764
+ if (!day) continue;
765
+ for (const [key, cell] of cells) {
766
+ const provider = splitHourlyKey(key).provider;
767
+ if (provider === AUXILIARY_PROVIDER) continue;
768
+ day.providers.set(provider, (day.providers.get(provider) ?? 0) + cell.cost);
769
+ day.total += cell.cost;
770
+ providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + cell.cost);
771
+ }
772
+ }
773
+ const providers = [...providerTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([name, total]) => ({ name, total }));
774
+ return { days, providers, max: Math.max(0, ...days.map((d) => d.total)), total: days.reduce((sum, d) => sum + d.total, 0) };
775
+ }
776
+
777
+ private renderGraph(width: number): string[] {
778
+ const th = this.theme;
779
+ const model = this.buildDailyProviderModel();
780
+ const lines: string[] = [...this.renderOverviewSummary(width), th.fg("muted", "Daily total cost · by provider"), ""];
781
+ if (model.total === 0) return [...lines, th.fg("dim", " No usage data for this period"), ""];
782
+
783
+ const labelW = Math.max(formatAxisCost(model.max).length, 3);
784
+ const plotW = Math.max(10, Math.min(width - labelW - 2, model.days.length));
785
+ const start = Math.max(0, model.days.length - plotW);
786
+ const days = model.days.slice(start);
787
+ const height = 8;
788
+ for (let row = height; row >= 1; row--) {
789
+ const threshold = (model.max * row) / height;
790
+ let line = th.fg("dim", `${row === height ? formatAxisCost(model.max) : row === 1 ? "$0" : ""}`.padStart(labelW) + " │");
791
+ for (const day of days) {
792
+ let acc = 0, owner = -1;
793
+ for (let i = 0; i < model.providers.length; i++) {
794
+ const p = model.providers[i]!;
795
+ if (this.graphHidden.has(p.name)) continue;
796
+ acc += day.providers.get(p.name) ?? 0;
797
+ if (acc >= threshold) { owner = i; break; }
798
+ }
799
+ line += owner < 0 ? " " : seriesColor(owner) + "█" + COLOR_RESET;
800
+ }
801
+ lines.push(line);
802
+ }
803
+ lines.push(th.fg("dim", " ".repeat(labelW) + " └" + "─".repeat(days.length)));
804
+ lines.push(th.fg("dim", " ".repeat(labelW + 2) + (days[0]?.label ?? "") + " ".repeat(Math.max(1, days.length - 12)) + (days.at(-1)?.label ?? "")));
805
+ lines.push("");
806
+ for (let i = 0; i < model.providers.length; i++) {
807
+ const p = model.providers[i]!;
808
+ const cursor = i === this.graphLegendIndex ? th.fg("accent", "▸ ") : " ";
809
+ const marker = this.graphHidden.has(p.name) ? th.fg("dim", "·") : seriesColor(i) + "•" + COLOR_RESET;
810
+ lines.push(`${cursor}${marker} ${padRight(this.graphHidden.has(p.name) ? th.fg("dim", p.name) : p.name, 24)} ${padLeft(formatAxisCost(p.total), 8)}`);
811
+ }
812
+ lines.push("");
813
+ return lines;
814
+ }
815
+
816
+
817
+ private renderInsights(width: number): string[] {
818
+ const th = this.theme;
819
+ if (this.insightContent) {
820
+ return [th.bold(this.insightContent.label), th.fg("dim", `${formatNumber(this.insightContent.chars)} chars · Esc back`), "", ...previewText(this.insightContent.text, width, 24).map((line) => th.fg("dim", line)), ""];
821
+ }
822
+ const detail = this.insightDetail;
823
+ const rows = detail ? detail.children : this.promptSections;
824
+ const total = detail ? detail.chars : this.promptSections.reduce((sum, section) => sum + section.chars, 0);
825
+ const source = this.historySelected ? `History ${this.historySelected.slice(0, 8)}` : "Current session";
826
+ const lines = [th.bold(detail ? `${detail.label} sources` : `${source} prompt sources`), th.fg("dim", detail ? "Esc back · source sizes" : "Assembled system prompt · source sizes · Enter details"), ""];
827
+ if (!detail) {
828
+ const widthBar = Math.max(20, Math.min(width - 4, 60));
829
+ const colors: ("accent" | "success" | "warning" | "thinkingHigh")[] = ["accent", "success", "warning", "thinkingHigh"];
830
+ let bar = "";
831
+ for (let i = 0; i < rows.length; i++) bar += th.fg(colors[i % colors.length]!, "█".repeat(Math.round(rows[i]!.chars / Math.max(total, 1) * widthBar)));
832
+ lines.push(bar, "");
833
+ }
834
+ for (let i = 0; i < rows.length; i++) {
835
+ const row = rows[i]!;
836
+ const chars = row.chars;
837
+ const pct = total ? `${(chars / total * 100).toFixed(1)}%` : "0.0%";
838
+ const selected = i === this.insightIndex;
839
+ const marker = selected ? th.fg("accent", "▸ ") : th.fg("dim", "· ");
840
+ const suffix = th.fg("dim", `${formatNumber(chars)} chars ${pct}`);
841
+ lines.push(`${marker}${selected ? th.fg("accent", row.label) : row.label}${" ".repeat(Math.max(1, width - visibleWidth(marker + row.label + suffix)))}${suffix}`);
842
+ }
843
+ lines.push("", th.fg("dim", "[↑↓] select [Enter] open [Esc] back"));
844
+ return lines;
845
+ }
846
+
847
+ private renderCostInsights(width: number): string[] {
848
+ const th = this.theme;
849
+ const stats = this.data[this.activeTab];
850
+ const { insights } = stats.insights;
851
+ const hasUsage =
852
+ stats.totals.messages > 0 ||
853
+ stats.totals.cost > 0 ||
854
+ stats.totals.tokens.total > 0 ||
855
+ stats.totals.tokens.cacheRead > 0;
856
+ const hasCost = stats.totals.cost > 0;
857
+ const lines: string[] = [];
858
+
859
+ // Cap the content column so advice stays readable on very wide terminals.
860
+ const contentWidth = Math.max(Math.min(width, 100), 40);
861
+
862
+ lines.push(th.bold("What's contributing to your cost?"));
863
+ const subtitle = "Approximate, based on local sessions on this machine (these are independent and don't sum to 100%).";
864
+ for (const wrapped of wrapTextWithAnsi(subtitle, contentWidth)) {
865
+ lines.push(th.fg("dim", wrapped));
866
+ }
867
+ lines.push("");
868
+
869
+ if (!hasUsage) {
870
+ lines.push(th.fg("dim", " No usage recorded for this period."));
871
+ lines.push("");
872
+ return lines;
873
+ }
874
+ if (!hasCost) {
875
+ lines.push(th.fg("dim", " No cost data recorded for this period."));
876
+ lines.push("");
877
+ return lines;
878
+ }
879
+ if (insights.length === 0) {
880
+ lines.push(th.fg("dim", " Nothing notable for this period."));
881
+ lines.push("");
882
+ return lines;
883
+ }
884
+
885
+ // Columns: marker(2) + stat(6) + gap(1); advice aligns under the headline.
886
+ const indent = " ";
887
+ const adviceWidth = Math.max(contentWidth - indent.length, 30);
888
+
889
+ const sectionHeader = (label: string, color: "warning" | "accent"): string => {
890
+ const rule = "─".repeat(Math.max(contentWidth - label.length - 1, 4));
891
+ return `${th.fg(color, th.bold(label))} ${th.fg("border", rule)}`;
892
+ };
893
+
894
+ const renderOne = (insight: (typeof insights)[number]): void => {
895
+ const isAlarm = insight.kind === "alarm";
896
+ const marker = isAlarm ? th.fg("warning", "⚠ ") : " ";
897
+ const statText = padLeft(insight.stat, 6);
898
+ const stat = isAlarm ? th.fg("warning", th.bold(statText)) : th.fg("accent", th.bold(statText));
899
+ // De-emphasise the trailing period-share parenthetical on alarm headlines.
900
+ const match = insight.headline.match(/^(.*?)\s*(\(\d[\d.,]*% of this period\))$/);
901
+ const headline = match ? `${match[1]} ${th.fg("dim", match[2]!)}` : insight.headline;
902
+ lines.push(`${marker}${stat} ${headline}`);
903
+ if (insight.advice) {
904
+ for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
905
+ lines.push(`${indent}${th.fg("dim", wrapped)}`);
906
+ }
907
+ }
908
+ lines.push("");
909
+ };
910
+
911
+ const alarms = insights.filter((i) => i.kind === "alarm");
912
+ const structure = insights.filter((i) => i.kind === "structure");
913
+ // Facts first, flagged waste second.
914
+ if (structure.length > 0) {
915
+ lines.push(sectionHeader("Where it went", "accent"));
916
+ for (const insight of structure) renderOne(insight);
917
+ }
918
+ lines.push(sectionHeader("Worth attention", "warning"));
919
+ if (alarms.length > 0) {
920
+ for (const insight of alarms) renderOne(insight);
921
+ } else {
922
+ lines.push(` ${th.fg("success", padLeft("✓", 6))} ${th.fg("dim", "no waste patterns flagged for this period")}`);
923
+ lines.push("");
924
+ }
925
+
926
+ return lines;
927
+ }
928
+
929
+ private renderHistory(width: number): string[] {
930
+ const th = this.theme;
931
+ const sessions = Array.from(this.data.sessions.values()).sort((a, b) => b.timestamp - a.timestamp);
932
+ const lines = [th.bold("Session history"), th.fg("dim", "Assistant usage only — tools/summaries excluded · Enter selects"), ""];
933
+ const selected = sessions.find((session) => session.id === this.historySelected);
934
+ if (selected) {
935
+ lines.push(th.fg("accent", `Selected ${selected.id.slice(0, 8)} · ${selected.cwd || "unknown project"}`));
936
+ lines.push(` ${formatTokens(selected.tokens.total)} tokens · ↑${formatTokens(selected.tokens.input + selected.tokens.cacheWrite)} input · ↓${formatTokens(selected.tokens.output)} output · ${formatTokens(selected.tokens.cacheRead)} cache · ${formatCost(selected.cost)}`, "");
937
+ lines.push(...this.renderInsights(width));
938
+ return lines;
939
+ }
940
+ for (let i = 0; i < sessions.length; i++) {
941
+ const session = sessions[i]!;
942
+ const marker = i === this.historyIndex ? th.fg("accent", "▸ ") : th.fg("dim", "· ");
943
+ const label = `${new Date(session.timestamp).toLocaleString()} ${session.cwd || "unknown"}`;
944
+ const saved = loadPromptSnapshot(session.id) !== null || session.id === this.currentSessionId;
945
+ const suffix = th.fg(saved ? "success" : "dim", `${saved ? "burden" : "no snapshot"} ${formatTokens(session.tokens.total)} ${session.messages} msgs`);
946
+ lines.push(`${marker}${truncateToWidth(label, Math.max(12, width - visibleWidth(marker + suffix) - 2))} ${suffix}`);
947
+ }
948
+ if (!sessions.length) lines.push(th.fg("dim", "No session usage recorded."));
949
+ lines.push("", th.fg("dim", "[↑↓] choose [Enter] run burden [Tab] view [q] close"));
950
+ return lines;
951
+ }
952
+
953
+ private renderTabs(width: number, layout: TableLayout): string[] {
954
+ const th = this.theme;
955
+ const fullTabs = TAB_ORDER.map((tab) => {
956
+ const label = TAB_LABELS[tab];
957
+ return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
958
+ }).join(" ");
959
+
960
+ const activeTabOnly = th.fg("accent", `[${TAB_LABELS[this.activeTab]}]`);
961
+ const tabLine = pickFittingText(width, [
962
+ fullTabs,
963
+ `${activeTabOnly} ${th.fg("dim", "[←→]")}`,
964
+ activeTabOnly,
965
+ ]);
966
+
967
+ // Compact-note only applies to the table view — it's meaningless for insights.
968
+ const infoLines =
969
+ this.viewMode === "table" && layout.compact
970
+ ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
971
+ : [];
972
+
973
+ if (this.viewMode === "table") {
974
+ if (this.tableFilterEditing) {
975
+ infoLines.push(`${th.fg("accent", `/ ${this.tableFilter}▌`)} ${th.fg("dim", "[Enter] keep · [Esc] clear")}`);
976
+ } else if (this.tableFilter.trim() !== "" || this.tableHidden.size > 0) {
977
+ const parts: string[] = [];
978
+ if (this.tableFilter.trim() !== "") parts.push(`filter: “${this.tableFilter.trim()}”`);
979
+ if (this.tableHidden.size > 0) parts.push(`${this.tableHidden.size} hidden`);
980
+ infoLines.push(th.fg("warning", `${parts.join(" · ")} · totals reflect this slice · [a] reset`));
981
+ }
982
+ }
983
+
984
+ return [tabLine, ...infoLines, ""];
985
+ }
986
+
987
+ private renderHeader(layout: TableLayout): string[] {
988
+ const th = this.theme;
989
+
990
+ let headerLine = fitCell("Provider / Model", layout.nameWidth);
991
+ for (const col of layout.columns) {
992
+ const label = fitCell(col.label, col.width, "right");
993
+ headerLine += col.dimmed ? th.fg("dim", label) : label;
994
+ }
995
+
996
+ return [th.fg("muted", headerLine), th.fg("border", "─".repeat(layout.tableWidth))];
997
+ }
998
+
999
+ private renderDataRow(
1000
+ name: string,
1001
+ stats: BaseStats & { sessions: Set<string> | number },
1002
+ layout: TableLayout,
1003
+ options: { indent?: number; selected?: boolean; dimAll?: boolean; prefix?: string } = {}
1004
+ ): string {
1005
+ const th = this.theme;
1006
+ const { indent = 0, selected = false, dimAll = false, prefix } = options;
1007
+
1008
+ const rawPrefix = prefix ?? " ".repeat(indent);
1009
+ const safePrefix = layout.nameWidth > 0 ? truncateToWidth(rawPrefix, layout.nameWidth, "") : "";
1010
+ const prefixWidth = visibleWidth(safePrefix);
1011
+ const innerNameWidth = Math.max(layout.nameWidth - prefixWidth, 0);
1012
+ const truncName = innerNameWidth > 0 ? truncateToWidth(name, innerNameWidth) : "";
1013
+ const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
1014
+
1015
+ let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
1016
+
1017
+ for (const col of layout.columns) {
1018
+ const value = fitCell(col.getValue(stats), col.width, "right");
1019
+ const shouldDim = col.dimmed || dimAll;
1020
+ row += shouldDim ? th.fg("dim", value) : value;
1021
+ }
1022
+
1023
+ return row;
1024
+ }
1025
+
1026
+ private renderRows(layout: TableLayout): string[] {
1027
+ const th = this.theme;
1028
+ const lines: string[] = [];
1029
+
1030
+ if (this.providerOrder.length === 0) {
1031
+ lines.push(th.fg("dim", " No usage data for this period"));
1032
+ return lines;
1033
+ }
1034
+
1035
+ const visible = Array.from(this.visibleTable().providers.entries());
1036
+ if (visible.length === 0) {
1037
+ lines.push(th.fg("dim", " Nothing matches the current filter — [a] resets"));
1038
+ return lines;
1039
+ }
1040
+
1041
+ for (let i = 0; i < visible.length; i++) {
1042
+ const [providerName, providerStats] = visible[i]!;
1043
+ const isSelected = i === this.selectedIndex;
1044
+ const isExpanded = this.expanded.has(providerName);
1045
+ const arrow = isExpanded ? "▾" : "▸";
1046
+ const prefix = isSelected ? th.fg("accent", `${arrow} `) : th.fg("dim", `${arrow} `);
1047
+
1048
+ lines.push(
1049
+ this.renderDataRow(providerName, providerStats, layout, {
1050
+ selected: isSelected,
1051
+ prefix,
1052
+ })
1053
+ );
1054
+
1055
+ if (isExpanded) {
1056
+ const models = Array.from(providerStats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
1057
+
1058
+ for (const [modelName, modelStats] of models) {
1059
+ lines.push(this.renderDataRow(modelName, modelStats, layout, { indent: 4, dimAll: true }));
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ return lines;
1065
+ }
1066
+
1067
+ private renderTotals(layout: TableLayout): string[] {
1068
+ const th = this.theme;
1069
+ const { totals } = this.visibleTable();
1070
+
1071
+ let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
1072
+ for (const col of layout.columns) {
1073
+ const value = fitCell(col.getValue(totals), col.width, "right");
1074
+ totalRow += col.dimmed ? th.fg("dim", value) : value;
1075
+ }
1076
+
1077
+ return [th.fg("border", "─".repeat(layout.tableWidth)), totalRow, ""];
1078
+ }
1079
+
1080
+ private renderFormulaNote(width: number): string[] {
1081
+ const line = pickFittingText(width, [
1082
+ "Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite (as of 0.2.0)",
1083
+ "Tokens = In + Out + CacheWrite · ↑In = In + CacheWrite (v0.2.0+)",
1084
+ "Tokens & ↑In include CacheWrite (v0.2.0+)",
1085
+ "Incl. CacheWrite (v0.2.0+)",
1086
+ ]);
1087
+ return [this.theme.fg("dim", line), ""];
1088
+ }
1089
+
1090
+ private renderHelp(width: number): string[] {
1091
+ const noteLines = this.exportNote
1092
+ ? [this.theme.fg(this.exportNote.ok ? "success" : "error", `${this.exportNote.ok ? "✓" : "✗"} ${this.exportNote.text}`), ""]
1093
+ : [];
1094
+ const variants =
1095
+ this.viewMode === "graph"
1096
+ ? [
1097
+ "[Tab/←→] period [↑↓/Enter] provider filter [a] all [e] export [v] view [q] close",
1098
+ "[Tab] period [↑↓/Enter] filter [e] export [v] view [q] close",
1099
+ "[↑↓] filter [v] view [q] close",
1100
+ "[q] close",
1101
+ ]
1102
+ : this.viewMode === "insights"
1103
+ ? [
1104
+ "[Tab/←→] period [↑↓] select [e] export [v] view [q] close",
1105
+ "[Tab] period [↑↓] select [e] export [v] view [q] close",
1106
+ "[↑↓] select [v] view [q] close",
1107
+ "[q] close",
1108
+ ]
1109
+ : [
1110
+ "[Tab/←→] period [↑↓] select [Enter] expand [/] filter [x] hide [a] all [e] export [v] view [q] close",
1111
+ "[Tab] period [↑↓] select [Enter] expand [/] filter [x] hide [e] export [v] view [q] close",
1112
+ "[↑↓] select [Enter] expand [/] filter [x] hide [v] view [q] close",
1113
+ "[↑↓] select [/] [x] [v] [q]",
1114
+ "[↑↓] select [q] close",
1115
+ "[q] close",
1116
+ ];
1117
+ const line = pickFittingText(width, variants);
1118
+ return [...noteLines, this.theme.fg("dim", line)];
1119
+ }
1120
+
1121
+ invalidate(): void {}
1122
+ dispose(): void {}
1123
+ }
1124
+
1125
+ // =============================================================================
1126
+ // Extension Entry Point
1127
+ // =============================================================================
1128
+
1129
+ function setUsageFooter(ctx: { ui: ExtensionCommandContext["ui"] }, totals: TotalStats): void {
1130
+ ctx.ui.setFooter((_tui, theme) => ({
1131
+ invalidate() {},
1132
+ render(width: number): string[] {
1133
+ const line = theme.fg("thinkingHigh", "Usage:") + " " + theme.fg("accent", formatCost(totals.cost)) + " · " + theme.fg("text", `${formatTokens(totals.tokens.total)} tokens`) + " · " + theme.fg("success", `↑${formatTokens(totals.tokens.input + totals.tokens.cacheWrite)}`) + " · " + theme.fg("warning", `↓${formatTokens(totals.tokens.output)}`) + " · " + theme.fg("thinkingHigh", `${formatTokens(totals.tokens.cacheRead + totals.tokens.cacheWrite)} cache`) + theme.fg("dim", " · (Today)");
1134
+ return [truncateToWidth(line, width)];
1135
+ },
1136
+ }));
1137
+ }
1138
+
1139
+ export default function (pi: ExtensionAPI) {
1140
+ const refreshFooter = (ctx: { hasUI: boolean; ui: ExtensionCommandContext["ui"] }) => {
1141
+ if (!ctx.hasUI) return;
1142
+ void collectUsageData().then((data) => {
1143
+ if (!data) return;
1144
+ const providers = Array.from(data.today.providers).filter(([name]) => name !== AUXILIARY_PROVIDER).map(([, stats]) => stats);
1145
+ const totals: TotalStats = { sessions: new Set(providers.flatMap((p) => Array.from(p.sessions))).size, messages: 0, cost: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } };
1146
+ for (const stats of providers) {
1147
+ totals.messages += stats.messages; totals.cost += stats.cost;
1148
+ totals.tokens.total += stats.tokens.total; totals.tokens.input += stats.tokens.input; totals.tokens.output += stats.tokens.output; totals.tokens.cacheRead += stats.tokens.cacheRead; totals.tokens.cacheWrite += stats.tokens.cacheWrite;
1149
+ }
1150
+ setUsageFooter(ctx, totals);
1151
+ });
1152
+ };
1153
+ const snapshotPrompt = (ctx: { sessionManager: { getSessionId(): string }; getSystemPrompt(): string }) => {
1154
+ try { savePromptSnapshot(ctx.sessionManager.getSessionId(), ctx.getSystemPrompt()); } catch { /* usage must never block Pi */ }
1155
+ };
1156
+ pi.on("session_start", (_event, ctx) => { snapshotPrompt(ctx); refreshFooter(ctx); });
1157
+ pi.on("message_end", (_event, ctx) => { snapshotPrompt(ctx); refreshFooter(ctx); });
1158
+ pi.registerCommand("usage", {
1159
+ description: "Show usage statistics dashboard",
1160
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
1161
+ try { savePromptSnapshot(ctx.sessionManager.getSessionId(), ctx.getSystemPrompt()); } catch { /* continue without history snapshot */ }
1162
+ if (!ctx.hasUI) {
1163
+ return;
1164
+ }
1165
+
1166
+ const data = await ctx.ui.custom<UsageData | null>((tui, theme, _kb, done) => {
1167
+ const loader = new CancellableLoader(
1168
+ tui,
1169
+ (s: string) => theme.fg("accent", s),
1170
+ (s: string) => theme.fg("muted", s),
1171
+ "Loading Usage..."
1172
+ );
1173
+ let finished = false;
1174
+ const finish = (value: UsageData | null) => {
1175
+ if (finished) return;
1176
+ finished = true;
1177
+ loader.dispose();
1178
+ done(value);
1179
+ };
1180
+
1181
+ loader.onAbort = () => finish(null);
1182
+
1183
+ const onProgress = (p: CollectProgress): void => {
1184
+ if (finished || p.filesToParse === 0) return;
1185
+ const files = `${p.filesParsed.toLocaleString()}/${p.filesToParse.toLocaleString()} files`;
1186
+ if (p.mode === "update") {
1187
+ const since = p.sinceMs !== null ? ` since ${formatSinceDate(p.sinceMs)}` : "";
1188
+ loader.setMessage(`Updating your usage history${since}… (${files})`);
1189
+ } else if (p.mode === "rebuild") {
1190
+ loader.setMessage(`Rebuilding your usage history — the cache format changed… (${files})`);
1191
+ } else {
1192
+ loader.setMessage(`Building your usage history for the first time… (${files})`);
1193
+ }
1194
+ };
1195
+
1196
+ collectUsageData({ signal: loader.signal, onProgress })
1197
+ .then(finish)
1198
+ .catch(() => finish(null));
1199
+
1200
+ return loader;
1201
+ });
1202
+
1203
+ if (!data) {
1204
+ return;
1205
+ }
1206
+
1207
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1208
+ const container = new Container();
1209
+
1210
+ // Top border
1211
+ container.addChild(new Spacer(1));
1212
+ container.addChild(new DynamicBorder((s: string) => theme.fg("border", s)));
1213
+ container.addChild(new Spacer(1));
1214
+
1215
+ const usage = new UsageComponent(theme, data, ctx.getSystemPrompt(), ctx.sessionManager.getSessionId(), () => tui.requestRender(), () => done());
1216
+
1217
+ return {
1218
+ render: (w: number) => {
1219
+ const borderLines = clampLines(container.render(w), w);
1220
+ const usageLines = usage.render(w);
1221
+ const bottomBorder = theme.fg("border", "─".repeat(w));
1222
+ return clampLines([...borderLines, ...usageLines, "", bottomBorder], w);
1223
+ },
1224
+ invalidate: () => container.invalidate(),
1225
+ handleInput: (input: string) => usage.handleInput(input),
1226
+ dispose: () => {},
1227
+ };
1228
+ });
1229
+ },
1230
+ });
1231
+ }