@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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/yacu.js +1782 -0
- package/package.json +62 -0
- package/src/data/jsonl.ts +81 -0
- package/src/data/load.ts +52 -0
- package/src/data/pricing.test.ts +22 -0
- package/src/data/pricing.ts +49 -0
- package/src/data/sources/claude.test.ts +40 -0
- package/src/data/sources/claude.ts +118 -0
- package/src/data/sources/codex.ts +73 -0
- package/src/data/sources/cursor.test.ts +16 -0
- package/src/data/sources/cursor.ts +83 -0
- package/src/data/sources/gemini.test.ts +27 -0
- package/src/data/sources/gemini.ts +112 -0
- package/src/data/sources/grok.ts +60 -0
- package/src/data/sources/opencode.ts +53 -0
- package/src/data/types.ts +23 -0
- package/src/domain/aggregate.test.ts +62 -0
- package/src/domain/aggregate.ts +143 -0
- package/src/domain/dates.ts +24 -0
- package/src/domain/types.ts +48 -0
- package/src/index.tsx +15 -0
- package/src/tui/App.tsx +170 -0
- package/src/tui/ThemeContext.tsx +12 -0
- package/src/tui/chart.test.ts +48 -0
- package/src/tui/chart.ts +130 -0
- package/src/tui/components/Breakdown.tsx +85 -0
- package/src/tui/components/Chart.test.tsx +67 -0
- package/src/tui/components/Chart.tsx +159 -0
- package/src/tui/components/Footer.tsx +11 -0
- package/src/tui/components/Header.tsx +52 -0
- package/src/tui/components/PointerButton.tsx +40 -0
- package/src/tui/components/ScanBoot.test.tsx +30 -0
- package/src/tui/components/ScanBoot.tsx +30 -0
- package/src/tui/components/Segmented.test.tsx +32 -0
- package/src/tui/components/Segmented.tsx +59 -0
- package/src/tui/components/Summary.tsx +67 -0
- package/src/tui/components/Totals.tsx +27 -0
- package/src/tui/format.ts +44 -0
- package/src/tui/theme.ts +99 -0
- package/src/tui/usePointer.ts +11 -0
- 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, {}));
|