@tmustier/pi-usage-extension 0.3.2 → 0.6.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/CHANGELOG.md +30 -0
- package/README.md +32 -5
- package/data.ts +964 -0
- package/graph.ts +386 -0
- package/index.ts +184 -561
- package/package.json +3 -3
package/index.ts
CHANGED
|
@@ -5,90 +5,30 @@
|
|
|
5
5
|
* - Tab cycles: Today → This Week → Last Week → All Time
|
|
6
6
|
* - Arrow keys navigate providers
|
|
7
7
|
* - Enter expands/collapses to show models
|
|
8
|
+
*
|
|
9
|
+
* Data collection and caching live in ./data.ts.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
13
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
13
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
|
-
|
|
17
|
-
// =============================================================================
|
|
18
|
-
// Types
|
|
19
|
-
// =============================================================================
|
|
20
15
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface ModelStats extends BaseStats {
|
|
36
|
-
sessions: Set<string>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface ProviderStats extends BaseStats {
|
|
40
|
-
sessions: Set<string>;
|
|
41
|
-
models: Map<string, ModelStats>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface TotalStats extends BaseStats {
|
|
45
|
-
sessions: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface Insight {
|
|
49
|
-
percent: number; // 0-100
|
|
50
|
-
headline: string;
|
|
51
|
-
advice: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface PeriodInsights {
|
|
55
|
-
insights: Insight[];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
interface RawMessage {
|
|
59
|
-
sessionId: string;
|
|
60
|
-
timestamp: number;
|
|
61
|
-
cost: number;
|
|
62
|
-
input: number;
|
|
63
|
-
cacheRead: number;
|
|
64
|
-
cacheWrite: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface PeriodRawData {
|
|
68
|
-
messages: RawMessage[];
|
|
69
|
-
sessionCosts: Map<string, number>;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface GlobalSessionSpan {
|
|
73
|
-
startMs: number;
|
|
74
|
-
endMs: number;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
interface TimeFilteredStats {
|
|
78
|
-
providers: Map<string, ProviderStats>;
|
|
79
|
-
totals: TotalStats;
|
|
80
|
-
insights: PeriodInsights;
|
|
81
|
-
}
|
|
16
|
+
import { collectUsageData, TAB_ORDER } from "./data";
|
|
17
|
+
import type { BaseStats, TabName, UsageData } from "./data";
|
|
18
|
+
import {
|
|
19
|
+
buildGraphModel,
|
|
20
|
+
renderChart,
|
|
21
|
+
GROUP_LABELS,
|
|
22
|
+
GROUP_ORDER,
|
|
23
|
+
METRIC_LABELS,
|
|
24
|
+
METRIC_ORDER,
|
|
25
|
+
TOTAL_SERIES_KEY,
|
|
26
|
+
} from "./graph";
|
|
27
|
+
import type { GraphGroupBy, GraphMetric, GraphModel } from "./graph";
|
|
82
28
|
|
|
83
|
-
|
|
84
|
-
today: TimeFilteredStats;
|
|
85
|
-
thisWeek: TimeFilteredStats;
|
|
86
|
-
lastWeek: TimeFilteredStats;
|
|
87
|
-
allTime: TimeFilteredStats;
|
|
88
|
-
}
|
|
29
|
+
type ViewMode = "table" | "insights" | "graph";
|
|
89
30
|
|
|
90
|
-
|
|
91
|
-
type ViewMode = "table" | "insights";
|
|
31
|
+
const VIEW_CYCLE: ViewMode[] = ["table", "insights", "graph"];
|
|
92
32
|
|
|
93
33
|
// =============================================================================
|
|
94
34
|
// Column Configuration
|
|
@@ -182,482 +122,6 @@ const TABLE_LAYOUTS: TableLayoutCandidate[] = [
|
|
|
182
122
|
{ columns: [COST_COLUMN], minNameWidth: 8, compact: true },
|
|
183
123
|
];
|
|
184
124
|
|
|
185
|
-
// =============================================================================
|
|
186
|
-
// Data Collection
|
|
187
|
-
// =============================================================================
|
|
188
|
-
|
|
189
|
-
function getSessionsDir(): string {
|
|
190
|
-
// Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
|
|
191
|
-
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
192
|
-
return join(agentDir, "sessions");
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
|
|
196
|
-
try {
|
|
197
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
198
|
-
for (const entry of entries) {
|
|
199
|
-
if (signal?.aborted) return;
|
|
200
|
-
const entryPath = join(dir, entry.name);
|
|
201
|
-
if (entry.isDirectory()) {
|
|
202
|
-
await collectSessionFilesRecursively(entryPath, files, signal);
|
|
203
|
-
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
204
|
-
files.push(entryPath);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
} catch {
|
|
208
|
-
// Skip directories we can't read
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
|
|
213
|
-
const files: string[] = [];
|
|
214
|
-
await collectSessionFilesRecursively(getSessionsDir(), files, signal);
|
|
215
|
-
files.sort();
|
|
216
|
-
return files;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
interface SessionMessage {
|
|
220
|
-
provider: string;
|
|
221
|
-
model: string;
|
|
222
|
-
cost: number;
|
|
223
|
-
input: number;
|
|
224
|
-
output: number;
|
|
225
|
-
cacheRead: number;
|
|
226
|
-
cacheWrite: number;
|
|
227
|
-
timestamp: number;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
interface ParsedSessionFile {
|
|
231
|
-
sessionId: string;
|
|
232
|
-
messages: SessionMessage[];
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function parseSessionFile(
|
|
236
|
-
filePath: string,
|
|
237
|
-
seenHashes: Set<string>,
|
|
238
|
-
signal?: AbortSignal
|
|
239
|
-
): Promise<ParsedSessionFile | null> {
|
|
240
|
-
try {
|
|
241
|
-
const content = await readFile(filePath, "utf8");
|
|
242
|
-
if (signal?.aborted) return null;
|
|
243
|
-
const lines = content.trim().split("\n");
|
|
244
|
-
const messages: SessionMessage[] = [];
|
|
245
|
-
let sessionId = "";
|
|
246
|
-
|
|
247
|
-
for (let i = 0; i < lines.length; i++) {
|
|
248
|
-
if (signal?.aborted) return null;
|
|
249
|
-
if (i % 500 === 0) {
|
|
250
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
251
|
-
}
|
|
252
|
-
const line = lines[i]!;
|
|
253
|
-
if (!line.trim()) continue;
|
|
254
|
-
try {
|
|
255
|
-
const entry = JSON.parse(line);
|
|
256
|
-
|
|
257
|
-
if (entry.type === "session") {
|
|
258
|
-
sessionId = entry.id;
|
|
259
|
-
} else if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
260
|
-
const msg = entry.message;
|
|
261
|
-
if (msg.usage && msg.provider && msg.model) {
|
|
262
|
-
const input = msg.usage.input || 0;
|
|
263
|
-
const output = msg.usage.output || 0;
|
|
264
|
-
const cacheRead = msg.usage.cacheRead || 0;
|
|
265
|
-
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
266
|
-
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
267
|
-
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
268
|
-
|
|
269
|
-
// Deduplicate copied history across branched session files.
|
|
270
|
-
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
271
|
-
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
272
|
-
const hash = `${timestamp}:${totalTokens}`;
|
|
273
|
-
if (seenHashes.has(hash)) continue;
|
|
274
|
-
seenHashes.add(hash);
|
|
275
|
-
|
|
276
|
-
messages.push({
|
|
277
|
-
provider: msg.provider,
|
|
278
|
-
model: msg.model,
|
|
279
|
-
cost: msg.usage.cost?.total || 0,
|
|
280
|
-
input,
|
|
281
|
-
output,
|
|
282
|
-
cacheRead,
|
|
283
|
-
cacheWrite,
|
|
284
|
-
timestamp,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
} catch {
|
|
289
|
-
// Skip malformed lines
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return sessionId ? { sessionId, messages } : null;
|
|
294
|
-
} catch {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Helper to accumulate stats into a target
|
|
300
|
-
function accumulateStats(
|
|
301
|
-
target: BaseStats,
|
|
302
|
-
cost: number,
|
|
303
|
-
tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
304
|
-
): void {
|
|
305
|
-
target.messages++;
|
|
306
|
-
target.cost += cost;
|
|
307
|
-
target.tokens.total += tokens.total;
|
|
308
|
-
target.tokens.input += tokens.input;
|
|
309
|
-
target.tokens.output += tokens.output;
|
|
310
|
-
target.tokens.cacheRead += tokens.cacheRead;
|
|
311
|
-
target.tokens.cacheWrite += tokens.cacheWrite;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function emptyTokens(): TokenStats {
|
|
315
|
-
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function emptyModelStats(): ModelStats {
|
|
319
|
-
return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens() };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function emptyProviderStats(): ProviderStats {
|
|
323
|
-
return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens(), models: new Map() };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
327
|
-
return {
|
|
328
|
-
providers: new Map(),
|
|
329
|
-
totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
|
|
330
|
-
insights: { insights: [] },
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function emptyPeriodRawData(): PeriodRawData {
|
|
335
|
-
return { messages: [], sessionCosts: new Map() };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function emptyUsageData(): UsageData {
|
|
339
|
-
return {
|
|
340
|
-
today: emptyTimeFilteredStats(),
|
|
341
|
-
thisWeek: emptyTimeFilteredStats(),
|
|
342
|
-
lastWeek: emptyTimeFilteredStats(),
|
|
343
|
-
allTime: emptyTimeFilteredStats(),
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number, lastWeekStartMs: number): TabName[] {
|
|
348
|
-
const periods: TabName[] = ["allTime"];
|
|
349
|
-
if (timestamp >= todayMs) periods.push("today");
|
|
350
|
-
if (timestamp >= weekStartMs) {
|
|
351
|
-
periods.push("thisWeek");
|
|
352
|
-
} else if (timestamp >= lastWeekStartMs) {
|
|
353
|
-
periods.push("lastWeek");
|
|
354
|
-
}
|
|
355
|
-
return periods;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function addMessagesToUsageData(
|
|
359
|
-
data: UsageData,
|
|
360
|
-
sessionId: string,
|
|
361
|
-
messages: SessionMessage[],
|
|
362
|
-
todayMs: number,
|
|
363
|
-
weekStartMs: number,
|
|
364
|
-
lastWeekStartMs: number,
|
|
365
|
-
rawByPeriod: Record<TabName, PeriodRawData>,
|
|
366
|
-
globalSessionSpans: Map<string, GlobalSessionSpan>
|
|
367
|
-
): void {
|
|
368
|
-
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
|
|
369
|
-
|
|
370
|
-
for (const msg of messages) {
|
|
371
|
-
// Track real per-session lifetime across every message we see, regardless of
|
|
372
|
-
// which period the message falls into. Used later for the "8h+ session" insight.
|
|
373
|
-
if (msg.timestamp > 0) {
|
|
374
|
-
const span = globalSessionSpans.get(sessionId);
|
|
375
|
-
if (!span) {
|
|
376
|
-
globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
|
|
377
|
-
} else {
|
|
378
|
-
if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
|
|
379
|
-
if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
|
|
384
|
-
const tokens = {
|
|
385
|
-
// Count fresh tokens processed this turn.
|
|
386
|
-
// Include cacheWrite because those prompt tokens were newly written and billed.
|
|
387
|
-
// Exclude cacheRead because repeated cache hits would otherwise dominate totals.
|
|
388
|
-
total: msg.input + msg.output + msg.cacheWrite,
|
|
389
|
-
input: msg.input,
|
|
390
|
-
output: msg.output,
|
|
391
|
-
cacheRead: msg.cacheRead,
|
|
392
|
-
cacheWrite: msg.cacheWrite,
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
for (const period of periods) {
|
|
396
|
-
const stats = data[period];
|
|
397
|
-
|
|
398
|
-
let providerStats = stats.providers.get(msg.provider);
|
|
399
|
-
if (!providerStats) {
|
|
400
|
-
providerStats = emptyProviderStats();
|
|
401
|
-
stats.providers.set(msg.provider, providerStats);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
let modelStats = providerStats.models.get(msg.model);
|
|
405
|
-
if (!modelStats) {
|
|
406
|
-
modelStats = emptyModelStats();
|
|
407
|
-
providerStats.models.set(msg.model, modelStats);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
modelStats.sessions.add(sessionId);
|
|
411
|
-
accumulateStats(modelStats, msg.cost, tokens);
|
|
412
|
-
|
|
413
|
-
providerStats.sessions.add(sessionId);
|
|
414
|
-
accumulateStats(providerStats, msg.cost, tokens);
|
|
415
|
-
|
|
416
|
-
accumulateStats(stats.totals, msg.cost, tokens);
|
|
417
|
-
sessionContributed[period] = true;
|
|
418
|
-
|
|
419
|
-
const raw = rawByPeriod[period];
|
|
420
|
-
raw.messages.push({
|
|
421
|
-
sessionId,
|
|
422
|
-
timestamp: msg.timestamp,
|
|
423
|
-
cost: msg.cost,
|
|
424
|
-
input: msg.input,
|
|
425
|
-
cacheRead: msg.cacheRead,
|
|
426
|
-
cacheWrite: msg.cacheWrite,
|
|
427
|
-
});
|
|
428
|
-
raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (sessionContributed.today) data.today.totals.sessions++;
|
|
433
|
-
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
434
|
-
if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
|
|
435
|
-
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
|
|
439
|
-
const startOfToday = new Date();
|
|
440
|
-
startOfToday.setHours(0, 0, 0, 0);
|
|
441
|
-
const todayMs = startOfToday.getTime();
|
|
442
|
-
|
|
443
|
-
// Start of current week (Monday 00:00)
|
|
444
|
-
const startOfWeek = new Date();
|
|
445
|
-
const dayOfWeek = startOfWeek.getDay(); // 0 = Sunday, 1 = Monday, ...
|
|
446
|
-
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
447
|
-
startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
|
|
448
|
-
startOfWeek.setHours(0, 0, 0, 0);
|
|
449
|
-
const weekStartMs = startOfWeek.getTime();
|
|
450
|
-
|
|
451
|
-
// Start of last week (previous Monday 00:00)
|
|
452
|
-
const startOfLastWeek = new Date(startOfWeek);
|
|
453
|
-
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
|
454
|
-
const lastWeekStartMs = startOfLastWeek.getTime();
|
|
455
|
-
|
|
456
|
-
const data = emptyUsageData();
|
|
457
|
-
const rawByPeriod: Record<TabName, PeriodRawData> = {
|
|
458
|
-
today: emptyPeriodRawData(),
|
|
459
|
-
thisWeek: emptyPeriodRawData(),
|
|
460
|
-
lastWeek: emptyPeriodRawData(),
|
|
461
|
-
allTime: emptyPeriodRawData(),
|
|
462
|
-
};
|
|
463
|
-
const globalSessionSpans = new Map<string, GlobalSessionSpan>();
|
|
464
|
-
|
|
465
|
-
const sessionFiles = await getAllSessionFiles(signal);
|
|
466
|
-
if (signal?.aborted) return null;
|
|
467
|
-
const seenHashes = new Set<string>();
|
|
468
|
-
|
|
469
|
-
for (const filePath of sessionFiles) {
|
|
470
|
-
if (signal?.aborted) return null;
|
|
471
|
-
const parsed = await parseSessionFile(filePath, seenHashes, signal);
|
|
472
|
-
if (signal?.aborted) return null;
|
|
473
|
-
if (!parsed) continue;
|
|
474
|
-
|
|
475
|
-
addMessagesToUsageData(
|
|
476
|
-
data,
|
|
477
|
-
parsed.sessionId,
|
|
478
|
-
parsed.messages,
|
|
479
|
-
todayMs,
|
|
480
|
-
weekStartMs,
|
|
481
|
-
lastWeekStartMs,
|
|
482
|
-
rawByPeriod,
|
|
483
|
-
globalSessionSpans
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Classify sessions that are globally long-running once, then reuse across periods.
|
|
490
|
-
const longSessionIds = new Set<string>();
|
|
491
|
-
for (const [id, span] of globalSessionSpans) {
|
|
492
|
-
if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
for (const period of TAB_ORDER) {
|
|
496
|
-
data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return data;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// =============================================================================
|
|
503
|
-
// Insights
|
|
504
|
-
// =============================================================================
|
|
505
|
-
|
|
506
|
-
const PARALLEL_WINDOW_MS = 2 * 60_000; // exact ±N milliseconds around each message
|
|
507
|
-
const PARALLEL_SESSION_THRESHOLD = 4;
|
|
508
|
-
const LARGE_CONTEXT_THRESHOLD = 150_000;
|
|
509
|
-
const LARGE_CACHE_MISS_THRESHOLD = 100_000;
|
|
510
|
-
const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
|
|
511
|
-
const TOP_SESSION_COUNT = 5;
|
|
512
|
-
const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
|
|
513
|
-
const MIN_PERCENT_TO_SHOW = 1;
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Insights are weighted by recorded API cost. Periods with zero total cost produce
|
|
517
|
-
* an empty `insights` list — the UI renders a distinct empty-state for that case.
|
|
518
|
-
* Long-running-session classification is passed in from a global pass so that a
|
|
519
|
-
* session's real lifetime is used rather than the slice visible inside this period.
|
|
520
|
-
*/
|
|
521
|
-
function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
|
|
522
|
-
if (raw.messages.length === 0) {
|
|
523
|
-
return { insights: [] };
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
|
|
527
|
-
if (total <= 0) {
|
|
528
|
-
return { insights: [] };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const candidates: Insight[] = [];
|
|
532
|
-
|
|
533
|
-
// 1. Parallel sessions — ≥ N unique sessions active within an exact ±W ms window.
|
|
534
|
-
const parallelWeight = computeParallelCostWeight(raw.messages);
|
|
535
|
-
if (parallelWeight !== null) {
|
|
536
|
-
candidates.push({
|
|
537
|
-
percent: (parallelWeight / total) * 100,
|
|
538
|
-
headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
|
|
539
|
-
advice:
|
|
540
|
-
"All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// 2. Large context — input + cacheRead + cacheWrite > threshold.
|
|
545
|
-
const largeContextWeight = raw.messages
|
|
546
|
-
.filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD)
|
|
547
|
-
.reduce((sum, m) => sum + m.cost, 0);
|
|
548
|
-
candidates.push({
|
|
549
|
-
percent: (largeContextWeight / total) * 100,
|
|
550
|
-
headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
|
|
551
|
-
advice:
|
|
552
|
-
"Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks.",
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
// 3. Large uncached prompt — fresh (non-cached) input > threshold, per the v0.2.0 formula.
|
|
556
|
-
const uncachedWeight = raw.messages
|
|
557
|
-
.filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD)
|
|
558
|
-
.reduce((sum, m) => sum + m.cost, 0);
|
|
559
|
-
candidates.push({
|
|
560
|
-
percent: (uncachedWeight / total) * 100,
|
|
561
|
-
headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
|
|
562
|
-
advice:
|
|
563
|
-
"Uncached input is expensive, and often happens when sending a message to a session that has gone idle. /compact before stepping away keeps the cold-start small.",
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
// 4. Long-running sessions — classification comes from the global pass so we use
|
|
567
|
-
// true session lifetime, not just the span visible inside this period slice.
|
|
568
|
-
const longWeight = raw.messages
|
|
569
|
-
.filter((m) => longSessionIds.has(m.sessionId))
|
|
570
|
-
.reduce((sum, m) => sum + m.cost, 0);
|
|
571
|
-
if (longWeight > 0) {
|
|
572
|
-
candidates.push({
|
|
573
|
-
percent: (longWeight / total) * 100,
|
|
574
|
-
headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3_600_000}+ hours`,
|
|
575
|
-
advice:
|
|
576
|
-
"These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// 5. Top-N session concentration.
|
|
581
|
-
if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
|
|
582
|
-
const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
|
|
583
|
-
const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
|
|
584
|
-
const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
|
|
585
|
-
candidates.push({
|
|
586
|
-
percent: (topWeight / total) * 100,
|
|
587
|
-
headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
|
|
588
|
-
advice:
|
|
589
|
-
"A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
|
|
594
|
-
return { insights };
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Two-pointer sweep of messages sorted by timestamp. For each message, count the
|
|
599
|
-
* number of distinct session IDs whose messages fall within an exact ± window.
|
|
600
|
-
* Returns the total cost attributable to moments when ≥ threshold sessions were
|
|
601
|
-
* active, or null if the period has too few sessions/messages to call it.
|
|
602
|
-
*
|
|
603
|
-
* Messages with missing/invalid timestamps (parsed as 0) are filtered out first —
|
|
604
|
-
* otherwise they would collapse into a single synthetic instant and inflate the
|
|
605
|
-
* parallel count on older or incomplete logs.
|
|
606
|
-
*/
|
|
607
|
-
function computeParallelCostWeight(messages: RawMessage[]): number | null {
|
|
608
|
-
const timed = messages.filter((m) => m.timestamp > 0);
|
|
609
|
-
if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT) return null;
|
|
610
|
-
const distinctSessions = new Set(timed.map((m) => m.sessionId));
|
|
611
|
-
if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD) return null;
|
|
612
|
-
|
|
613
|
-
const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
614
|
-
const sidCount = new Map<string, number>();
|
|
615
|
-
let uniqueCount = 0;
|
|
616
|
-
let left = 0;
|
|
617
|
-
let right = 0;
|
|
618
|
-
let parallelCost = 0;
|
|
619
|
-
|
|
620
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
621
|
-
const current = sorted[i]!;
|
|
622
|
-
const high = current.timestamp + PARALLEL_WINDOW_MS;
|
|
623
|
-
const low = current.timestamp - PARALLEL_WINDOW_MS;
|
|
624
|
-
|
|
625
|
-
while (right < sorted.length && sorted[right]!.timestamp <= high) {
|
|
626
|
-
const sid = sorted[right]!.sessionId;
|
|
627
|
-
const next = (sidCount.get(sid) ?? 0) + 1;
|
|
628
|
-
sidCount.set(sid, next);
|
|
629
|
-
if (next === 1) uniqueCount++;
|
|
630
|
-
right++;
|
|
631
|
-
}
|
|
632
|
-
while (left < right && sorted[left]!.timestamp < low) {
|
|
633
|
-
const sid = sorted[left]!.sessionId;
|
|
634
|
-
const next = (sidCount.get(sid) ?? 0) - 1;
|
|
635
|
-
if (next === 0) {
|
|
636
|
-
sidCount.delete(sid);
|
|
637
|
-
uniqueCount--;
|
|
638
|
-
} else {
|
|
639
|
-
sidCount.set(sid, next);
|
|
640
|
-
}
|
|
641
|
-
left++;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
return parallelCost;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function formatThresholdTokens(n: number): string {
|
|
651
|
-
if (n >= 1_000_000) return `${n / 1_000_000}M`;
|
|
652
|
-
if (n >= 1_000) return `${n / 1_000}k`;
|
|
653
|
-
return String(n);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
function formatInsightPercent(p: number): string {
|
|
657
|
-
if (p >= 10) return `${Math.round(p)}%`;
|
|
658
|
-
return `${Math.round(p * 10) / 10}%`;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
125
|
// =============================================================================
|
|
662
126
|
// Formatting Helpers
|
|
663
127
|
// =============================================================================
|
|
@@ -685,6 +149,37 @@ function formatNumber(n: number): string {
|
|
|
685
149
|
return n.toLocaleString();
|
|
686
150
|
}
|
|
687
151
|
|
|
152
|
+
// Compact axis/legend formatters for the graph view.
|
|
153
|
+
function formatAxisCost(v: number): string {
|
|
154
|
+
if (v === 0) return "$0";
|
|
155
|
+
if (v < 1) return `$${v.toFixed(2)}`;
|
|
156
|
+
if (v < 100) return `$${v.toFixed(1)}`;
|
|
157
|
+
if (v < 10_000) return `$${Math.round(v)}`;
|
|
158
|
+
if (v < 1_000_000) return `$${(v / 1000).toFixed(1)}k`;
|
|
159
|
+
return `$${(v / 1_000_000).toFixed(2)}M`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function formatAxisCount(v: number): string {
|
|
163
|
+
if (v === 0) return "0";
|
|
164
|
+
if (v < 1000) return String(Math.round(v));
|
|
165
|
+
if (v < 1_000_000) return `${(v / 1000).toFixed(v < 10_000 ? 1 : 0)}k`;
|
|
166
|
+
if (v < 1_000_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
167
|
+
return `${(v / 1_000_000_000).toFixed(1)}B`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Bright ANSI palette for graph series (Total uses index 0).
|
|
171
|
+
const SERIES_COLORS = ["\x1b[97m", "\x1b[96m", "\x1b[95m", "\x1b[93m", "\x1b[92m", "\x1b[94m", "\x1b[91m", "\x1b[90m"];
|
|
172
|
+
const COLOR_RESET = "\x1b[39m";
|
|
173
|
+
|
|
174
|
+
function seriesColor(index: number): string {
|
|
175
|
+
return SERIES_COLORS[index % SERIES_COLORS.length]!;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatInsightPercent(p: number): string {
|
|
179
|
+
if (p >= 10) return `${Math.round(p)}%`;
|
|
180
|
+
return `${Math.round(p * 10) / 10}%`;
|
|
181
|
+
}
|
|
182
|
+
|
|
688
183
|
function padLeft(s: string, len: number): string {
|
|
689
184
|
const vis = visibleWidth(s);
|
|
690
185
|
if (vis >= len) return s;
|
|
@@ -753,11 +248,10 @@ const TAB_LABELS: Record<TabName, string> = {
|
|
|
753
248
|
today: "Today",
|
|
754
249
|
thisWeek: "This Week",
|
|
755
250
|
lastWeek: "Last Week",
|
|
251
|
+
last30Days: "Last 30 Days",
|
|
756
252
|
allTime: "All Time",
|
|
757
253
|
};
|
|
758
254
|
|
|
759
|
-
const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
|
|
760
|
-
|
|
761
255
|
class UsageComponent {
|
|
762
256
|
private activeTab: TabName = "allTime";
|
|
763
257
|
private viewMode: ViewMode = "table";
|
|
@@ -769,6 +263,13 @@ class UsageComponent {
|
|
|
769
263
|
private requestRender: () => void;
|
|
770
264
|
private done: () => void;
|
|
771
265
|
|
|
266
|
+
// Graph explorer state.
|
|
267
|
+
private graphMetric: GraphMetric = "cost";
|
|
268
|
+
private graphGroupBy: GraphGroupBy = "provider";
|
|
269
|
+
private graphCumulative = true;
|
|
270
|
+
private graphHidden = new Set<string>();
|
|
271
|
+
private graphLegendIndex = 0;
|
|
272
|
+
|
|
772
273
|
constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
|
|
773
274
|
this.theme = theme;
|
|
774
275
|
this.requestRender = requestRender;
|
|
@@ -792,11 +293,16 @@ class UsageComponent {
|
|
|
792
293
|
}
|
|
793
294
|
|
|
794
295
|
if (matchesKey(data, "v")) {
|
|
795
|
-
|
|
296
|
+
const idx = VIEW_CYCLE.indexOf(this.viewMode);
|
|
297
|
+
this.viewMode = VIEW_CYCLE[(idx + 1) % VIEW_CYCLE.length]!;
|
|
796
298
|
this.requestRender();
|
|
797
299
|
return;
|
|
798
300
|
}
|
|
799
301
|
|
|
302
|
+
if (this.viewMode === "graph" && this.handleGraphInput(data)) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
800
306
|
if (matchesKey(data, "tab") || matchesKey(data, "right")) {
|
|
801
307
|
const idx = TAB_ORDER.indexOf(this.activeTab);
|
|
802
308
|
this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
|
|
@@ -807,6 +313,8 @@ class UsageComponent {
|
|
|
807
313
|
this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
|
|
808
314
|
this.updateProviderOrder();
|
|
809
315
|
this.requestRender();
|
|
316
|
+
} else if (this.viewMode === "graph") {
|
|
317
|
+
// Graph-specific keys were handled above; swallow table-only keys.
|
|
810
318
|
} else if (this.viewMode === "table" && matchesKey(data, "up")) {
|
|
811
319
|
if (this.selectedIndex > 0) {
|
|
812
320
|
this.selectedIndex--;
|
|
@@ -834,7 +342,57 @@ class UsageComponent {
|
|
|
834
342
|
// Render Methods
|
|
835
343
|
// -------------------------------------------------------------------------
|
|
836
344
|
|
|
345
|
+
private handleGraphInput(data: string): boolean {
|
|
346
|
+
if (matchesKey(data, "m")) {
|
|
347
|
+
const idx = METRIC_ORDER.indexOf(this.graphMetric);
|
|
348
|
+
this.graphMetric = METRIC_ORDER[(idx + 1) % METRIC_ORDER.length]!;
|
|
349
|
+
} else if (matchesKey(data, "g")) {
|
|
350
|
+
const idx = GROUP_ORDER.indexOf(this.graphGroupBy);
|
|
351
|
+
this.graphGroupBy = GROUP_ORDER[(idx + 1) % GROUP_ORDER.length]!;
|
|
352
|
+
this.graphHidden.clear();
|
|
353
|
+
this.graphLegendIndex = 0;
|
|
354
|
+
} else if (matchesKey(data, "c")) {
|
|
355
|
+
this.graphCumulative = !this.graphCumulative;
|
|
356
|
+
} else if (matchesKey(data, "a")) {
|
|
357
|
+
this.graphHidden.clear();
|
|
358
|
+
} else if (matchesKey(data, "up")) {
|
|
359
|
+
this.graphLegendIndex = Math.max(0, this.graphLegendIndex - 1);
|
|
360
|
+
} else if (matchesKey(data, "down")) {
|
|
361
|
+
const count = this.buildGraphModelForView().series.length;
|
|
362
|
+
this.graphLegendIndex = Math.min(Math.max(count - 1, 0), this.graphLegendIndex + 1);
|
|
363
|
+
} else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
|
|
364
|
+
const model = this.buildGraphModelForView();
|
|
365
|
+
const target = model.series[this.graphLegendIndex];
|
|
366
|
+
if (target) {
|
|
367
|
+
if (this.graphHidden.has(target.key)) this.graphHidden.delete(target.key);
|
|
368
|
+
else this.graphHidden.add(target.key);
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
this.requestRender();
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private buildGraphModelForView(): GraphModel {
|
|
378
|
+
return buildGraphModel(this.data.hourly, {
|
|
379
|
+
period: this.activeTab,
|
|
380
|
+
metric: this.graphMetric,
|
|
381
|
+
groupBy: this.graphGroupBy,
|
|
382
|
+
cumulative: this.graphCumulative,
|
|
383
|
+
hidden: this.graphHidden,
|
|
384
|
+
bounds: this.data.bounds,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
837
388
|
render(width: number): string[] {
|
|
389
|
+
if (this.viewMode === "graph") {
|
|
390
|
+
return clampLines(
|
|
391
|
+
[...this.renderTitle(), ...this.renderTabs(width, getTableLayout(width)), ...this.renderGraph(width), ...this.renderHelp(width)],
|
|
392
|
+
width
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
838
396
|
if (this.viewMode === "insights") {
|
|
839
397
|
return clampLines(
|
|
840
398
|
[
|
|
@@ -864,10 +422,67 @@ class UsageComponent {
|
|
|
864
422
|
|
|
865
423
|
private renderTitle(): string[] {
|
|
866
424
|
const th = this.theme;
|
|
867
|
-
const label =
|
|
425
|
+
const label =
|
|
426
|
+
this.viewMode === "insights" ? "Usage Insights" : this.viewMode === "graph" ? "Usage Graphs" : "Usage Statistics";
|
|
868
427
|
return [th.fg("accent", th.bold(label)), ""];
|
|
869
428
|
}
|
|
870
429
|
|
|
430
|
+
private renderGraph(width: number): string[] {
|
|
431
|
+
const th = this.theme;
|
|
432
|
+
const model = this.buildGraphModelForView();
|
|
433
|
+
const lines: string[] = [];
|
|
434
|
+
|
|
435
|
+
const modeLabel = `${this.graphCumulative ? "Cumulative" : "Per bucket"} ${METRIC_LABELS[this.graphMetric]} · ${GROUP_LABELS[this.graphGroupBy]}`;
|
|
436
|
+
lines.push(th.fg("muted", modeLabel));
|
|
437
|
+
lines.push("");
|
|
438
|
+
|
|
439
|
+
if (model.groupedTotal === 0 && model.series.every((s) => s.total === 0)) {
|
|
440
|
+
lines.push(th.fg("dim", " No usage data for this period"));
|
|
441
|
+
lines.push("");
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const formatValue = this.graphMetric === "cost" ? formatAxisCost : formatAxisCount;
|
|
446
|
+
const spanMs = model.domainEndMs - model.domainStartMs;
|
|
447
|
+
const formatTime = (ms: number): string => {
|
|
448
|
+
const d = new Date(ms);
|
|
449
|
+
if (spanMs <= 26 * 3_600_000) {
|
|
450
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
451
|
+
}
|
|
452
|
+
return d.toLocaleDateString(undefined, { day: "numeric", month: "short" });
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const chartHeight = 12;
|
|
456
|
+
const chart = renderChart(model, {
|
|
457
|
+
width: Math.max(Math.min(width, 110), 30),
|
|
458
|
+
height: chartHeight,
|
|
459
|
+
formatValue,
|
|
460
|
+
formatTime,
|
|
461
|
+
colorize: (seriesIndex, text) => {
|
|
462
|
+
if (seriesIndex < 0) return th.fg("dim", text);
|
|
463
|
+
return seriesColor(seriesIndex) + text + COLOR_RESET;
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
lines.push(...chart);
|
|
467
|
+
lines.push("");
|
|
468
|
+
|
|
469
|
+
// Legend with selection cursor and hide/show state.
|
|
470
|
+
for (let i = 0; i < model.series.length; i++) {
|
|
471
|
+
const s = model.series[i]!;
|
|
472
|
+
const cursor = i === this.graphLegendIndex ? th.fg("accent", "▸ ") : " ";
|
|
473
|
+
const marker = s.hidden ? th.fg("dim", "○") : seriesColor(i) + "●" + COLOR_RESET;
|
|
474
|
+
const value = this.graphMetric === "cost" ? formatAxisCost(s.total) : formatAxisCount(s.total);
|
|
475
|
+
const pct =
|
|
476
|
+
s.key !== TOTAL_SERIES_KEY && model.groupedTotal > 0
|
|
477
|
+
? ` ${th.fg("dim", `${Math.round((s.total / model.groupedTotal) * 100)}%`)}`
|
|
478
|
+
: "";
|
|
479
|
+
const label = s.hidden ? th.fg("dim", s.label) : s.key === TOTAL_SERIES_KEY ? th.bold(s.label) : s.label;
|
|
480
|
+
lines.push(`${cursor}${marker} ${padRight(label, 24)} ${padLeft(value, 8)}${pct}`);
|
|
481
|
+
}
|
|
482
|
+
lines.push("");
|
|
483
|
+
return lines;
|
|
484
|
+
}
|
|
485
|
+
|
|
871
486
|
private renderInsights(width: number): string[] {
|
|
872
487
|
const th = this.theme;
|
|
873
488
|
const stats = this.data[this.activeTab];
|
|
@@ -1038,11 +653,19 @@ class UsageComponent {
|
|
|
1038
653
|
|
|
1039
654
|
private renderHelp(width: number): string[] {
|
|
1040
655
|
const variants =
|
|
1041
|
-
this.viewMode === "
|
|
656
|
+
this.viewMode === "graph"
|
|
657
|
+
? [
|
|
658
|
+
"[Tab/←→] period [m] metric [g] group [c] cumulative [↑↓/Enter] filter [a] all [v] view [q] close",
|
|
659
|
+
"[Tab] period [m] metric [g] group [c] cumul [↑↓/Enter] filter [v] view [q] close",
|
|
660
|
+
"[m] metric [g] group [c] cumul [↑↓] filter [q] close",
|
|
661
|
+
"[m] [g] [c] [↑↓] [q]",
|
|
662
|
+
"[q] close",
|
|
663
|
+
]
|
|
664
|
+
: this.viewMode === "insights"
|
|
1042
665
|
? [
|
|
1043
|
-
"[Tab/←→] period [v]
|
|
1044
|
-
"[Tab] period [v]
|
|
1045
|
-
"[v]
|
|
666
|
+
"[Tab/←→] period [v] graph view [q] close",
|
|
667
|
+
"[Tab] period [v] graphs [q] close",
|
|
668
|
+
"[v] graphs [q] close",
|
|
1046
669
|
"[q] close",
|
|
1047
670
|
]
|
|
1048
671
|
: [
|
|
@@ -1090,7 +713,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1090
713
|
|
|
1091
714
|
loader.onAbort = () => finish(null);
|
|
1092
715
|
|
|
1093
|
-
collectUsageData(loader.signal)
|
|
716
|
+
collectUsageData({ signal: loader.signal })
|
|
1094
717
|
.then(finish)
|
|
1095
718
|
.catch(() => finish(null));
|
|
1096
719
|
|