@dropalltables/yacu 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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/yacu.js +1782 -0
  4. package/package.json +62 -0
  5. package/src/data/jsonl.ts +81 -0
  6. package/src/data/load.ts +52 -0
  7. package/src/data/pricing.test.ts +22 -0
  8. package/src/data/pricing.ts +49 -0
  9. package/src/data/sources/claude.test.ts +40 -0
  10. package/src/data/sources/claude.ts +118 -0
  11. package/src/data/sources/codex.ts +73 -0
  12. package/src/data/sources/cursor.test.ts +16 -0
  13. package/src/data/sources/cursor.ts +83 -0
  14. package/src/data/sources/gemini.test.ts +27 -0
  15. package/src/data/sources/gemini.ts +112 -0
  16. package/src/data/sources/grok.ts +60 -0
  17. package/src/data/sources/opencode.ts +53 -0
  18. package/src/data/types.ts +23 -0
  19. package/src/domain/aggregate.test.ts +62 -0
  20. package/src/domain/aggregate.ts +143 -0
  21. package/src/domain/dates.ts +24 -0
  22. package/src/domain/types.ts +48 -0
  23. package/src/index.tsx +15 -0
  24. package/src/tui/App.tsx +170 -0
  25. package/src/tui/ThemeContext.tsx +12 -0
  26. package/src/tui/chart.test.ts +48 -0
  27. package/src/tui/chart.ts +130 -0
  28. package/src/tui/components/Breakdown.tsx +85 -0
  29. package/src/tui/components/Chart.test.tsx +67 -0
  30. package/src/tui/components/Chart.tsx +159 -0
  31. package/src/tui/components/Footer.tsx +11 -0
  32. package/src/tui/components/Header.tsx +52 -0
  33. package/src/tui/components/PointerButton.tsx +40 -0
  34. package/src/tui/components/ScanBoot.test.tsx +30 -0
  35. package/src/tui/components/ScanBoot.tsx +30 -0
  36. package/src/tui/components/Segmented.test.tsx +32 -0
  37. package/src/tui/components/Segmented.tsx +59 -0
  38. package/src/tui/components/Summary.tsx +67 -0
  39. package/src/tui/components/Totals.tsx +27 -0
  40. package/src/tui/format.ts +44 -0
  41. package/src/tui/theme.ts +99 -0
  42. package/src/tui/usePointer.ts +11 -0
  43. package/tsconfig.json +15 -0
package/dist/yacu.js ADDED
@@ -0,0 +1,1782 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/index.tsx
5
+ import { createCliRenderer, RGBA as RGBA2 } from "@opentui/core";
6
+ import { createRoot } from "@opentui/react";
7
+
8
+ // src/tui/App.tsx
9
+ import { useEffect as useEffect2, useMemo as useMemo2, useState as useState7 } from "react";
10
+ import { useKeyboard, useRenderer as useRenderer2, useTerminalDimensions } from "@opentui/react";
11
+
12
+ // src/domain/dates.ts
13
+ function localDate(value) {
14
+ const date = value instanceof Date ? value : new Date(value);
15
+ const year = date.getFullYear();
16
+ const month = String(date.getMonth() + 1).padStart(2, "0");
17
+ const day = String(date.getDate()).padStart(2, "0");
18
+ return `${year}-${month}-${day}`;
19
+ }
20
+ function dateDaysAgo(daysAgo) {
21
+ const date = new Date;
22
+ date.setHours(0, 0, 0, 0);
23
+ date.setDate(date.getDate() - daysAgo);
24
+ return localDate(date);
25
+ }
26
+ function datesInRange(days) {
27
+ return Array.from({ length: days }, (_, index) => dateDaysAgo(days - index - 1));
28
+ }
29
+ function shortDate(value) {
30
+ return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(new Date(`${value}T12:00:00`));
31
+ }
32
+
33
+ // src/domain/types.ts
34
+ var SOURCE_META = {
35
+ claude: { id: "claude", label: "Claude Code", mark: "*" },
36
+ codex: { id: "codex", label: "Codex", mark: "o" },
37
+ cursor: { id: "cursor", label: "Cursor", mark: ">" },
38
+ gemini: { id: "gemini", label: "Gemini CLI", mark: "g" },
39
+ grok: { id: "grok", label: "Grok Build", mark: "x" },
40
+ opencode: { id: "opencode", label: "OpenCode", mark: "+" }
41
+ };
42
+ var SOURCE_ORDER = ["codex", "claude", "cursor", "gemini", "grok", "opencode"];
43
+
44
+ // src/domain/aggregate.ts
45
+ function emptyTotals() {
46
+ return {
47
+ inputTokens: 0,
48
+ outputTokens: 0,
49
+ cacheCreationTokens: 0,
50
+ cacheReadTokens: 0,
51
+ processedTokens: 0,
52
+ costUsd: 0,
53
+ cacheSavingsUsd: 0
54
+ };
55
+ }
56
+ function addRecord(target, record) {
57
+ target.inputTokens += record.inputTokens;
58
+ target.outputTokens += record.outputTokens;
59
+ target.cacheCreationTokens += record.cacheCreationTokens;
60
+ target.cacheReadTokens += record.cacheReadTokens;
61
+ target.processedTokens += record.inputTokens + record.outputTokens + record.cacheCreationTokens + record.cacheReadTokens;
62
+ target.costUsd += record.costUsd;
63
+ target.cacheSavingsUsd += record.cacheSavingsUsd;
64
+ }
65
+ function metricValue(totals, metric) {
66
+ return metric === "cost" ? totals.costUsd : totals.processedTokens;
67
+ }
68
+ function buildDashboard(dataset, range, metric) {
69
+ const days = datesInRange(range);
70
+ const dateSet = new Set(days);
71
+ const records = dataset.records.filter((record) => dateSet.has(record.date));
72
+ const sessions = dataset.sessions.filter((session) => dateSet.has(session.date));
73
+ const totals = emptyTotals();
74
+ for (const record of records)
75
+ addRecord(totals, record);
76
+ const providerMap = new Map;
77
+ const providerSessions = new Map;
78
+ for (const record of records) {
79
+ const aggregate = providerMap.get(record.source) ?? emptyTotals();
80
+ addRecord(aggregate, record);
81
+ providerMap.set(record.source, aggregate);
82
+ }
83
+ for (const session of sessions) {
84
+ const ids = providerSessions.get(session.source) ?? new Set;
85
+ ids.add(session.id);
86
+ providerSessions.set(session.source, ids);
87
+ }
88
+ const metricTotal = metricValue(totals, metric);
89
+ const providers = SOURCE_ORDER.filter((source) => providerMap.has(source)).map((source) => {
90
+ const values = providerMap.get(source) ?? emptyTotals();
91
+ return {
92
+ ...values,
93
+ source,
94
+ sessions: providerSessions.get(source)?.size ?? 0,
95
+ share: metricTotal > 0 ? metricValue(values, metric) / metricTotal : 0
96
+ };
97
+ });
98
+ const modelMap = new Map;
99
+ for (const record of records) {
100
+ const key = `${record.source}\x00${record.model}`;
101
+ const row = modelMap.get(key) ?? {
102
+ ...emptyTotals(),
103
+ key,
104
+ label: record.model,
105
+ source: record.source,
106
+ share: 0
107
+ };
108
+ addRecord(row, record);
109
+ modelMap.set(key, row);
110
+ }
111
+ const models = [...modelMap.values()].map((row) => ({ ...row, share: metricTotal > 0 ? metricValue(row, metric) / metricTotal : 0 })).sort((a, b) => metricValue(b, metric) - metricValue(a, metric));
112
+ const dailyMap = new Map;
113
+ for (const day of days) {
114
+ dailyMap.set(day, { ...emptyTotals(), key: day, label: day, share: 0 });
115
+ }
116
+ for (const record of records)
117
+ addRecord(dailyMap.get(record.date), record);
118
+ const daily = [...dailyMap.values()].map((row) => ({ ...row, share: metricTotal > 0 ? metricValue(row, metric) / metricTotal : 0 })).sort((a, b) => metricValue(b, metric) - metricValue(a, metric));
119
+ const series = Object.fromEntries(SOURCE_ORDER.map((source) => {
120
+ const values = days.map((day) => {
121
+ const dayTotals = emptyTotals();
122
+ for (const record of records) {
123
+ if (record.source === source && record.date === day)
124
+ addRecord(dayTotals, record);
125
+ }
126
+ return metricValue(dayTotals, metric);
127
+ });
128
+ return [source, values];
129
+ }));
130
+ return { days, records, totals, sessions: new Set(sessions.map((session) => session.id)).size, providers, models, daily, series };
131
+ }
132
+
133
+ // src/data/sources/claude.ts
134
+ import { homedir } from "os";
135
+ import { join, resolve } from "path";
136
+
137
+ // src/data/pricing.ts
138
+ import {
139
+ estimateUsdCost,
140
+ normalizeTokenUsage,
141
+ pricingFromUsdPerMillion
142
+ } from "tokentally";
143
+ var PRICING = [
144
+ [/claude.*opus/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 5, outputUsdPerMillion: 25, cachedInputUsdPerMillion: 0.5, cacheCreationInputUsdPerMillion: 6.25 })],
145
+ [/claude.*sonnet/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 3, outputUsdPerMillion: 15, cachedInputUsdPerMillion: 0.3, cacheCreationInputUsdPerMillion: 3.75 })],
146
+ [/claude.*haiku/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1, outputUsdPerMillion: 5, cachedInputUsdPerMillion: 0.1, cacheCreationInputUsdPerMillion: 1.25 })],
147
+ [/(^|\/)gpt-5|codex/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.75, outputUsdPerMillion: 14, cachedInputUsdPerMillion: 0.175 })],
148
+ [/grok|composer/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.2 })],
149
+ [/deepseek/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.27, outputUsdPerMillion: 1.1, cachedInputUsdPerMillion: 0.07 })],
150
+ [/gemini-3\.1-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 12, cachedInputUsdPerMillion: 0.2 })],
151
+ [/gemini-2\.5-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.25, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.125 })],
152
+ [/gemini-3\.[67]-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.75, outputUsdPerMillion: 3.75, cachedInputUsdPerMillion: 0.075 })],
153
+ [/gemini-3\.5-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.5, outputUsdPerMillion: 9, cachedInputUsdPerMillion: 0.15 })],
154
+ [/gemini-3\.1-flash-lite/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.25, outputUsdPerMillion: 1.5, cachedInputUsdPerMillion: 0.025 })],
155
+ [/gemini.*flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.3, outputUsdPerMillion: 2.5, cachedInputUsdPerMillion: 0.03 })]
156
+ ];
157
+ function resolvePricing(model) {
158
+ return PRICING.find(([pattern]) => pattern.test(model))?.[1] ?? null;
159
+ }
160
+ function estimateCost(model, tokens) {
161
+ const usage = normalizeTokenUsage({
162
+ inputTokens: tokens.inputTokens,
163
+ outputTokens: tokens.outputTokens,
164
+ cachedInputTokens: tokens.cacheReadTokens,
165
+ cacheCreationInputTokens: tokens.cacheCreationTokens
166
+ });
167
+ return estimateUsdCost({ usage, pricing: resolvePricing(model) })?.totalUsd ?? 0;
168
+ }
169
+ function estimateCacheSavings(model, cachedReadTokens) {
170
+ const pricing = resolvePricing(model);
171
+ if (pricing == null)
172
+ return 0;
173
+ const cachedRate = pricing.cachedInputUsdPerToken ?? pricing.inputUsdPerToken;
174
+ return Math.max(0, cachedReadTokens * (pricing.inputUsdPerToken - cachedRate));
175
+ }
176
+
177
+ // src/data/jsonl.ts
178
+ import { stat } from "fs/promises";
179
+ async function forEachJsonLine(path, callback) {
180
+ const reader = Bun.file(path).stream().getReader();
181
+ const decoder = new TextDecoder;
182
+ let pending = "";
183
+ while (true) {
184
+ const { done, value } = await reader.read();
185
+ if (done)
186
+ break;
187
+ pending += decoder.decode(value, { stream: true });
188
+ const lines = pending.split(`
189
+ `);
190
+ pending = lines.pop() ?? "";
191
+ for (const line of lines)
192
+ await parseLine(line, callback);
193
+ }
194
+ pending += decoder.decode();
195
+ await parseLine(pending, callback);
196
+ }
197
+ async function parseLine(line, callback) {
198
+ if (line.trim() === "")
199
+ return;
200
+ try {
201
+ await callback(JSON.parse(line));
202
+ } catch {
203
+ return;
204
+ }
205
+ }
206
+ async function globFiles(root, pattern, options = {}) {
207
+ if (!await Bun.file(root).exists() && !await isDirectory(root))
208
+ return [];
209
+ const files = [];
210
+ const glob = new Bun.Glob(pattern);
211
+ for await (const file of glob.scan({
212
+ cwd: root,
213
+ absolute: true,
214
+ onlyFiles: options.includeSymlinks !== true
215
+ }))
216
+ files.push(file);
217
+ if (options.includeSymlinks === true) {
218
+ const existingFiles = await Promise.all(files.map(async (file) => {
219
+ try {
220
+ return (await stat(file)).isFile() ? file : null;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }));
225
+ return existingFiles.filter((file) => file != null).sort();
226
+ }
227
+ return files.sort();
228
+ }
229
+ async function isDirectory(path) {
230
+ try {
231
+ return (await stat(path)).isDirectory();
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+ function asObject(value) {
237
+ return typeof value === "object" && value != null && !Array.isArray(value) ? value : null;
238
+ }
239
+ function numberValue(value) {
240
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
241
+ }
242
+ function stringValue(value) {
243
+ return typeof value === "string" && value !== "" ? value : null;
244
+ }
245
+
246
+ // src/data/sources/claude.ts
247
+ async function loadClaudeUsage() {
248
+ const files = [...new Set((await Promise.all(claudeRoots().map((root) => globFiles(join(root, "projects"), "**/*.jsonl", { includeSymlinks: true })))).flat())];
249
+ const records = [];
250
+ const sessions = new Map;
251
+ const processed = new Set;
252
+ for (const path of files) {
253
+ await forEachJsonLine(path, (value) => {
254
+ const entry = parseClaudeUsageEntry(value);
255
+ if (entry == null)
256
+ return;
257
+ const uniqueId = entry.messageId != null && entry.requestId != null ? `${entry.messageId}:${entry.requestId}` : null;
258
+ if (uniqueId != null && processed.has(uniqueId))
259
+ return;
260
+ if (uniqueId != null)
261
+ processed.add(uniqueId);
262
+ const date = localDate(entry.timestamp);
263
+ const sessionId = entry.sessionId == null ? undefined : `claude:${entry.sessionId}`;
264
+ const tokens = {
265
+ inputTokens: entry.inputTokens,
266
+ outputTokens: entry.outputTokens,
267
+ cacheCreationTokens: entry.cacheCreationTokens,
268
+ cacheReadTokens: entry.cacheReadTokens
269
+ };
270
+ if (sumTokens(tokens) === 0 || entry.model === "<synthetic>")
271
+ return;
272
+ records.push({
273
+ date,
274
+ source: "claude",
275
+ model: entry.model,
276
+ sessionId,
277
+ ...tokens,
278
+ costUsd: estimateCost(entry.model, tokens),
279
+ cacheSavingsUsd: estimateCacheSavings(entry.model, entry.cacheReadTokens)
280
+ });
281
+ if (sessionId != null) {
282
+ const existing = sessions.get(sessionId);
283
+ if (existing == null || existing.date < date) {
284
+ sessions.set(sessionId, { id: sessionId, source: "claude", date });
285
+ }
286
+ }
287
+ });
288
+ }
289
+ return { records, sessions: [...sessions.values()], files: files.length };
290
+ }
291
+ function parseClaudeUsageEntry(value) {
292
+ const row = asObject(value);
293
+ const message = asObject(row?.message);
294
+ const usage = asObject(message?.usage);
295
+ const timestamp = stringValue(row?.timestamp);
296
+ const inputTokens = finiteNumber(usage?.input_tokens);
297
+ const outputTokens = finiteNumber(usage?.output_tokens);
298
+ if (timestamp == null || Number.isNaN(new Date(timestamp).getTime()) || inputTokens == null || outputTokens == null)
299
+ return null;
300
+ return {
301
+ timestamp,
302
+ sessionId: stringValue(row?.sessionId),
303
+ model: stringValue(message?.model) ?? "unknown",
304
+ messageId: stringValue(message?.id),
305
+ requestId: stringValue(row?.requestId),
306
+ inputTokens,
307
+ outputTokens,
308
+ cacheCreationTokens: numberValue(usage?.cache_creation_input_tokens),
309
+ cacheReadTokens: numberValue(usage?.cache_read_input_tokens)
310
+ };
311
+ }
312
+ function claudeRoots() {
313
+ const configured = process.env.CLAUDE_CONFIG_DIR?.trim();
314
+ if (configured != null && configured !== "") {
315
+ return [...new Set(configured.split(",").map((path) => resolve(path.trim())).filter(Boolean))];
316
+ }
317
+ return [join(homedir(), ".config", "claude"), join(homedir(), ".claude")];
318
+ }
319
+ function finiteNumber(value) {
320
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
321
+ }
322
+ function sumTokens(tokens) {
323
+ return tokens.inputTokens + tokens.outputTokens + tokens.cacheCreationTokens + tokens.cacheReadTokens;
324
+ }
325
+
326
+ // src/data/sources/codex.ts
327
+ import { homedir as homedir2 } from "os";
328
+ import { join as join2 } from "path";
329
+ async function loadCodexUsage() {
330
+ const root = join2(process.env.CODEX_HOME ?? join2(homedir2(), ".codex"), "sessions");
331
+ const files = await globFiles(root, "**/*.jsonl");
332
+ const records = [];
333
+ const sessions = new Map;
334
+ for (const path of files) {
335
+ let sessionId = path;
336
+ let model = "codex";
337
+ let skipSession = false;
338
+ let previousTotal = "";
339
+ await forEachJsonLine(path, (unknownValue) => {
340
+ const value = asObject(unknownValue);
341
+ const payload = asObject(value?.payload);
342
+ const type = stringValue(value?.type);
343
+ if (type === "session_meta") {
344
+ sessionId = stringValue(payload?.id) ?? sessionId;
345
+ const timestamp2 = stringValue(payload?.timestamp) ?? stringValue(value?.timestamp);
346
+ const threadSource = payload?.thread_source;
347
+ skipSession = typeof threadSource !== "string" && JSON.stringify(threadSource).toLowerCase().includes("sub");
348
+ previousTotal = "";
349
+ if (timestamp2 != null && !skipSession) {
350
+ sessions.set(sessionId, { id: `codex:${sessionId}`, source: "codex", date: localDate(timestamp2) });
351
+ }
352
+ return;
353
+ }
354
+ if (type === "turn_context") {
355
+ model = stringValue(payload?.model) ?? model;
356
+ return;
357
+ }
358
+ if (skipSession || type !== "event_msg" || payload?.type !== "token_count")
359
+ return;
360
+ const info = asObject(payload.info);
361
+ const last = asObject(info?.last_token_usage);
362
+ const total = asObject(info?.total_token_usage);
363
+ if (last == null)
364
+ return;
365
+ const signature = JSON.stringify(total);
366
+ if (signature === previousTotal)
367
+ return;
368
+ previousTotal = signature;
369
+ const cached = numberValue(last.cached_input_tokens);
370
+ const rawInput = numberValue(last.input_tokens);
371
+ const input = Math.max(0, rawInput - cached);
372
+ const output = numberValue(last.output_tokens);
373
+ if (input + cached + output === 0)
374
+ return;
375
+ const timestamp = stringValue(value?.timestamp) ?? new Date().toISOString();
376
+ const tokens = { inputTokens: input, outputTokens: output, cacheCreationTokens: 0, cacheReadTokens: cached };
377
+ records.push({
378
+ date: localDate(timestamp),
379
+ source: "codex",
380
+ model,
381
+ sessionId: `codex:${sessionId}`,
382
+ ...tokens,
383
+ costUsd: estimateCost(model, tokens),
384
+ cacheSavingsUsd: estimateCacheSavings(model, cached)
385
+ });
386
+ });
387
+ }
388
+ return { records, sessions: [...sessions.values()], files: files.length };
389
+ }
390
+
391
+ // src/data/sources/cursor.ts
392
+ import { stat as stat2 } from "fs/promises";
393
+ import { homedir as homedir3 } from "os";
394
+ import { basename, dirname, join as join3 } from "path";
395
+ import { countTokens } from "gpt-tokenizer/encoding/o200k_base";
396
+ async function loadCursorUsage() {
397
+ const root = process.env.CURSOR_CONFIG_DIR ?? join3(homedir3(), ".cursor");
398
+ const files = await globFiles(join3(root, "projects"), "**/agent-transcripts/**/*.jsonl");
399
+ const fallbackModel = await readConfiguredModel(join3(root, "cli-config.json"));
400
+ const records = [];
401
+ const sessions = [];
402
+ for (const path of files) {
403
+ const counts = { inputTokens: 0, outputTokens: 0 };
404
+ let model = fallbackModel;
405
+ await forEachJsonLine(path, (unknownValue) => {
406
+ const value = asObject(unknownValue);
407
+ const message = asObject(value?.message) ?? value;
408
+ const role = stringValue(value?.role) ?? stringValue(message?.role);
409
+ model = stringValue(value?.model) ?? stringValue(message?.model) ?? model;
410
+ const tokens2 = countContent(message?.content);
411
+ if (role === "user")
412
+ counts.inputTokens += tokens2;
413
+ if (role === "assistant")
414
+ counts.outputTokens += tokens2;
415
+ });
416
+ if (counts.inputTokens + counts.outputTokens === 0)
417
+ continue;
418
+ const timestamp = (await stat2(path)).mtime;
419
+ const date = localDate(timestamp);
420
+ const rawSessionId = basename(dirname(path));
421
+ const sessionId = `cursor:${rawSessionId}`;
422
+ const tokens = { ...counts, cacheCreationTokens: 0, cacheReadTokens: 0 };
423
+ records.push({
424
+ date,
425
+ source: "cursor",
426
+ model,
427
+ sessionId,
428
+ ...tokens,
429
+ costUsd: estimateCost(model, tokens),
430
+ cacheSavingsUsd: 0
431
+ });
432
+ sessions.push({ id: sessionId, source: "cursor", date });
433
+ }
434
+ return { records, sessions, files: files.length };
435
+ }
436
+ function countContent(value) {
437
+ if (typeof value === "string")
438
+ return countTokens(value);
439
+ if (!Array.isArray(value))
440
+ return 0;
441
+ return value.reduce((total, item) => {
442
+ const part = asObject(item);
443
+ if (part == null)
444
+ return total;
445
+ const text = stringValue(part.text);
446
+ if (text != null)
447
+ return total + countTokens(text);
448
+ if (part.type === "tool_use")
449
+ return total + countTokens(JSON.stringify({ name: part.name, input: part.input }));
450
+ return total;
451
+ }, 0);
452
+ }
453
+ async function readConfiguredModel(path) {
454
+ try {
455
+ const config = asObject(await Bun.file(path).json());
456
+ const model = asObject(config?.model);
457
+ const selected = asObject(config?.selectedModel);
458
+ return stringValue(model?.modelId) ?? stringValue(model?.id) ?? stringValue(selected?.modelId) ?? stringValue(selected?.id) ?? "cursor-agent";
459
+ } catch {
460
+ return "cursor-agent";
461
+ }
462
+ }
463
+
464
+ // src/data/sources/gemini.ts
465
+ import { homedir as homedir4 } from "os";
466
+ import { basename as basename2, join as join4 } from "path";
467
+ async function loadGeminiUsage() {
468
+ const root = process.env.GEMINI_HOME ?? join4(homedir4(), ".gemini");
469
+ const files = [
470
+ ...await globFiles(join4(root, "tmp"), "**/chats/*.json"),
471
+ ...await globFiles(join4(root, "tmp"), "**/chats/*.jsonl")
472
+ ];
473
+ const records = [];
474
+ const sessions = new Map;
475
+ for (const path of files) {
476
+ let parsed = null;
477
+ try {
478
+ parsed = parseGeminiConversation(await Bun.file(path).text(), basename2(path));
479
+ } catch {
480
+ continue;
481
+ }
482
+ if (parsed == null)
483
+ continue;
484
+ const sessionId = `gemini:${parsed.sessionId}`;
485
+ for (const message of parsed.messages) {
486
+ if (message.type !== "gemini")
487
+ continue;
488
+ const usage = asObject(message.tokens) ?? asObject(message.usageMetadata);
489
+ if (usage == null)
490
+ continue;
491
+ const cached = numberValue(usage.cached) || numberValue(usage.cachedContentTokenCount);
492
+ const prompt = numberValue(usage.input) || numberValue(usage.promptTokenCount);
493
+ const candidates = numberValue(usage.output) || numberValue(usage.candidatesTokenCount);
494
+ const thoughts = numberValue(usage.thoughts) || numberValue(usage.thoughtsTokenCount);
495
+ const tool = numberValue(usage.tool) || numberValue(usage.toolUsePromptTokenCount);
496
+ const tokens = {
497
+ inputTokens: Math.max(0, prompt - cached) + tool,
498
+ outputTokens: candidates + thoughts,
499
+ cacheCreationTokens: 0,
500
+ cacheReadTokens: cached
501
+ };
502
+ if (Object.values(tokens).every((value) => value === 0))
503
+ continue;
504
+ const model = stringValue(message.model) ?? "gemini";
505
+ const timestamp = stringValue(message.timestamp) ?? parsed.startTime ?? new Date().toISOString();
506
+ const date = localDate(timestamp);
507
+ records.push({
508
+ date,
509
+ source: "gemini",
510
+ model,
511
+ sessionId,
512
+ ...tokens,
513
+ costUsd: estimateCost(model, tokens),
514
+ cacheSavingsUsd: estimateCacheSavings(model, cached)
515
+ });
516
+ sessions.set(sessionId, { id: sessionId, source: "gemini", date });
517
+ }
518
+ }
519
+ return { records, sessions: [...sessions.values()], files: files.length };
520
+ }
521
+ function parseGeminiConversation(text, fallbackId = "session") {
522
+ const trimmed = text.trim();
523
+ if (trimmed === "")
524
+ return null;
525
+ try {
526
+ const full = asObject(JSON.parse(trimmed));
527
+ if (full != null && Array.isArray(full.messages)) {
528
+ return {
529
+ sessionId: stringValue(full.sessionId) ?? fallbackId,
530
+ startTime: stringValue(full.startTime),
531
+ messages: full.messages.flatMap((message) => {
532
+ const row = asObject(message);
533
+ return row == null ? [] : [row];
534
+ })
535
+ };
536
+ }
537
+ } catch {}
538
+ let sessionId = fallbackId;
539
+ let startTime = null;
540
+ const messages = [];
541
+ for (const line of trimmed.split(`
542
+ `)) {
543
+ let row = null;
544
+ try {
545
+ row = asObject(JSON.parse(line));
546
+ } catch {
547
+ continue;
548
+ }
549
+ if (row == null)
550
+ continue;
551
+ const metadata = asObject(row.$set) ?? row;
552
+ sessionId = stringValue(metadata.sessionId) ?? sessionId;
553
+ startTime = stringValue(metadata.startTime) ?? startTime;
554
+ const rewindTo = stringValue(row.$rewindTo);
555
+ if (rewindTo != null) {
556
+ const index = messages.findIndex((message) => message.id === rewindTo);
557
+ if (index >= 0)
558
+ messages.splice(index + 1);
559
+ } else if (stringValue(row.type) != null) {
560
+ messages.push(row);
561
+ }
562
+ }
563
+ return { sessionId, startTime, messages };
564
+ }
565
+
566
+ // src/data/sources/grok.ts
567
+ import { homedir as homedir5 } from "os";
568
+ import { basename as basename3, dirname as dirname2, join as join5 } from "path";
569
+ async function loadGrokUsage() {
570
+ const root = process.env.GROK_HOME ?? join5(homedir5(), ".grok");
571
+ const files = await globFiles(join5(root, "sessions"), "**/updates.jsonl");
572
+ const records = [];
573
+ const sessions = new Map;
574
+ for (const path of files) {
575
+ const sessionId = `grok:${basename3(dirname2(path))}`;
576
+ await forEachJsonLine(path, (unknownValue) => {
577
+ const value = asObject(unknownValue);
578
+ const update = asObject(value?.update);
579
+ const usage = asObject(update?.usage);
580
+ if (update?.sessionUpdate !== "turn_completed" || usage == null)
581
+ return;
582
+ const metadata = asObject(value?._meta);
583
+ const timestamp = stringValue(value?.timestamp) ?? (numberValue(metadata?.agentTimestampMs) || Date.now());
584
+ const date = localDate(timestamp);
585
+ const models = asObject(usage.modelUsage);
586
+ const entries = models == null ? [["grok-build", usage]] : Object.entries(models);
587
+ const cost = numberValue(usage.costUsdTicks) / 1e9;
588
+ const tokenWeights = entries.map(([, entry]) => {
589
+ const tokens = asObject(entry);
590
+ return numberValue(tokens?.inputTokens) + numberValue(tokens?.outputTokens);
591
+ });
592
+ const totalWeight = tokenWeights.reduce((sum, value2) => sum + value2, 0);
593
+ entries.forEach(([model, entry], index) => {
594
+ const raw = asObject(entry);
595
+ const cached = numberValue(raw?.cachedReadTokens);
596
+ const input = Math.max(0, numberValue(raw?.inputTokens) - cached);
597
+ const tokens = {
598
+ inputTokens: input,
599
+ outputTokens: numberValue(raw?.outputTokens),
600
+ cacheCreationTokens: 0,
601
+ cacheReadTokens: cached
602
+ };
603
+ const allocatedCost = totalWeight > 0 ? cost * (tokenWeights[index] / totalWeight) : 0;
604
+ records.push({
605
+ date,
606
+ source: "grok",
607
+ model,
608
+ sessionId,
609
+ ...tokens,
610
+ costUsd: allocatedCost || estimateCost(model, tokens),
611
+ cacheSavingsUsd: estimateCacheSavings(model, cached)
612
+ });
613
+ });
614
+ sessions.set(sessionId, { id: sessionId, source: "grok", date });
615
+ });
616
+ }
617
+ return { records, sessions: [...sessions.values()], files: files.length };
618
+ }
619
+
620
+ // src/data/sources/opencode.ts
621
+ import { homedir as homedir6 } from "os";
622
+ import { join as join6 } from "path";
623
+ async function loadOpenCodeUsage() {
624
+ const root = process.env.OPENCODE_DATA_DIR ?? join6(homedir6(), ".local", "share", "opencode");
625
+ const files = await globFiles(join6(root, "storage", "message"), "**/*.json");
626
+ const seen = new Set;
627
+ const records = [];
628
+ const sessions = new Map;
629
+ for (const path of files) {
630
+ try {
631
+ const message = asObject(await Bun.file(path).json());
632
+ const id = stringValue(message?.id);
633
+ const model = stringValue(message?.modelID);
634
+ const tokensValue = asObject(message?.tokens);
635
+ if (id == null || model == null || tokensValue == null || seen.has(id))
636
+ continue;
637
+ seen.add(id);
638
+ const cache = asObject(tokensValue.cache);
639
+ const tokens = {
640
+ inputTokens: numberValue(tokensValue.input),
641
+ outputTokens: numberValue(tokensValue.output),
642
+ cacheCreationTokens: numberValue(cache?.write),
643
+ cacheReadTokens: numberValue(cache?.read)
644
+ };
645
+ if (Object.values(tokens).every((value) => value === 0))
646
+ continue;
647
+ const time = asObject(message?.time);
648
+ const date = localDate(numberValue(time?.created) || Date.now());
649
+ const rawSession = stringValue(message?.sessionID) ?? id;
650
+ const sessionId = `opencode:${rawSession}`;
651
+ const localCost = typeof message?.cost === "number" ? message.cost : null;
652
+ records.push({
653
+ date,
654
+ source: "opencode",
655
+ model,
656
+ sessionId,
657
+ ...tokens,
658
+ costUsd: localCost ?? estimateCost(model, tokens),
659
+ cacheSavingsUsd: estimateCacheSavings(model, tokens.cacheReadTokens)
660
+ });
661
+ sessions.set(sessionId, { id: sessionId, source: "opencode", date });
662
+ } catch {
663
+ continue;
664
+ }
665
+ }
666
+ return { records, sessions: [...sessions.values()], files: files.length };
667
+ }
668
+
669
+ // src/data/load.ts
670
+ var SOURCES = [
671
+ { id: "claude", load: loadClaudeUsage },
672
+ { id: "codex", load: loadCodexUsage },
673
+ { id: "cursor", load: loadCursorUsage },
674
+ { id: "gemini", load: loadGeminiUsage },
675
+ { id: "opencode", load: loadOpenCodeUsage },
676
+ { id: "grok", load: loadGrokUsage }
677
+ ];
678
+ async function loadUsageDataset(onProgress) {
679
+ let completed = 0;
680
+ const results = await Promise.all(SOURCES.map(async (source) => {
681
+ const common = { source: source.id, label: SOURCE_META[source.id].label, total: SOURCES.length };
682
+ onProgress?.({ ...common, status: "scanning", completed });
683
+ try {
684
+ const value = await source.load();
685
+ completed += 1;
686
+ onProgress?.({
687
+ ...common,
688
+ status: "done",
689
+ completed,
690
+ files: value.files,
691
+ records: value.records.length,
692
+ sessions: value.sessions.length
693
+ });
694
+ return { value, error: null };
695
+ } catch (cause) {
696
+ completed += 1;
697
+ const error = cause instanceof Error ? cause.message : String(cause);
698
+ onProgress?.({ ...common, status: "error", completed, error });
699
+ return { value: null, error };
700
+ }
701
+ }));
702
+ const records = results.flatMap((result) => result.value?.records ?? []);
703
+ const sessions = results.flatMap((result) => result.value?.sessions ?? []);
704
+ const errors = results.flatMap((result) => result.error == null ? [] : [result.error]);
705
+ return { records, sessions, errors, scannedAt: new Date };
706
+ }
707
+
708
+ // src/tui/components/Segmented.tsx
709
+ import { useState as useState2 } from "react";
710
+
711
+ // src/tui/ThemeContext.tsx
712
+ import { createContext, useContext } from "react";
713
+
714
+ // src/tui/theme.ts
715
+ import { useEffect, useState } from "react";
716
+ import { RGBA } from "@opentui/core";
717
+ var dark = createTheme("dark");
718
+ var light = createTheme("light");
719
+ function terminalTheme(mode) {
720
+ return mode === "light" ? light : dark;
721
+ }
722
+ function useTerminalTheme(renderer) {
723
+ const [mode, setMode] = useState(renderer.themeMode ?? colorFgBgMode() ?? "dark");
724
+ useEffect(() => {
725
+ const update = (next) => setMode(next);
726
+ const updatePalette = (colors) => {
727
+ const next = backgroundMode(colors.defaultBackground);
728
+ if (next != null)
729
+ setMode(next);
730
+ };
731
+ renderer.on("theme_mode", update);
732
+ renderer.on("palette", updatePalette);
733
+ renderer.waitForThemeMode(300).then((next) => {
734
+ if (next != null)
735
+ setMode(next);
736
+ });
737
+ renderer.getPalette({ size: 16, timeout: 300 }).then(updatePalette).catch(() => {});
738
+ return () => {
739
+ renderer.off("theme_mode", update);
740
+ renderer.off("palette", updatePalette);
741
+ };
742
+ }, [renderer]);
743
+ return terminalTheme(mode);
744
+ }
745
+ function colorFgBgMode() {
746
+ const index = Number(process.env.COLORFGBG?.split(";").at(-1));
747
+ if (!Number.isInteger(index))
748
+ return null;
749
+ return index === 7 || index > 8 ? "light" : "dark";
750
+ }
751
+ function backgroundMode(background) {
752
+ if (background == null)
753
+ return null;
754
+ const hex = background.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
755
+ if (hex == null)
756
+ return null;
757
+ const red = Number.parseInt(hex[1], 16);
758
+ const green = Number.parseInt(hex[2], 16);
759
+ const blue = Number.parseInt(hex[3], 16);
760
+ const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
761
+ return luminance >= 0.55 ? "light" : "dark";
762
+ }
763
+ function createTheme(mode) {
764
+ const isLight = mode === "light";
765
+ const textSnapshot = isLight ? "#202020" : "#ededed";
766
+ const backgroundSnapshot = isLight ? "#fafafa" : "#1e1e1e";
767
+ return {
768
+ mode,
769
+ bg: RGBA.defaultBackground(backgroundSnapshot),
770
+ panel: RGBA.defaultBackground(backgroundSnapshot),
771
+ hover: RGBA.fromHex(isLight ? "#e4e4e4" : "#303030"),
772
+ selected: RGBA.fromHex(isLight ? "#d8d8d8" : "#3a3a3a"),
773
+ text: RGBA.defaultForeground(textSnapshot),
774
+ onHover: RGBA.defaultForeground(textSnapshot),
775
+ onSelected: RGBA.defaultForeground(textSnapshot),
776
+ muted: RGBA.fromHex(isLight ? "#666666" : "#858585"),
777
+ faint: RGBA.fromHex(isLight ? "#c8c8c8" : "#424242"),
778
+ accent: RGBA.fromHex(isLight ? "#8a5a00" : "#d7a94a"),
779
+ error: RGBA.fromHex(isLight ? "#a52a2a" : "#dc6b6b"),
780
+ progress: RGBA.fromHex(isLight ? "#1f9d68" : "#35c98b"),
781
+ onProgress: RGBA.fromHex(isLight ? "#ffffff" : "#111111"),
782
+ sources: {
783
+ claude: RGBA.fromHex(isLight ? "#a84324" : "#dc7957"),
784
+ codex: RGBA.defaultForeground(textSnapshot),
785
+ cursor: RGBA.fromHex(isLight ? "#713b91" : "#c58be2"),
786
+ gemini: RGBA.fromHex(isLight ? "#245fa8" : "#77a7e8"),
787
+ grok: RGBA.fromHex(isLight ? "#5e5e5e" : "#a5a5a5"),
788
+ opencode: RGBA.fromHex(isLight ? "#287a73" : "#72a7a0")
789
+ }
790
+ };
791
+ }
792
+
793
+ // src/tui/ThemeContext.tsx
794
+ import { jsx } from "@opentui/react/jsx-runtime";
795
+ var ThemeContext = createContext(terminalTheme("dark"));
796
+ function ThemeProvider({ theme, children }) {
797
+ return /* @__PURE__ */ jsx(ThemeContext.Provider, {
798
+ value: theme,
799
+ children
800
+ });
801
+ }
802
+ function useTheme() {
803
+ return useContext(ThemeContext);
804
+ }
805
+
806
+ // src/tui/usePointer.ts
807
+ import { useRenderer } from "@opentui/react";
808
+ function usePointer(style = "pointer") {
809
+ const renderer = useRenderer();
810
+ return {
811
+ pointerOver: () => renderer.setMousePointer(style),
812
+ pointerOut: () => renderer.setMousePointer("default")
813
+ };
814
+ }
815
+
816
+ // src/tui/components/Segmented.tsx
817
+ import { jsx as jsx2 } from "@opentui/react/jsx-runtime";
818
+ function Segmented({
819
+ options,
820
+ selected,
821
+ onChange
822
+ }) {
823
+ const theme = useTheme();
824
+ const [hovered, setHovered] = useState2(null);
825
+ const { pointerOver, pointerOut } = usePointer();
826
+ const mousePress = (value) => (event) => {
827
+ if (event.button !== 0)
828
+ return;
829
+ event.stopPropagation();
830
+ onChange(value);
831
+ };
832
+ const keyPress = (value) => (key) => {
833
+ if (key.name === "return" || key.name === "space")
834
+ onChange(value);
835
+ };
836
+ return /* @__PURE__ */ jsx2("box", {
837
+ flexDirection: "row",
838
+ backgroundColor: theme.panel,
839
+ children: options.map((option) => {
840
+ const active = option.value === selected;
841
+ const hot = option.value === hovered;
842
+ return /* @__PURE__ */ jsx2("box", {
843
+ focusable: true,
844
+ height: 1,
845
+ backgroundColor: active ? theme.selected : hot ? theme.hover : theme.panel,
846
+ onMouseDown: mousePress(option.value),
847
+ onMouseOver: () => {
848
+ setHovered(option.value);
849
+ pointerOver();
850
+ },
851
+ onMouseOut: () => {
852
+ setHovered(null);
853
+ pointerOut();
854
+ },
855
+ onKeyDown: keyPress(option.value),
856
+ children: /* @__PURE__ */ jsx2("text", {
857
+ selectable: false,
858
+ fg: active ? theme.onSelected : hot ? theme.onHover : theme.muted,
859
+ children: ` ${option.label} `
860
+ })
861
+ }, String(option.value));
862
+ })
863
+ });
864
+ }
865
+
866
+ // src/tui/components/PointerButton.tsx
867
+ import { useState as useState3 } from "react";
868
+ import { jsx as jsx3 } from "@opentui/react/jsx-runtime";
869
+ function PointerButton({ label, onPress }) {
870
+ const theme = useTheme();
871
+ const [hovered, setHovered] = useState3(false);
872
+ const { pointerOver, pointerOut } = usePointer();
873
+ const press = (event) => {
874
+ if (event.button !== 0)
875
+ return;
876
+ event.stopPropagation();
877
+ onPress();
878
+ };
879
+ const keyPress = (key) => {
880
+ if (key.name === "return" || key.name === "space")
881
+ onPress();
882
+ };
883
+ return /* @__PURE__ */ jsx3("box", {
884
+ focusable: true,
885
+ height: 1,
886
+ backgroundColor: hovered ? theme.hover : theme.panel,
887
+ onMouseDown: press,
888
+ onMouseOver: () => {
889
+ setHovered(true);
890
+ pointerOver();
891
+ },
892
+ onMouseOut: () => {
893
+ setHovered(false);
894
+ pointerOut();
895
+ },
896
+ onKeyDown: keyPress,
897
+ children: /* @__PURE__ */ jsx3("text", {
898
+ selectable: false,
899
+ fg: hovered ? theme.onHover : theme.muted,
900
+ children: ` ${label} `
901
+ })
902
+ });
903
+ }
904
+
905
+ // src/tui/components/Header.tsx
906
+ import { jsx as jsx4, jsxs } from "@opentui/react/jsx-runtime";
907
+ function Header({
908
+ metric,
909
+ range,
910
+ days,
911
+ compact,
912
+ onMetricChange,
913
+ onRangeChange,
914
+ onRefresh
915
+ }) {
916
+ const theme = useTheme();
917
+ const dateLabel = days.length === 0 ? "" : `${shortDate(days[0])} to ${shortDate(days.at(-1))}`;
918
+ return /* @__PURE__ */ jsxs("box", {
919
+ flexDirection: compact ? "column" : "row",
920
+ justifyContent: "space-between",
921
+ width: "100%",
922
+ gap: 1,
923
+ children: [
924
+ /* @__PURE__ */ jsxs("text", {
925
+ fg: theme.text,
926
+ children: [
927
+ /* @__PURE__ */ jsx4("strong", {
928
+ children: "yacu"
929
+ }),
930
+ /* @__PURE__ */ jsx4("span", {
931
+ fg: theme.muted,
932
+ children: ` / ${dateLabel}`
933
+ })
934
+ ]
935
+ }),
936
+ /* @__PURE__ */ jsxs("box", {
937
+ flexDirection: "row",
938
+ gap: 2,
939
+ children: [
940
+ /* @__PURE__ */ jsx4(Segmented, {
941
+ selected: metric,
942
+ onChange: onMetricChange,
943
+ options: [{ value: "cost", label: "Cost" }, { value: "tokens", label: "Tokens" }]
944
+ }),
945
+ /* @__PURE__ */ jsx4(Segmented, {
946
+ selected: range,
947
+ onChange: onRangeChange,
948
+ options: [
949
+ { value: 1, label: "Past 24h" },
950
+ { value: 7, label: "7 days" },
951
+ { value: 30, label: "30 days" },
952
+ { value: 90, label: "90 days" }
953
+ ]
954
+ }),
955
+ /* @__PURE__ */ jsx4(PointerButton, {
956
+ label: "r \u21BB",
957
+ onPress: onRefresh
958
+ })
959
+ ]
960
+ })
961
+ ]
962
+ });
963
+ }
964
+
965
+ // src/tui/components/Summary.tsx
966
+ import { useState as useState4 } from "react";
967
+
968
+ // src/tui/format.ts
969
+ function formatMoney(value) {
970
+ return new Intl.NumberFormat("en-US", {
971
+ style: "currency",
972
+ currency: "USD",
973
+ minimumFractionDigits: 2,
974
+ maximumFractionDigits: 2
975
+ }).format(value);
976
+ }
977
+ function formatCompact(value) {
978
+ if (value < 1000)
979
+ return Math.round(value).toLocaleString("en-US");
980
+ const units = [
981
+ [1e9, "B"],
982
+ [1e6, "M"],
983
+ [1000, "K"]
984
+ ];
985
+ const [divisor, suffix] = units.find(([divisor2]) => value >= divisor2) ?? units[2];
986
+ const scaled = value / divisor;
987
+ return `${scaled >= 100 ? scaled.toFixed(0) : scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)}${suffix}`;
988
+ }
989
+ function formatMetric(value, metric) {
990
+ return metric === "cost" ? formatMoney(value) : formatCompact(value);
991
+ }
992
+ function formatAxis(value, metric) {
993
+ if (metric === "tokens")
994
+ return formatCompact(value);
995
+ if (value >= 1000)
996
+ return `$${formatCompact(value)}`;
997
+ return `$${Math.round(value).toLocaleString("en-US")}`;
998
+ }
999
+ function percent(value) {
1000
+ return `${(value * 100).toFixed(1)}%`;
1001
+ }
1002
+ function truncate(value, width) {
1003
+ if (value.length <= width)
1004
+ return value.padEnd(width);
1005
+ if (width <= 1)
1006
+ return value.slice(0, width);
1007
+ return `${value.slice(0, width - 1)}\u2026`;
1008
+ }
1009
+
1010
+ // src/tui/components/Summary.tsx
1011
+ import { jsx as jsx5, jsxs as jsxs2 } from "@opentui/react/jsx-runtime";
1012
+ function Summary({
1013
+ dashboard,
1014
+ metric,
1015
+ visibleSources,
1016
+ onToggleSource
1017
+ }) {
1018
+ const theme = useTheme();
1019
+ const [hovered, setHovered] = useState4(null);
1020
+ const { pointerOver, pointerOut } = usePointer();
1021
+ const total = metric === "cost" ? formatMoney(dashboard.totals.costUsd) : formatCompact(dashboard.totals.processedTokens);
1022
+ return /* @__PURE__ */ jsxs2("box", {
1023
+ flexDirection: "column",
1024
+ width: "100%",
1025
+ gap: 1,
1026
+ children: [
1027
+ /* @__PURE__ */ jsx5("text", {
1028
+ fg: theme.text,
1029
+ children: /* @__PURE__ */ jsx5("strong", {
1030
+ children: total
1031
+ })
1032
+ }),
1033
+ /* @__PURE__ */ jsx5("text", {
1034
+ fg: theme.muted,
1035
+ children: `${dashboard.sessions.toLocaleString("en-US")} sessions \xB7 API estimate`
1036
+ }),
1037
+ /* @__PURE__ */ jsx5("box", {
1038
+ height: 1
1039
+ }),
1040
+ dashboard.providers.map((provider) => {
1041
+ const meta = SOURCE_META[provider.source];
1042
+ const value = metric === "cost" ? provider.costUsd : provider.processedTokens;
1043
+ const visible = visibleSources.has(provider.source);
1044
+ const hot = hovered === provider.source;
1045
+ const toggle = (event) => {
1046
+ if (event.button !== 0)
1047
+ return;
1048
+ event.stopPropagation();
1049
+ onToggleSource(provider.source);
1050
+ };
1051
+ return /* @__PURE__ */ jsxs2("box", {
1052
+ flexDirection: "column",
1053
+ marginBottom: 1,
1054
+ backgroundColor: hot ? theme.hover : theme.bg,
1055
+ opacity: visible ? 1 : 0.45,
1056
+ onMouseDown: toggle,
1057
+ onMouseOver: () => {
1058
+ setHovered(provider.source);
1059
+ pointerOver();
1060
+ },
1061
+ onMouseOut: () => {
1062
+ setHovered(null);
1063
+ pointerOut();
1064
+ },
1065
+ children: [
1066
+ /* @__PURE__ */ jsxs2("box", {
1067
+ flexDirection: "row",
1068
+ justifyContent: "space-between",
1069
+ children: [
1070
+ /* @__PURE__ */ jsxs2("text", {
1071
+ selectable: false,
1072
+ fg: hot ? theme.onHover : theme.sources[provider.source],
1073
+ children: [
1074
+ `${meta.mark} `,
1075
+ /* @__PURE__ */ jsx5("span", {
1076
+ fg: hot ? theme.onHover : theme.text,
1077
+ children: meta.label
1078
+ }),
1079
+ /* @__PURE__ */ jsx5("span", {
1080
+ fg: hot ? theme.onHover : theme.muted,
1081
+ children: ` ${provider.sessions} sessions`
1082
+ })
1083
+ ]
1084
+ }),
1085
+ /* @__PURE__ */ jsx5("text", {
1086
+ selectable: false,
1087
+ fg: hot ? theme.onHover : theme.text,
1088
+ children: formatMetric(value, metric)
1089
+ })
1090
+ ]
1091
+ }),
1092
+ /* @__PURE__ */ jsx5("text", {
1093
+ selectable: false,
1094
+ fg: hot ? theme.onHover : theme.muted,
1095
+ children: `${percent(provider.share)} of ${metric} \xB7 ${formatCompact(provider.processedTokens)} tokens`
1096
+ })
1097
+ ]
1098
+ }, provider.source);
1099
+ })
1100
+ ]
1101
+ });
1102
+ }
1103
+
1104
+ // src/tui/components/Chart.tsx
1105
+ import { useMemo, useState as useState5 } from "react";
1106
+
1107
+ // src/tui/chart.ts
1108
+ var DOTS = [
1109
+ [1, 8],
1110
+ [2, 16],
1111
+ [4, 32],
1112
+ [64, 128]
1113
+ ];
1114
+ function renderBrailleChart(series, width, height, highlightColumn, highlightColor, highlightForeground, palette = terminalTheme("dark")) {
1115
+ const safeWidth = Math.max(4, width);
1116
+ const safeHeight = Math.max(3, height);
1117
+ const pixelWidth = safeWidth * 2;
1118
+ const pixelHeight = safeHeight * 4;
1119
+ const cells = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => ({ bits: 0, source: null })));
1120
+ const entries = Object.entries(series).filter(([, values]) => values.some((value) => value > 0)).sort(([, a], [, b]) => sum(a) - sum(b));
1121
+ const max = Math.max(0, ...entries.flatMap(([, values]) => values));
1122
+ if (max > 0) {
1123
+ for (const [source, values] of entries) {
1124
+ const points = values.map((value, index) => ({
1125
+ x: values.length <= 1 ? 0 : Math.round(index / (values.length - 1) * (pixelWidth - 1)),
1126
+ y: Math.round((1 - value / max) * (pixelHeight - 1))
1127
+ }));
1128
+ if (points.length === 1)
1129
+ plot(cells, points[0].x, points[0].y, source);
1130
+ for (let index = 1;index < points.length; index++) {
1131
+ drawLine(cells, points[index - 1], points[index], source);
1132
+ }
1133
+ }
1134
+ }
1135
+ return {
1136
+ max,
1137
+ rows: cells.map((row) => groupSegments(row, highlightColumn, highlightColor, highlightForeground, palette))
1138
+ };
1139
+ }
1140
+ function sum(values) {
1141
+ return values.reduce((total, value) => total + value, 0);
1142
+ }
1143
+ function drawLine(cells, from, to, source) {
1144
+ let x = from.x;
1145
+ let y = from.y;
1146
+ const dx = Math.abs(to.x - from.x);
1147
+ const sx = from.x < to.x ? 1 : -1;
1148
+ const dy = -Math.abs(to.y - from.y);
1149
+ const sy = from.y < to.y ? 1 : -1;
1150
+ let error = dx + dy;
1151
+ while (true) {
1152
+ plot(cells, x, y, source);
1153
+ if (x === to.x && y === to.y)
1154
+ return;
1155
+ const doubled = error * 2;
1156
+ if (doubled >= dy) {
1157
+ error += dy;
1158
+ x += sx;
1159
+ }
1160
+ if (doubled <= dx) {
1161
+ error += dx;
1162
+ y += sy;
1163
+ }
1164
+ }
1165
+ }
1166
+ function plot(cells, pixelX, pixelY, source) {
1167
+ const row = Math.floor(pixelY / 4);
1168
+ const column = Math.floor(pixelX / 2);
1169
+ const cell = cells[row]?.[column];
1170
+ if (cell == null)
1171
+ return;
1172
+ cell.bits |= DOTS[pixelY % 4][pixelX % 2];
1173
+ cell.source = source;
1174
+ }
1175
+ function groupSegments(cells, highlightColumn, highlightColor, highlightForeground, palette = terminalTheme("dark")) {
1176
+ const segments = [];
1177
+ for (const [column, cell] of cells.entries()) {
1178
+ const text = cell.bits === 0 ? " " : String.fromCodePoint(10240 + cell.bits);
1179
+ const color = column === highlightColumn && highlightForeground != null ? highlightForeground : cell.source == null ? palette.faint : palette.sources[cell.source];
1180
+ const background = column === highlightColumn ? highlightColor : undefined;
1181
+ const previous = segments.at(-1);
1182
+ if (previous?.color === color && previous.background === background)
1183
+ previous.text += text;
1184
+ else
1185
+ segments.push({ text, color, background });
1186
+ }
1187
+ return segments;
1188
+ }
1189
+ function dayIndexAtColumn(column, width, dayCount) {
1190
+ if (dayCount <= 1 || width <= 1)
1191
+ return 0;
1192
+ const bounded = Math.max(0, Math.min(width - 1, column));
1193
+ return Math.round(bounded / (width - 1) * (dayCount - 1));
1194
+ }
1195
+ function chartColumnForDay(index, width, dayCount) {
1196
+ if (dayCount <= 1 || width <= 1)
1197
+ return 0;
1198
+ const bounded = Math.max(0, Math.min(dayCount - 1, index));
1199
+ return Math.round(bounded / (dayCount - 1) * (width - 1));
1200
+ }
1201
+
1202
+ // src/tui/components/Chart.tsx
1203
+ import { jsx as jsx6, jsxs as jsxs3 } from "@opentui/react/jsx-runtime";
1204
+ function Chart({
1205
+ dashboard,
1206
+ metric,
1207
+ width,
1208
+ visibleSources,
1209
+ selectedDay,
1210
+ onSelectDay,
1211
+ height = 11
1212
+ }) {
1213
+ const theme = useTheme();
1214
+ const labelWidth = 9;
1215
+ const graphWidth = Math.max(8, width - labelWidth - 1);
1216
+ const [hoveredIndex, setHoveredIndex] = useState5(null);
1217
+ const { pointerOver, pointerOut } = usePointer("crosshair");
1218
+ const selectedIndex = selectedDay == null ? -1 : dashboard.days.indexOf(selectedDay);
1219
+ const activeIndex = hoveredIndex ?? (selectedIndex >= 0 ? selectedIndex : null);
1220
+ const highlightColumn = activeIndex == null ? undefined : chartColumnForDay(activeIndex, graphWidth, dashboard.days.length);
1221
+ const visibleSeries = useMemo(() => Object.fromEntries(SOURCE_ORDER.map((source) => [
1222
+ source,
1223
+ visibleSources.has(source) ? dashboard.series[source] : dashboard.series[source].map(() => 0)
1224
+ ])), [dashboard.series, visibleSources]);
1225
+ const chart = renderBrailleChart(visibleSeries, graphWidth, height, highlightColumn, theme.selected, theme.onSelected, theme);
1226
+ const indexFromEvent = (event) => {
1227
+ const start = event.currentTarget?.screenX ?? event.x;
1228
+ return dayIndexAtColumn(event.x - start, graphWidth, dashboard.days.length);
1229
+ };
1230
+ const move = (event) => setHoveredIndex(indexFromEvent(event));
1231
+ const select = (event) => {
1232
+ if (event.button !== 0)
1233
+ return;
1234
+ event.stopPropagation();
1235
+ const index = indexFromEvent(event);
1236
+ const day = dashboard.days[index] ?? null;
1237
+ onSelectDay(day === selectedDay ? null : day);
1238
+ };
1239
+ return /* @__PURE__ */ jsxs3("box", {
1240
+ flexDirection: "column",
1241
+ width: "100%",
1242
+ children: [
1243
+ /* @__PURE__ */ jsx6("text", {
1244
+ fg: theme.text,
1245
+ children: `Daily ${metric}`
1246
+ }),
1247
+ /* @__PURE__ */ jsx6("box", {
1248
+ height: 1,
1249
+ children: activeIndex == null ? null : /* @__PURE__ */ jsx6(ChartPoint, {
1250
+ dashboard,
1251
+ metric,
1252
+ index: activeIndex,
1253
+ visibleSources,
1254
+ pinned: hoveredIndex == null && selectedIndex === activeIndex
1255
+ })
1256
+ }),
1257
+ /* @__PURE__ */ jsxs3("box", {
1258
+ flexDirection: "row",
1259
+ height,
1260
+ children: [
1261
+ /* @__PURE__ */ jsx6("box", {
1262
+ flexDirection: "column",
1263
+ width: labelWidth,
1264
+ children: chart.rows.map((_, index) => {
1265
+ const ratio = 1 - index / Math.max(1, chart.rows.length - 1);
1266
+ const show = index === 0 || index === Math.floor(chart.rows.length / 2) || index === chart.rows.length - 1;
1267
+ const label = show ? formatAxis(chart.max * ratio, metric) : "";
1268
+ return /* @__PURE__ */ jsx6("text", {
1269
+ fg: theme.muted,
1270
+ height: 1,
1271
+ children: label.padStart(labelWidth - 1)
1272
+ }, index);
1273
+ })
1274
+ }),
1275
+ /* @__PURE__ */ jsx6("box", {
1276
+ focusable: true,
1277
+ flexDirection: "column",
1278
+ width: graphWidth,
1279
+ height,
1280
+ onMouseMove: move,
1281
+ onMouseDown: select,
1282
+ onMouseOver: pointerOver,
1283
+ onMouseOut: () => {
1284
+ setHoveredIndex(null);
1285
+ pointerOut();
1286
+ },
1287
+ children: chart.rows.map((segments, index) => /* @__PURE__ */ jsx6("text", {
1288
+ height: 1,
1289
+ selectable: false,
1290
+ children: segments.map((segment, segmentIndex) => /* @__PURE__ */ jsx6("span", {
1291
+ fg: segment.color,
1292
+ bg: segment.background,
1293
+ children: segment.text
1294
+ }, segmentIndex))
1295
+ }, index))
1296
+ })
1297
+ ]
1298
+ }),
1299
+ /* @__PURE__ */ jsxs3("box", {
1300
+ flexDirection: "row",
1301
+ marginLeft: labelWidth,
1302
+ justifyContent: "space-between",
1303
+ width: graphWidth,
1304
+ children: [
1305
+ /* @__PURE__ */ jsx6("text", {
1306
+ fg: theme.muted,
1307
+ children: shortDate(dashboard.days[0] ?? "")
1308
+ }),
1309
+ /* @__PURE__ */ jsx6("text", {
1310
+ fg: theme.muted,
1311
+ children: shortDate(dashboard.days[Math.floor(dashboard.days.length / 2)] ?? "")
1312
+ }),
1313
+ /* @__PURE__ */ jsx6("text", {
1314
+ fg: theme.muted,
1315
+ children: shortDate(dashboard.days.at(-1) ?? "")
1316
+ })
1317
+ ]
1318
+ })
1319
+ ]
1320
+ });
1321
+ }
1322
+ function ChartPoint({
1323
+ dashboard,
1324
+ metric,
1325
+ index,
1326
+ visibleSources,
1327
+ pinned
1328
+ }) {
1329
+ const theme = useTheme();
1330
+ const values = SOURCE_ORDER.filter((source) => visibleSources.has(source)).map((source) => ({ source, value: dashboard.series[source][index] ?? 0 })).filter(({ value }) => value > 0);
1331
+ const total = values.reduce((sum2, { value }) => sum2 + value, 0);
1332
+ return /* @__PURE__ */ jsxs3("text", {
1333
+ height: 1,
1334
+ children: [
1335
+ /* @__PURE__ */ jsx6("span", {
1336
+ fg: theme.text,
1337
+ children: shortDate(dashboard.days[index] ?? "")
1338
+ }),
1339
+ /* @__PURE__ */ jsx6("span", {
1340
+ fg: theme.muted,
1341
+ children: ` ${formatMetric(total, metric)}`
1342
+ }),
1343
+ values.map(({ source, value }) => /* @__PURE__ */ jsx6("span", {
1344
+ fg: theme.sources[source],
1345
+ children: ` ${SOURCE_META[source].mark} ${formatMetric(value, metric)}`
1346
+ }, source)),
1347
+ pinned ? /* @__PURE__ */ jsx6("span", {
1348
+ fg: theme.muted,
1349
+ children: " pinned"
1350
+ }) : null
1351
+ ]
1352
+ });
1353
+ }
1354
+
1355
+ // src/tui/components/Totals.tsx
1356
+ import { jsx as jsx7, jsxs as jsxs4 } from "@opentui/react/jsx-runtime";
1357
+ function Totals({ dashboard, compact }) {
1358
+ const theme = useTheme();
1359
+ const values = [
1360
+ ["Processed tokens", formatCompact(dashboard.totals.processedTokens)],
1361
+ ["Cached input", formatCompact(dashboard.totals.cacheReadTokens)],
1362
+ ["Uncached input", formatCompact(dashboard.totals.inputTokens + dashboard.totals.cacheCreationTokens)],
1363
+ ["Output", formatCompact(dashboard.totals.outputTokens)],
1364
+ ["Cache savings", formatMoney(dashboard.totals.cacheSavingsUsd)]
1365
+ ];
1366
+ return /* @__PURE__ */ jsxs4("box", {
1367
+ flexDirection: "column",
1368
+ width: "100%",
1369
+ gap: 1,
1370
+ children: [
1371
+ /* @__PURE__ */ jsx7("text", {
1372
+ fg: theme.text,
1373
+ children: /* @__PURE__ */ jsx7("strong", {
1374
+ children: "Totals"
1375
+ })
1376
+ }),
1377
+ /* @__PURE__ */ jsx7("box", {
1378
+ flexDirection: compact ? "column" : "row",
1379
+ justifyContent: "space-between",
1380
+ gap: compact ? 0 : 2,
1381
+ children: values.map(([label, value]) => /* @__PURE__ */ jsxs4("box", {
1382
+ flexDirection: compact ? "row" : "column",
1383
+ justifyContent: "space-between",
1384
+ flexGrow: 1,
1385
+ children: [
1386
+ /* @__PURE__ */ jsx7("text", {
1387
+ fg: theme.muted,
1388
+ children: label
1389
+ }),
1390
+ /* @__PURE__ */ jsx7("text", {
1391
+ fg: theme.text,
1392
+ children: value
1393
+ })
1394
+ ]
1395
+ }, label))
1396
+ })
1397
+ ]
1398
+ });
1399
+ }
1400
+
1401
+ // src/tui/components/Breakdown.tsx
1402
+ import { useState as useState6 } from "react";
1403
+ import { jsx as jsx8, jsxs as jsxs5 } from "@opentui/react/jsx-runtime";
1404
+ function Breakdown({
1405
+ dashboard,
1406
+ mode,
1407
+ metric,
1408
+ width,
1409
+ selectedDay,
1410
+ onModeChange,
1411
+ onSelectDay
1412
+ }) {
1413
+ const theme = useTheme();
1414
+ const [hovered, setHovered] = useState6(null);
1415
+ const { pointerOver, pointerOut } = usePointer();
1416
+ const rows = mode === "model" ? dashboard.models : dashboard.daily;
1417
+ const nameWidth = Math.max(18, width - 42);
1418
+ return /* @__PURE__ */ jsxs5("box", {
1419
+ flexDirection: "column",
1420
+ width: "100%",
1421
+ gap: 1,
1422
+ children: [
1423
+ /* @__PURE__ */ jsxs5("box", {
1424
+ flexDirection: "row",
1425
+ justifyContent: "space-between",
1426
+ children: [
1427
+ /* @__PURE__ */ jsx8("text", {
1428
+ fg: theme.text,
1429
+ children: /* @__PURE__ */ jsx8("strong", {
1430
+ children: "Breakdown"
1431
+ })
1432
+ }),
1433
+ /* @__PURE__ */ jsx8(Segmented, {
1434
+ selected: mode,
1435
+ onChange: onModeChange,
1436
+ options: [{ value: "model", label: "Model" }, { value: "day", label: "Day" }]
1437
+ })
1438
+ ]
1439
+ }),
1440
+ /* @__PURE__ */ jsxs5("box", {
1441
+ flexDirection: "row",
1442
+ children: [
1443
+ /* @__PURE__ */ jsx8("text", {
1444
+ fg: theme.muted,
1445
+ width: nameWidth,
1446
+ children: mode === "model" ? "Model" : "Day"
1447
+ }),
1448
+ /* @__PURE__ */ jsx8("text", {
1449
+ fg: theme.muted,
1450
+ width: 15,
1451
+ children: metric === "cost" ? "Cost" : "Tokens"
1452
+ }),
1453
+ /* @__PURE__ */ jsx8("text", {
1454
+ fg: theme.muted,
1455
+ width: 12,
1456
+ children: "Share"
1457
+ }),
1458
+ /* @__PURE__ */ jsx8("text", {
1459
+ fg: theme.muted,
1460
+ children: "Tokens"
1461
+ })
1462
+ ]
1463
+ }),
1464
+ rows.slice(0, 18).map((row) => {
1465
+ const value = metric === "cost" ? row.costUsd : row.processedTokens;
1466
+ const color = row.source == null ? theme.text : theme.sources[row.source];
1467
+ const mark = row.source == null ? " " : SOURCE_META[row.source].mark;
1468
+ const interactive = mode === "day";
1469
+ const selected = interactive && selectedDay === row.key;
1470
+ const hot = interactive && hovered === row.key;
1471
+ const select = interactive ? (event) => {
1472
+ if (event.button !== 0)
1473
+ return;
1474
+ event.stopPropagation();
1475
+ onSelectDay(selected ? null : row.key);
1476
+ } : undefined;
1477
+ return /* @__PURE__ */ jsxs5("box", {
1478
+ flexDirection: "row",
1479
+ backgroundColor: selected ? theme.selected : hot ? theme.hover : theme.bg,
1480
+ onMouseDown: select,
1481
+ onMouseOver: interactive ? () => {
1482
+ setHovered(row.key);
1483
+ pointerOver();
1484
+ } : undefined,
1485
+ onMouseOut: interactive ? () => {
1486
+ setHovered(null);
1487
+ pointerOut();
1488
+ } : undefined,
1489
+ children: [
1490
+ /* @__PURE__ */ jsx8("text", {
1491
+ selectable: !interactive,
1492
+ fg: selected ? theme.onSelected : hot ? theme.onHover : color,
1493
+ width: nameWidth,
1494
+ children: `${mark} ${truncate(row.label, nameWidth - 2)}`
1495
+ }),
1496
+ /* @__PURE__ */ jsx8("text", {
1497
+ selectable: !interactive,
1498
+ fg: selected ? theme.onSelected : hot ? theme.onHover : theme.text,
1499
+ width: 15,
1500
+ children: formatMetric(value, metric)
1501
+ }),
1502
+ /* @__PURE__ */ jsx8("text", {
1503
+ selectable: !interactive,
1504
+ fg: selected ? theme.onSelected : hot ? theme.onHover : theme.muted,
1505
+ width: 12,
1506
+ children: percent(row.share)
1507
+ }),
1508
+ /* @__PURE__ */ jsx8("text", {
1509
+ selectable: !interactive,
1510
+ fg: selected ? theme.onSelected : hot ? theme.onHover : theme.muted,
1511
+ children: formatMetric(row.processedTokens, "tokens")
1512
+ })
1513
+ ]
1514
+ }, row.key);
1515
+ })
1516
+ ]
1517
+ });
1518
+ }
1519
+
1520
+ // src/tui/components/Footer.tsx
1521
+ import { jsx as jsx9, jsxs as jsxs6 } from "@opentui/react/jsx-runtime";
1522
+ function Footer({ loading }) {
1523
+ const theme = useTheme();
1524
+ return /* @__PURE__ */ jsxs6("box", {
1525
+ width: "100%",
1526
+ flexDirection: "row",
1527
+ justifyContent: "space-between",
1528
+ backgroundColor: theme.panel,
1529
+ paddingX: 1,
1530
+ children: [
1531
+ /* @__PURE__ */ jsx9("text", {
1532
+ fg: theme.muted,
1533
+ children: "c cost/tokens 1-4 range b breakdown r refresh q quit"
1534
+ }),
1535
+ /* @__PURE__ */ jsx9("text", {
1536
+ fg: loading ? theme.accent : theme.muted,
1537
+ children: loading ? "loading" : "local"
1538
+ })
1539
+ ]
1540
+ });
1541
+ }
1542
+
1543
+ // src/tui/components/ScanBoot.tsx
1544
+ import { jsx as jsx10, jsxs as jsxs7 } from "@opentui/react/jsx-runtime";
1545
+ function ScanBoot({
1546
+ completed,
1547
+ total,
1548
+ width
1549
+ }) {
1550
+ const theme = useTheme();
1551
+ const progress = total === 0 ? 1 : Math.max(0, Math.min(1, completed / total));
1552
+ const barWidth = Math.max(8, Math.min(20, width - 4));
1553
+ const filled = Math.round(progress * barWidth);
1554
+ return /* @__PURE__ */ jsx10("box", {
1555
+ width: "100%",
1556
+ flexGrow: 1,
1557
+ justifyContent: "center",
1558
+ alignItems: "center",
1559
+ children: /* @__PURE__ */ jsxs7("box", {
1560
+ flexDirection: "column",
1561
+ alignItems: "center",
1562
+ gap: 1,
1563
+ children: [
1564
+ /* @__PURE__ */ jsx10("text", {
1565
+ fg: theme.text,
1566
+ children: "scanning usage..."
1567
+ }),
1568
+ /* @__PURE__ */ jsx10("text", {
1569
+ fg: theme.text,
1570
+ children: `[${"#".repeat(filled)}${" ".repeat(barWidth - filled)}]`
1571
+ })
1572
+ ]
1573
+ })
1574
+ });
1575
+ }
1576
+
1577
+ // src/tui/App.tsx
1578
+ import { jsx as jsx11, jsxs as jsxs8, Fragment } from "@opentui/react/jsx-runtime";
1579
+ var RANGES = [1, 7, 30, 90];
1580
+ var COMPLETED_SCAN_HOLD_MS = 800;
1581
+ function App() {
1582
+ const renderer = useRenderer2();
1583
+ const theme = useTerminalTheme(renderer);
1584
+ const { width, height } = useTerminalDimensions();
1585
+ const [dataset, setDataset] = useState7(null);
1586
+ const [metric, setMetric] = useState7("cost");
1587
+ const [range, setRange] = useState7(30);
1588
+ const [breakdown, setBreakdown] = useState7("model");
1589
+ const [visibleSources, setVisibleSources] = useState7(() => new Set(SOURCE_ORDER));
1590
+ const [selectedDay, setSelectedDay] = useState7(null);
1591
+ const [loading, setLoading] = useState7(true);
1592
+ const [error, setError] = useState7(null);
1593
+ const [scanCompleted, setScanCompleted] = useState7(0);
1594
+ const refresh = async () => {
1595
+ const initialScan = dataset == null;
1596
+ setLoading(true);
1597
+ setError(null);
1598
+ if (initialScan)
1599
+ setScanCompleted(0);
1600
+ try {
1601
+ const nextDataset = await loadUsageDataset((progress) => {
1602
+ if (initialScan)
1603
+ setScanCompleted(progress.completed);
1604
+ });
1605
+ if (initialScan)
1606
+ await Bun.sleep(COMPLETED_SCAN_HOLD_MS);
1607
+ setDataset(nextDataset);
1608
+ } catch (cause) {
1609
+ setError(cause instanceof Error ? cause.message : String(cause));
1610
+ } finally {
1611
+ setLoading(false);
1612
+ }
1613
+ };
1614
+ useEffect2(() => {
1615
+ refresh();
1616
+ }, []);
1617
+ const changeRange = (next) => {
1618
+ setRange(next);
1619
+ setSelectedDay(null);
1620
+ };
1621
+ const selectDay = (day) => {
1622
+ setSelectedDay(day);
1623
+ if (day != null)
1624
+ setBreakdown("day");
1625
+ };
1626
+ const toggleSource = (source) => {
1627
+ setVisibleSources((current) => {
1628
+ const next = new Set(current);
1629
+ if (next.has(source))
1630
+ next.delete(source);
1631
+ else
1632
+ next.add(source);
1633
+ return next;
1634
+ });
1635
+ };
1636
+ useKeyboard((key) => {
1637
+ if (key.name === "q" || key.name === "escape")
1638
+ renderer.destroy();
1639
+ else if (key.name === "c")
1640
+ setMetric((value) => value === "cost" ? "tokens" : "cost");
1641
+ else if (key.name === "b")
1642
+ setBreakdown((value) => value === "model" ? "day" : "model");
1643
+ else if (key.name === "r")
1644
+ refresh();
1645
+ else if (["1", "2", "3", "4"].includes(key.name))
1646
+ changeRange(RANGES[Number(key.name) - 1]);
1647
+ else if (key.name === "left" || key.name === "right") {
1648
+ const index = RANGES.indexOf(range);
1649
+ const next = key.name === "left" ? Math.max(0, index - 1) : Math.min(RANGES.length - 1, index + 1);
1650
+ changeRange(RANGES[next]);
1651
+ }
1652
+ });
1653
+ const dashboard = useMemo2(() => dataset == null ? null : buildDashboard(dataset, range, metric), [dataset, range, metric]);
1654
+ const compact = width < 100;
1655
+ const wide = width >= 112;
1656
+ const contentWidth = Math.max(40, width - 4);
1657
+ const summaryWidth = wide ? Math.min(45, Math.floor(contentWidth * 0.34)) : contentWidth;
1658
+ const chartWidth = wide ? contentWidth - summaryWidth - 3 : contentWidth;
1659
+ return /* @__PURE__ */ jsx11(ThemeProvider, {
1660
+ theme,
1661
+ children: /* @__PURE__ */ jsxs8("box", {
1662
+ width: "100%",
1663
+ height: "100%",
1664
+ flexDirection: "column",
1665
+ backgroundColor: theme.bg,
1666
+ children: [
1667
+ dashboard == null ? /* @__PURE__ */ jsx11("box", {
1668
+ flexGrow: 1,
1669
+ width: "100%",
1670
+ height: "100%",
1671
+ paddingX: 2,
1672
+ paddingTop: 1,
1673
+ children: error == null ? /* @__PURE__ */ jsx11(ScanBoot, {
1674
+ completed: scanCompleted,
1675
+ total: SOURCE_ORDER.length,
1676
+ width: contentWidth
1677
+ }) : /* @__PURE__ */ jsx11("text", {
1678
+ fg: theme.error,
1679
+ children: `Error: ${error}`
1680
+ })
1681
+ }) : /* @__PURE__ */ jsx11("scrollbox", {
1682
+ flexGrow: 1,
1683
+ width: "100%",
1684
+ paddingX: 2,
1685
+ paddingTop: 1,
1686
+ scrollY: true,
1687
+ viewportCulling: true,
1688
+ scrollbarOptions: {
1689
+ trackOptions: { backgroundColor: theme.bg, foregroundColor: theme.muted },
1690
+ arrowOptions: { backgroundColor: theme.bg, foregroundColor: theme.muted }
1691
+ },
1692
+ children: /* @__PURE__ */ jsxs8("box", {
1693
+ flexDirection: "column",
1694
+ width: "100%",
1695
+ gap: 2,
1696
+ children: [
1697
+ /* @__PURE__ */ jsx11(Header, {
1698
+ metric,
1699
+ range,
1700
+ days: dashboard.days,
1701
+ compact,
1702
+ onMetricChange: setMetric,
1703
+ onRangeChange: changeRange,
1704
+ onRefresh: () => void refresh()
1705
+ }),
1706
+ dashboard.records.length === 0 ? /* @__PURE__ */ jsx11("text", {
1707
+ fg: theme.muted,
1708
+ children: "No local usage found"
1709
+ }) : /* @__PURE__ */ jsxs8(Fragment, {
1710
+ children: [
1711
+ /* @__PURE__ */ jsxs8("box", {
1712
+ flexDirection: wide ? "row" : "column",
1713
+ width: "100%",
1714
+ gap: 3,
1715
+ children: [
1716
+ /* @__PURE__ */ jsx11("box", {
1717
+ width: wide ? summaryWidth : "100%",
1718
+ children: /* @__PURE__ */ jsx11(Summary, {
1719
+ dashboard,
1720
+ metric,
1721
+ visibleSources,
1722
+ onToggleSource: toggleSource
1723
+ })
1724
+ }),
1725
+ /* @__PURE__ */ jsx11("box", {
1726
+ width: wide ? chartWidth : "100%",
1727
+ children: /* @__PURE__ */ jsx11(Chart, {
1728
+ dashboard,
1729
+ metric,
1730
+ width: chartWidth,
1731
+ height: wide ? 11 : 8,
1732
+ visibleSources,
1733
+ selectedDay,
1734
+ onSelectDay: selectDay
1735
+ })
1736
+ })
1737
+ ]
1738
+ }),
1739
+ /* @__PURE__ */ jsx11(Totals, {
1740
+ dashboard,
1741
+ compact
1742
+ }),
1743
+ /* @__PURE__ */ jsx11(Breakdown, {
1744
+ dashboard,
1745
+ mode: breakdown,
1746
+ metric,
1747
+ width: contentWidth,
1748
+ selectedDay,
1749
+ onModeChange: setBreakdown,
1750
+ onSelectDay: selectDay
1751
+ })
1752
+ ]
1753
+ }),
1754
+ dataset?.errors.length ? /* @__PURE__ */ jsx11("text", {
1755
+ fg: theme.error,
1756
+ children: dataset.errors.join(" \xB7 ")
1757
+ }) : null,
1758
+ /* @__PURE__ */ jsx11("box", {
1759
+ height: 1
1760
+ })
1761
+ ]
1762
+ })
1763
+ }),
1764
+ dashboard == null ? null : /* @__PURE__ */ jsx11(Footer, {
1765
+ loading
1766
+ })
1767
+ ]
1768
+ })
1769
+ });
1770
+ }
1771
+
1772
+ // src/index.tsx
1773
+ import { jsx as jsx12 } from "@opentui/react/jsx-runtime";
1774
+ var renderer = await createCliRenderer({
1775
+ backgroundColor: RGBA2.defaultBackground("#1e1e1e"),
1776
+ exitOnCtrlC: true,
1777
+ targetFps: 30,
1778
+ useMouse: true,
1779
+ enableMouseMovement: true,
1780
+ autoFocus: false
1781
+ });
1782
+ createRoot(renderer).render(/* @__PURE__ */ jsx12(App, {}));