@tokscale/cli 1.0.5 → 1.0.7
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/dist/cli.js +14 -3
- package/dist/cli.js.map +1 -1
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +3 -2
- package/dist/native.js.map +1 -1
- package/package.json +6 -4
- package/src/auth.ts +211 -0
- package/src/cli.ts +1040 -0
- package/src/credentials.ts +123 -0
- package/src/cursor.ts +558 -0
- package/src/graph-types.ts +188 -0
- package/src/graph.ts +485 -0
- package/src/native-runner.ts +105 -0
- package/src/native.ts +938 -0
- package/src/pricing.ts +309 -0
- package/src/sessions/claudecode.ts +119 -0
- package/src/sessions/codex.ts +227 -0
- package/src/sessions/gemini.ts +108 -0
- package/src/sessions/index.ts +126 -0
- package/src/sessions/opencode.ts +94 -0
- package/src/sessions/reports.ts +475 -0
- package/src/sessions/types.ts +59 -0
- package/src/spinner.ts +283 -0
- package/src/submit.ts +175 -0
- package/src/table.ts +233 -0
- package/src/tui/App.tsx +339 -0
- package/src/tui/components/BarChart.tsx +198 -0
- package/src/tui/components/DailyView.tsx +113 -0
- package/src/tui/components/DateBreakdownPanel.tsx +79 -0
- package/src/tui/components/Footer.tsx +225 -0
- package/src/tui/components/Header.tsx +68 -0
- package/src/tui/components/Legend.tsx +39 -0
- package/src/tui/components/LoadingSpinner.tsx +82 -0
- package/src/tui/components/ModelRow.tsx +47 -0
- package/src/tui/components/ModelView.tsx +145 -0
- package/src/tui/components/OverviewView.tsx +108 -0
- package/src/tui/components/StatsView.tsx +225 -0
- package/src/tui/components/TokenBreakdown.tsx +46 -0
- package/src/tui/components/index.ts +15 -0
- package/src/tui/config/settings.ts +130 -0
- package/src/tui/config/themes.ts +115 -0
- package/src/tui/hooks/useData.ts +518 -0
- package/src/tui/index.tsx +44 -0
- package/src/tui/opentui.d.ts +137 -0
- package/src/tui/types/index.ts +165 -0
- package/src/tui/utils/cleanup.ts +65 -0
- package/src/tui/utils/colors.ts +65 -0
- package/src/tui/utils/format.ts +36 -0
- package/src/tui/utils/responsive.ts +8 -0
- package/src/types.d.ts +28 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { createSignal, createEffect, on, type Accessor } from "solid-js";
|
|
2
|
+
import type {
|
|
3
|
+
SourceType,
|
|
4
|
+
SortType,
|
|
5
|
+
ModelEntry,
|
|
6
|
+
DailyEntry,
|
|
7
|
+
ContributionDay,
|
|
8
|
+
Stats,
|
|
9
|
+
ModelWithPercentage,
|
|
10
|
+
GridCell,
|
|
11
|
+
TotalBreakdown,
|
|
12
|
+
TUIData,
|
|
13
|
+
ChartDataPoint,
|
|
14
|
+
LoadingPhase,
|
|
15
|
+
DailyModelBreakdown,
|
|
16
|
+
} from "../types/index.js";
|
|
17
|
+
import {
|
|
18
|
+
parseLocalSourcesAsync,
|
|
19
|
+
finalizeReportAsync,
|
|
20
|
+
finalizeGraphAsync,
|
|
21
|
+
type ParsedMessages,
|
|
22
|
+
} from "../../native.js";
|
|
23
|
+
import { PricingFetcher } from "../../pricing.js";
|
|
24
|
+
import { syncCursorCache, loadCursorCredentials } from "../../cursor.js";
|
|
25
|
+
import { getModelColor } from "../utils/colors.js";
|
|
26
|
+
import { loadCachedData, saveCachedData, isCacheStale } from "../config/settings.js";
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
SortType,
|
|
30
|
+
ModelEntry,
|
|
31
|
+
DailyEntry,
|
|
32
|
+
ContributionDay,
|
|
33
|
+
Stats,
|
|
34
|
+
ModelWithPercentage,
|
|
35
|
+
GridCell,
|
|
36
|
+
TotalBreakdown,
|
|
37
|
+
TUIData,
|
|
38
|
+
LoadingPhase,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface DateFilters {
|
|
42
|
+
since?: string;
|
|
43
|
+
until?: string;
|
|
44
|
+
year?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildContributionGrid(contributions: ContributionDay[]): GridCell[][] {
|
|
48
|
+
const grid: GridCell[][] = Array.from({ length: 7 }, () => []);
|
|
49
|
+
|
|
50
|
+
const today = new Date();
|
|
51
|
+
const todayStr = today.toISOString().split("T")[0];
|
|
52
|
+
|
|
53
|
+
const startDate = new Date(today);
|
|
54
|
+
startDate.setDate(startDate.getDate() - 364);
|
|
55
|
+
while (startDate.getDay() !== 0) {
|
|
56
|
+
startDate.setDate(startDate.getDate() - 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const endDate = new Date(today);
|
|
60
|
+
while (endDate.getDay() !== 6) {
|
|
61
|
+
endDate.setDate(endDate.getDate() + 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const contributionMap = new Map(contributions.map(c => [c.date, c.level]));
|
|
65
|
+
|
|
66
|
+
const currentDate = new Date(startDate);
|
|
67
|
+
while (currentDate <= endDate) {
|
|
68
|
+
const year = currentDate.getFullYear();
|
|
69
|
+
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
|
|
70
|
+
const day = String(currentDate.getDate()).padStart(2, "0");
|
|
71
|
+
const dateStr = `${year}-${month}-${day}`;
|
|
72
|
+
const dayOfWeek = currentDate.getDay();
|
|
73
|
+
|
|
74
|
+
const isFuture = dateStr > todayStr;
|
|
75
|
+
const level = isFuture ? 0 : (contributionMap.get(dateStr) || 0);
|
|
76
|
+
|
|
77
|
+
grid[dayOfWeek].push({ date: isFuture ? null : dateStr, level });
|
|
78
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return grid;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function calculatePeakHour(messages: Array<{ timestamp: number }>): string {
|
|
85
|
+
if (messages.length === 0) return "N/A";
|
|
86
|
+
|
|
87
|
+
const hourCounts = new Array(24).fill(0);
|
|
88
|
+
for (const msg of messages) {
|
|
89
|
+
const hour = new Date(msg.timestamp).getHours();
|
|
90
|
+
hourCounts[hour]++;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let maxCount = 0;
|
|
94
|
+
let peakHour = 0;
|
|
95
|
+
for (let h = 0; h < 24; h++) {
|
|
96
|
+
if (hourCounts[h] > maxCount) {
|
|
97
|
+
maxCount = hourCounts[h];
|
|
98
|
+
peakHour = h;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (maxCount === 0) return "N/A";
|
|
103
|
+
|
|
104
|
+
const suffix = peakHour >= 12 ? "pm" : "am";
|
|
105
|
+
const displayHour = peakHour === 0 ? 12 : peakHour > 12 ? peakHour - 12 : peakHour;
|
|
106
|
+
return `${displayHour}${suffix}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function calculateLongestSession(messages: Array<{ sessionId: string; timestamp: number }>): string {
|
|
110
|
+
if (messages.length === 0) return "N/A";
|
|
111
|
+
|
|
112
|
+
const sessions = new Map<string, number[]>();
|
|
113
|
+
for (const msg of messages) {
|
|
114
|
+
if (!msg.sessionId) continue;
|
|
115
|
+
const timestamps = sessions.get(msg.sessionId) || [];
|
|
116
|
+
timestamps.push(msg.timestamp);
|
|
117
|
+
sessions.set(msg.sessionId, timestamps);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (sessions.size === 0) return "N/A";
|
|
121
|
+
|
|
122
|
+
let maxDurationMs = 0;
|
|
123
|
+
for (const [, timestamps] of sessions) {
|
|
124
|
+
if (timestamps.length < 2) continue;
|
|
125
|
+
const minTs = Math.min(...timestamps);
|
|
126
|
+
const maxTs = Math.max(...timestamps);
|
|
127
|
+
const duration = maxTs - minTs;
|
|
128
|
+
if (duration > maxDurationMs) {
|
|
129
|
+
maxDurationMs = duration;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (maxDurationMs === 0) return "N/A";
|
|
134
|
+
|
|
135
|
+
const totalSeconds = Math.floor(maxDurationMs / 1000);
|
|
136
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
137
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
138
|
+
|
|
139
|
+
if (hours > 0) {
|
|
140
|
+
return `${hours}h ${minutes}m`;
|
|
141
|
+
} else if (minutes > 0) {
|
|
142
|
+
return `${minutes}m`;
|
|
143
|
+
}
|
|
144
|
+
return `${totalSeconds}s`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function loadData(enabledSources: Set<SourceType>, dateFilters?: DateFilters): Promise<TUIData> {
|
|
148
|
+
const sources = Array.from(enabledSources);
|
|
149
|
+
const localSources = sources.filter(s => s !== "cursor");
|
|
150
|
+
const includeCursor = sources.includes("cursor");
|
|
151
|
+
const { since, until, year } = dateFilters ?? {};
|
|
152
|
+
|
|
153
|
+
const pricingFetcher = new PricingFetcher();
|
|
154
|
+
|
|
155
|
+
const phase1Results = await Promise.allSettled([
|
|
156
|
+
pricingFetcher.fetchPricing(),
|
|
157
|
+
includeCursor && loadCursorCredentials() ? syncCursorCache() : Promise.resolve({ synced: false, rows: 0 }),
|
|
158
|
+
localSources.length > 0
|
|
159
|
+
? parseLocalSourcesAsync({ sources: localSources as ("opencode" | "claude" | "codex" | "gemini")[], since, until, year })
|
|
160
|
+
: Promise.resolve({ messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, processingTimeMs: 0 } as ParsedMessages),
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const cursorSync = phase1Results[1].status === "fulfilled"
|
|
164
|
+
? phase1Results[1].value
|
|
165
|
+
: { synced: false, rows: 0 };
|
|
166
|
+
const localMessages = phase1Results[2].status === "fulfilled"
|
|
167
|
+
? phase1Results[2].value
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
const emptyMessages: ParsedMessages = {
|
|
171
|
+
messages: [],
|
|
172
|
+
opencodeCount: 0,
|
|
173
|
+
claudeCount: 0,
|
|
174
|
+
codexCount: 0,
|
|
175
|
+
geminiCount: 0,
|
|
176
|
+
processingTimeMs: 0,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const phase2Results = await Promise.allSettled([
|
|
180
|
+
finalizeReportAsync({
|
|
181
|
+
localMessages: localMessages || emptyMessages,
|
|
182
|
+
pricing: pricingFetcher.toPricingEntries(),
|
|
183
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
184
|
+
since,
|
|
185
|
+
until,
|
|
186
|
+
year,
|
|
187
|
+
}),
|
|
188
|
+
finalizeGraphAsync({
|
|
189
|
+
localMessages: localMessages || emptyMessages,
|
|
190
|
+
pricing: pricingFetcher.toPricingEntries(),
|
|
191
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
192
|
+
since,
|
|
193
|
+
until,
|
|
194
|
+
year,
|
|
195
|
+
}),
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
if (phase2Results[0].status === "rejected") {
|
|
199
|
+
throw new Error(`Failed to finalize report: ${phase2Results[0].reason}`);
|
|
200
|
+
}
|
|
201
|
+
if (phase2Results[1].status === "rejected") {
|
|
202
|
+
throw new Error(`Failed to finalize graph: ${phase2Results[1].reason}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const report = phase2Results[0].value;
|
|
206
|
+
const graph = phase2Results[1].value;
|
|
207
|
+
|
|
208
|
+
const modelEntries: ModelEntry[] = report.entries.map(e => ({
|
|
209
|
+
source: e.source,
|
|
210
|
+
model: e.model,
|
|
211
|
+
input: e.input,
|
|
212
|
+
output: e.output,
|
|
213
|
+
cacheWrite: e.cacheWrite,
|
|
214
|
+
cacheRead: e.cacheRead,
|
|
215
|
+
reasoning: e.reasoning,
|
|
216
|
+
total: e.input + e.output + e.cacheWrite + e.cacheRead + e.reasoning,
|
|
217
|
+
cost: e.cost,
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
const dailyMap = new Map<string, DailyEntry>();
|
|
221
|
+
for (const contrib of graph.contributions) {
|
|
222
|
+
const dateStr = contrib.date;
|
|
223
|
+
if (!dailyMap.has(dateStr)) {
|
|
224
|
+
dailyMap.set(dateStr, {
|
|
225
|
+
date: dateStr,
|
|
226
|
+
input: 0,
|
|
227
|
+
output: 0,
|
|
228
|
+
cache: 0,
|
|
229
|
+
total: 0,
|
|
230
|
+
cost: 0,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const entry = dailyMap.get(dateStr)!;
|
|
234
|
+
entry.input += contrib.tokenBreakdown.input;
|
|
235
|
+
entry.output += contrib.tokenBreakdown.output;
|
|
236
|
+
entry.cache += contrib.tokenBreakdown.cacheRead;
|
|
237
|
+
entry.total += contrib.totals.tokens;
|
|
238
|
+
entry.cost += contrib.totals.cost;
|
|
239
|
+
}
|
|
240
|
+
const dailyEntries = Array.from(dailyMap.values()).sort((a, b) => b.date.localeCompare(a.date));
|
|
241
|
+
|
|
242
|
+
let maxCost = 1;
|
|
243
|
+
for (const d of dailyEntries) {
|
|
244
|
+
if (d.cost > maxCost) maxCost = d.cost;
|
|
245
|
+
}
|
|
246
|
+
const contributions: ContributionDay[] = dailyEntries.map(d => ({
|
|
247
|
+
date: d.date,
|
|
248
|
+
cost: d.cost,
|
|
249
|
+
level: d.cost === 0 ? 0 : (Math.min(4, Math.ceil((d.cost / maxCost) * 4)) as 0 | 1 | 2 | 3 | 4),
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
const contributionGrid = buildContributionGrid(contributions);
|
|
253
|
+
|
|
254
|
+
const modelCosts = new Map<string, number>();
|
|
255
|
+
for (const e of modelEntries) {
|
|
256
|
+
const current = modelCosts.get(e.model) || 0;
|
|
257
|
+
modelCosts.set(e.model, current + e.cost);
|
|
258
|
+
}
|
|
259
|
+
let favoriteModel = "N/A";
|
|
260
|
+
let maxModelCost = 0;
|
|
261
|
+
for (const [model, cost] of modelCosts) {
|
|
262
|
+
if (cost > maxModelCost) {
|
|
263
|
+
maxModelCost = cost;
|
|
264
|
+
favoriteModel = model;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let currentStreak = 0;
|
|
269
|
+
let longestStreak = 0;
|
|
270
|
+
|
|
271
|
+
const sortedDates = dailyEntries.map(d => d.date).sort();
|
|
272
|
+
if (sortedDates.length > 0) {
|
|
273
|
+
const todayParts = new Date().toISOString().split("T")[0].split("-").map(Number);
|
|
274
|
+
const todayUTC = Date.UTC(todayParts[0], todayParts[1] - 1, todayParts[2]);
|
|
275
|
+
|
|
276
|
+
let streak = 0;
|
|
277
|
+
for (let i = sortedDates.length - 1; i >= 0; i--) {
|
|
278
|
+
const dateParts = sortedDates[i].split("-").map(Number);
|
|
279
|
+
const dateUTC = Date.UTC(dateParts[0], dateParts[1] - 1, dateParts[2]);
|
|
280
|
+
const daysFromToday = Math.round((todayUTC - dateUTC) / (1000 * 60 * 60 * 24));
|
|
281
|
+
|
|
282
|
+
if (i === sortedDates.length - 1) {
|
|
283
|
+
if (daysFromToday <= 1) {
|
|
284
|
+
streak = 1;
|
|
285
|
+
} else {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
const prevParts = sortedDates[i + 1].split("-").map(Number);
|
|
290
|
+
const prevUTC = Date.UTC(prevParts[0], prevParts[1] - 1, prevParts[2]);
|
|
291
|
+
const diffDays = Math.round((prevUTC - dateUTC) / (1000 * 60 * 60 * 24));
|
|
292
|
+
if (diffDays === 1) {
|
|
293
|
+
streak++;
|
|
294
|
+
} else {
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
currentStreak = streak;
|
|
300
|
+
|
|
301
|
+
streak = 1;
|
|
302
|
+
for (let i = 1; i < sortedDates.length; i++) {
|
|
303
|
+
const prevParts = sortedDates[i - 1].split("-").map(Number);
|
|
304
|
+
const currParts = sortedDates[i].split("-").map(Number);
|
|
305
|
+
const prevDate = Date.UTC(prevParts[0], prevParts[1] - 1, prevParts[2]);
|
|
306
|
+
const currDate = Date.UTC(currParts[0], currParts[1] - 1, currParts[2]);
|
|
307
|
+
const diffDays = Math.round((currDate - prevDate) / (1000 * 60 * 60 * 24));
|
|
308
|
+
if (diffDays === 1) {
|
|
309
|
+
streak++;
|
|
310
|
+
} else {
|
|
311
|
+
longestStreak = Math.max(longestStreak, streak);
|
|
312
|
+
streak = 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
longestStreak = Math.max(longestStreak, streak);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const stats: Stats = {
|
|
319
|
+
favoriteModel,
|
|
320
|
+
totalTokens: report.totalInput + report.totalOutput + report.totalCacheRead + report.totalCacheWrite,
|
|
321
|
+
sessions: report.totalMessages,
|
|
322
|
+
longestSession: calculateLongestSession(localMessages?.messages || []),
|
|
323
|
+
currentStreak,
|
|
324
|
+
longestStreak,
|
|
325
|
+
activeDays: dailyEntries.length,
|
|
326
|
+
totalDays: graph.summary.totalDays,
|
|
327
|
+
peakHour: calculatePeakHour(localMessages?.messages || []),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const dailyModelMap = new Map<string, Map<string, number>>();
|
|
331
|
+
for (const contrib of graph.contributions) {
|
|
332
|
+
const dateStr = contrib.date;
|
|
333
|
+
if (!dailyModelMap.has(dateStr)) {
|
|
334
|
+
dailyModelMap.set(dateStr, new Map());
|
|
335
|
+
}
|
|
336
|
+
const modelMap = dailyModelMap.get(dateStr)!;
|
|
337
|
+
for (const source of contrib.sources) {
|
|
338
|
+
const modelId = source.modelId;
|
|
339
|
+
const tokens = source.tokens.input + source.tokens.output + source.tokens.cacheRead;
|
|
340
|
+
modelMap.set(modelId, (modelMap.get(modelId) || 0) + tokens);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const chartData: ChartDataPoint[] = Array.from(dailyModelMap.entries())
|
|
345
|
+
.map(([date, modelMap]) => {
|
|
346
|
+
const models = Array.from(modelMap.entries()).map(([modelId, tokens]) => ({
|
|
347
|
+
modelId,
|
|
348
|
+
tokens,
|
|
349
|
+
color: getModelColor(modelId),
|
|
350
|
+
}));
|
|
351
|
+
return {
|
|
352
|
+
date,
|
|
353
|
+
models,
|
|
354
|
+
total: models.reduce((sum, m) => sum + m.tokens, 0),
|
|
355
|
+
};
|
|
356
|
+
})
|
|
357
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
358
|
+
|
|
359
|
+
const modelTokensMap = new Map<string, { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }>();
|
|
360
|
+
for (const e of modelEntries) {
|
|
361
|
+
const existing = modelTokensMap.get(e.model) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
362
|
+
modelTokensMap.set(e.model, {
|
|
363
|
+
input: existing.input + e.input,
|
|
364
|
+
output: existing.output + e.output,
|
|
365
|
+
cacheRead: existing.cacheRead + e.cacheRead,
|
|
366
|
+
cacheWrite: existing.cacheWrite + e.cacheWrite,
|
|
367
|
+
cost: existing.cost + e.cost,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const totalCostSum = report.totalCost || 1;
|
|
372
|
+
const topModels: ModelWithPercentage[] = Array.from(modelTokensMap.entries())
|
|
373
|
+
.map(([modelId, data]) => {
|
|
374
|
+
const totalTokens = data.input + data.output + data.cacheRead + data.cacheWrite;
|
|
375
|
+
return {
|
|
376
|
+
modelId,
|
|
377
|
+
percentage: (data.cost / totalCostSum) * 100,
|
|
378
|
+
inputTokens: data.input,
|
|
379
|
+
outputTokens: data.output,
|
|
380
|
+
cacheReadTokens: data.cacheRead,
|
|
381
|
+
cacheWriteTokens: data.cacheWrite,
|
|
382
|
+
totalTokens,
|
|
383
|
+
cost: data.cost,
|
|
384
|
+
};
|
|
385
|
+
})
|
|
386
|
+
.sort((a, b) => b.cost - a.cost);
|
|
387
|
+
|
|
388
|
+
const totalReasoning = modelEntries.reduce((sum, e) => sum + e.reasoning, 0);
|
|
389
|
+
const totals: TotalBreakdown = {
|
|
390
|
+
input: report.totalInput,
|
|
391
|
+
output: report.totalOutput,
|
|
392
|
+
cacheWrite: report.totalCacheWrite,
|
|
393
|
+
cacheRead: report.totalCacheRead,
|
|
394
|
+
reasoning: totalReasoning,
|
|
395
|
+
total: report.totalInput + report.totalOutput + report.totalCacheWrite + report.totalCacheRead + totalReasoning,
|
|
396
|
+
cost: report.totalCost,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const dailyBreakdowns = new Map<string, DailyModelBreakdown>();
|
|
400
|
+
for (const contrib of graph.contributions) {
|
|
401
|
+
const models = contrib.sources.map((source: { modelId: string; source: string; tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; reasoning?: number }; cost: number; messages: number }) => ({
|
|
402
|
+
modelId: source.modelId,
|
|
403
|
+
source: source.source,
|
|
404
|
+
tokens: {
|
|
405
|
+
input: source.tokens.input,
|
|
406
|
+
output: source.tokens.output,
|
|
407
|
+
cacheRead: source.tokens.cacheRead,
|
|
408
|
+
cacheWrite: source.tokens.cacheWrite,
|
|
409
|
+
reasoning: source.tokens.reasoning || 0,
|
|
410
|
+
},
|
|
411
|
+
cost: source.cost,
|
|
412
|
+
messages: source.messages,
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
dailyBreakdowns.set(contrib.date, {
|
|
416
|
+
date: contrib.date,
|
|
417
|
+
cost: contrib.totals.cost,
|
|
418
|
+
totalTokens: contrib.totals.tokens,
|
|
419
|
+
models: models.sort((a, b) => b.cost - a.cost),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
modelEntries,
|
|
425
|
+
dailyEntries,
|
|
426
|
+
contributions,
|
|
427
|
+
contributionGrid,
|
|
428
|
+
stats,
|
|
429
|
+
totalCost: report.totalCost,
|
|
430
|
+
totals,
|
|
431
|
+
modelCount: modelEntries.length,
|
|
432
|
+
chartData,
|
|
433
|
+
topModels,
|
|
434
|
+
dailyBreakdowns,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function useData(enabledSources: Accessor<Set<SourceType>>, dateFilters?: DateFilters) {
|
|
439
|
+
const initialSources = enabledSources();
|
|
440
|
+
const initialCachedData = loadCachedData(initialSources);
|
|
441
|
+
const initialCacheIsStale = initialCachedData ? isCacheStale(initialSources) : true;
|
|
442
|
+
|
|
443
|
+
const [data, setData] = createSignal<TUIData | null>(initialCachedData);
|
|
444
|
+
const [loading, setLoading] = createSignal(!initialCachedData);
|
|
445
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
446
|
+
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
|
|
447
|
+
const [loadingPhase, setLoadingPhase] = createSignal<LoadingPhase>(
|
|
448
|
+
initialCachedData ? (initialCacheIsStale ? "loading-pricing" : "complete") : "idle"
|
|
449
|
+
);
|
|
450
|
+
const [isRefreshing, setIsRefreshing] = createSignal(initialCachedData ? initialCacheIsStale : false);
|
|
451
|
+
|
|
452
|
+
const [forceRefresh, setForceRefresh] = createSignal(false);
|
|
453
|
+
|
|
454
|
+
const refresh = () => {
|
|
455
|
+
setForceRefresh(true);
|
|
456
|
+
setRefreshTrigger(prev => prev + 1);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const doLoad = (sources: Set<SourceType>, skipCacheCheck = false) => {
|
|
460
|
+
const shouldSkipCache = skipCacheCheck || forceRefresh();
|
|
461
|
+
|
|
462
|
+
if (!shouldSkipCache) {
|
|
463
|
+
const cachedData = loadCachedData(sources);
|
|
464
|
+
const cacheIsStale = isCacheStale(sources);
|
|
465
|
+
|
|
466
|
+
if (cachedData && !cacheIsStale) {
|
|
467
|
+
setData(cachedData);
|
|
468
|
+
setLoading(false);
|
|
469
|
+
setLoadingPhase("complete");
|
|
470
|
+
setIsRefreshing(false);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (cachedData) {
|
|
475
|
+
setData(cachedData);
|
|
476
|
+
setLoading(false);
|
|
477
|
+
setIsRefreshing(true);
|
|
478
|
+
setLoadingPhase("loading-pricing");
|
|
479
|
+
} else {
|
|
480
|
+
setLoading(true);
|
|
481
|
+
setLoadingPhase("loading-pricing");
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
setIsRefreshing(true);
|
|
485
|
+
setLoadingPhase("loading-pricing");
|
|
486
|
+
setForceRefresh(false);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
setError(null);
|
|
490
|
+
loadData(sources, dateFilters)
|
|
491
|
+
.then((freshData) => {
|
|
492
|
+
setData(freshData);
|
|
493
|
+
saveCachedData(freshData, sources);
|
|
494
|
+
})
|
|
495
|
+
.catch((e) => setError(e.message))
|
|
496
|
+
.finally(() => {
|
|
497
|
+
setLoading(false);
|
|
498
|
+
setIsRefreshing(false);
|
|
499
|
+
setLoadingPhase("complete");
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
if (initialCachedData && initialCacheIsStale) {
|
|
504
|
+
doLoad(initialSources, true);
|
|
505
|
+
} else if (!initialCachedData) {
|
|
506
|
+
doLoad(initialSources, false);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
createEffect(on(
|
|
510
|
+
() => [enabledSources(), refreshTrigger()] as const,
|
|
511
|
+
([sources]) => {
|
|
512
|
+
doLoad(sources);
|
|
513
|
+
},
|
|
514
|
+
{ defer: true }
|
|
515
|
+
));
|
|
516
|
+
|
|
517
|
+
return { data, loading, error, refresh, loadingPhase, isRefreshing };
|
|
518
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { render } from "@opentui/solid";
|
|
2
|
+
import { App } from "./App.js";
|
|
3
|
+
import type { TUIOptions } from "./types/index.js";
|
|
4
|
+
import { restoreTerminalState } from "./utils/cleanup.js";
|
|
5
|
+
|
|
6
|
+
export type { TUIOptions };
|
|
7
|
+
|
|
8
|
+
export async function launchTUI(options?: TUIOptions) {
|
|
9
|
+
const cleanup = () => {
|
|
10
|
+
restoreTerminalState();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
process.on('uncaughtException', (error) => {
|
|
14
|
+
cleanup();
|
|
15
|
+
console.error('Uncaught exception:', error);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
process.on('unhandledRejection', (reason) => {
|
|
20
|
+
cleanup();
|
|
21
|
+
console.error('Unhandled rejection:', reason);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
process.on('SIGINT', () => {
|
|
26
|
+
cleanup();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
process.on('SIGTERM', () => {
|
|
31
|
+
cleanup();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
process.on('beforeExit', cleanup);
|
|
36
|
+
|
|
37
|
+
await render(() => <App {...(options ?? {})} />, {
|
|
38
|
+
exitOnCtrlC: false,
|
|
39
|
+
useAlternateScreen: true,
|
|
40
|
+
useMouse: true,
|
|
41
|
+
targetFps: 60,
|
|
42
|
+
useKittyKeyboard: {},
|
|
43
|
+
} as any);
|
|
44
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
declare module "@opentui/core" {
|
|
2
|
+
export interface CliRendererConfig {
|
|
3
|
+
exitOnCtrlC?: boolean;
|
|
4
|
+
targetFps?: number;
|
|
5
|
+
backgroundColor?: string;
|
|
6
|
+
useAlternateScreen?: boolean;
|
|
7
|
+
useMouse?: boolean;
|
|
8
|
+
gatherStats?: boolean;
|
|
9
|
+
useKittyKeyboard?: Record<string, unknown> | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CliRenderer {
|
|
13
|
+
root: {
|
|
14
|
+
add: (renderable: unknown) => void;
|
|
15
|
+
};
|
|
16
|
+
start: () => void;
|
|
17
|
+
stop: () => void;
|
|
18
|
+
destroy: () => void;
|
|
19
|
+
console: {
|
|
20
|
+
show: () => void;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createCliRenderer(config?: CliRendererConfig): Promise<CliRenderer>;
|
|
25
|
+
|
|
26
|
+
export interface KeyEvent {
|
|
27
|
+
name: string;
|
|
28
|
+
eventType: "press" | "release";
|
|
29
|
+
repeated?: boolean;
|
|
30
|
+
ctrl?: boolean;
|
|
31
|
+
meta?: boolean;
|
|
32
|
+
shift?: boolean;
|
|
33
|
+
super?: boolean;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare module "@opentui/solid" {
|
|
38
|
+
import type { Accessor, JSX as SolidJSX } from "solid-js";
|
|
39
|
+
import type { CliRendererConfig, CliRenderer, KeyEvent } from "@opentui/core";
|
|
40
|
+
|
|
41
|
+
export function render(
|
|
42
|
+
node: () => SolidJSX.Element,
|
|
43
|
+
config?: CliRendererConfig
|
|
44
|
+
): Promise<void>;
|
|
45
|
+
|
|
46
|
+
export function useKeyboard(
|
|
47
|
+
handler: (key: KeyEvent) => void,
|
|
48
|
+
options?: { release?: boolean }
|
|
49
|
+
): void;
|
|
50
|
+
|
|
51
|
+
export function useTerminalDimensions(): Accessor<{
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
}>;
|
|
55
|
+
|
|
56
|
+
export function useRenderer(): CliRenderer;
|
|
57
|
+
|
|
58
|
+
export function useOnResize(callback: (width: number, height: number) => void): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare namespace JSX {
|
|
62
|
+
interface MouseEvent {
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
button: number;
|
|
66
|
+
type: "down" | "up" | "move" | "drag" | "scroll";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface MouseEventHandlers {
|
|
70
|
+
onMouse?: (event: MouseEvent) => void;
|
|
71
|
+
onMouseDown?: (event: MouseEvent) => void;
|
|
72
|
+
onMouseUp?: (event: MouseEvent) => void;
|
|
73
|
+
onMouseMove?: (event: MouseEvent) => void;
|
|
74
|
+
onMouseDrag?: (event: MouseEvent) => void;
|
|
75
|
+
onMouseOver?: (event: MouseEvent) => void;
|
|
76
|
+
onMouseOut?: (event: MouseEvent) => void;
|
|
77
|
+
onMouseScroll?: (event: MouseEvent) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface IntrinsicElements {
|
|
81
|
+
box: {
|
|
82
|
+
flexDirection?: "row" | "column";
|
|
83
|
+
flexGrow?: number;
|
|
84
|
+
flexShrink?: number;
|
|
85
|
+
flexWrap?: "wrap" | "nowrap";
|
|
86
|
+
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
|
|
87
|
+
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
|
|
88
|
+
alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
|
|
89
|
+
gap?: number;
|
|
90
|
+
width?: number | string;
|
|
91
|
+
height?: number | string;
|
|
92
|
+
minWidth?: number | string;
|
|
93
|
+
minHeight?: number | string;
|
|
94
|
+
maxWidth?: number | string;
|
|
95
|
+
maxHeight?: number | string;
|
|
96
|
+
padding?: number;
|
|
97
|
+
paddingX?: number;
|
|
98
|
+
paddingY?: number;
|
|
99
|
+
paddingTop?: number;
|
|
100
|
+
paddingRight?: number;
|
|
101
|
+
paddingBottom?: number;
|
|
102
|
+
paddingLeft?: number;
|
|
103
|
+
margin?: number;
|
|
104
|
+
marginTop?: number;
|
|
105
|
+
marginRight?: number;
|
|
106
|
+
marginBottom?: number;
|
|
107
|
+
marginLeft?: number;
|
|
108
|
+
position?: "relative" | "absolute";
|
|
109
|
+
top?: number;
|
|
110
|
+
right?: number;
|
|
111
|
+
bottom?: number;
|
|
112
|
+
left?: number;
|
|
113
|
+
backgroundColor?: string;
|
|
114
|
+
borderStyle?: "single" | "double" | "round" | "bold" | "singleDouble" | "doubleSingle" | "classic";
|
|
115
|
+
borderColor?: string;
|
|
116
|
+
borderTop?: boolean;
|
|
117
|
+
borderRight?: boolean;
|
|
118
|
+
borderBottom?: boolean;
|
|
119
|
+
borderLeft?: boolean;
|
|
120
|
+
overflow?: "visible" | "hidden" | "scroll";
|
|
121
|
+
children?: unknown;
|
|
122
|
+
} & MouseEventHandlers;
|
|
123
|
+
text: {
|
|
124
|
+
fg?: string;
|
|
125
|
+
bg?: string;
|
|
126
|
+
backgroundColor?: string;
|
|
127
|
+
bold?: boolean;
|
|
128
|
+
dim?: boolean;
|
|
129
|
+
italic?: boolean;
|
|
130
|
+
underline?: boolean;
|
|
131
|
+
strikethrough?: boolean;
|
|
132
|
+
inverse?: boolean;
|
|
133
|
+
wrap?: "wrap" | "truncate" | "truncate-start" | "truncate-middle" | "truncate-end";
|
|
134
|
+
children?: unknown;
|
|
135
|
+
} & MouseEventHandlers;
|
|
136
|
+
}
|
|
137
|
+
}
|