pi-tokenburn 0.5.8 → 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.
Files changed (32) hide show
  1. package/README.md +59 -4
  2. package/extensions/command/completions.ts +80 -0
  3. package/extensions/command/handlers.ts +22 -0
  4. package/extensions/command/host.ts +25 -0
  5. package/extensions/command/parse.ts +173 -0
  6. package/extensions/core/aggregate.ts +155 -0
  7. package/extensions/core/config.ts +157 -0
  8. package/extensions/core/format.ts +12 -0
  9. package/extensions/core/paint.ts +35 -0
  10. package/extensions/core/parser.ts +261 -0
  11. package/extensions/core/select.ts +105 -0
  12. package/extensions/core/time.ts +25 -0
  13. package/extensions/core/types.ts +43 -0
  14. package/extensions/features/breakdown/commands.ts +32 -0
  15. package/extensions/features/breakdown/query.ts +138 -0
  16. package/extensions/features/breakdown/table.ts +100 -0
  17. package/extensions/features/budgets/budgets.ts +105 -0
  18. package/extensions/features/budgets/commands.ts +20 -0
  19. package/extensions/features/export/commands.ts +19 -0
  20. package/extensions/features/export/export.ts +61 -0
  21. package/extensions/features/filter/commands.ts +18 -0
  22. package/extensions/features/live/coalescer.ts +71 -0
  23. package/extensions/features/pricing/commands.ts +30 -0
  24. package/extensions/features/pricing/report.ts +59 -0
  25. package/extensions/features/report/chart.ts +96 -0
  26. package/extensions/features/report/commands.ts +33 -0
  27. package/extensions/features/report/report.ts +47 -0
  28. package/extensions/features/status-line/commands.ts +78 -0
  29. package/extensions/features/status-line/render.ts +43 -0
  30. package/extensions/features/status-line/status.ts +35 -0
  31. package/extensions/tokenburn.ts +78 -1030
  32. package/package.json +5 -3
@@ -1,751 +1,67 @@
1
1
  /**
2
- * pi-tokenburn — token usage monitor with budget alerts in your Pi session.
2
+ * pi-tokenburn: token usage monitor with budget alerts in your Pi session.
3
3
  *
4
- * Inspired by the tokenburn Rust project (https://github.com/sorinirimies/tokenburn).
5
4
  * Reads local Pi session JSONL logs directly (0 network, 0 prompt tokens).
5
+ * This file is wiring only: it connects pi's events and the /tokenburn command to the pieces below.
6
6
  *
7
- * Features:
8
- * - Status bar indicator: `🔥 1.2M (today) · $0.45`
9
- * - Optional widget window with Day / Week / Month / Total breakdown
10
- * - Configurable limits with visual warning when budget threshold is exceeded
11
- * - Commands:
12
- * /tokenburn show token burn report
13
- * /tokenburn window toggle permanent tokenburn widget in editor
14
- * /tokenburn budget <d|w|m> <tokens|cost> set budget
7
+ * core/ types, config, session-log parser (incremental cache), aggregation, filters + prices
8
+ * command/ parse + completions (pure), the handler table, the Host handlers receive
9
+ * features/ one folder per feature, each with its logic and its commands:
10
+ * status-line footer text, optional panel, burn rate / cache hit / savings, status period
11
+ * report the /tokenburn summary and the column charts
12
+ * budgets budgets, projection ("runs out Thu 14:05"), one-time warnings
13
+ * breakdown tables per model / provider / project / session, current session
14
+ * filter limit everything to a provider / model / project
15
+ * pricing cost sources, price table
16
+ * export csv / json
17
+ * live refresh scheduling
15
18
  */
16
-
17
- import { readdirSync, statSync } from "node:fs";
18
- import { open, type FileHandle } from "node:fs/promises";
19
- import { homedir } from "node:os";
20
- import { join } from "node:path";
21
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
- import { readFile, writeFile, mkdir } from "node:fs/promises";
23
-
24
- export interface TokenUsage {
25
- input: number;
26
- output: number;
27
- cacheRead: number;
28
- cacheWrite: number;
29
- cost: number;
30
- }
31
-
32
- export interface PeriodStats {
33
- /** input + output (+ cache read/write when includeCache). */
34
- tokens: number;
35
- input: number;
36
- output: number;
37
- cacheRead: number;
38
- cacheWrite: number;
39
- cost: number;
40
- sessions: number;
41
- }
42
-
43
- export interface TokenBurnReport {
44
- today: PeriodStats;
45
- week: PeriodStats;
46
- month: PeriodStats;
47
- year: PeriodStats;
48
- total: PeriodStats;
49
- }
50
-
51
- export const STATUS_WINDOWS = ["today", "week", "month", "year", "total"] as const;
52
- export type Window = (typeof STATUS_WINDOWS)[number];
53
-
54
- export interface TokenBurnConfig {
55
- /** Master switch. When false nothing runs in the background and the status line / panel are cleared. */
56
- enabled: boolean;
57
- /** Refresh after each assistant message (coalesced), not only when a turn ends. */
58
- liveRefresh: boolean;
59
- /** Trailing delay for live refreshes, in ms (250 to 60000). */
60
- refreshMs: number;
61
- /** Count cache read/write tokens in totals (matches the tokenburn CLI). */
62
- includeCache: boolean;
63
- /** Color the report chart (set false if your terminal garbles ANSI in notifications). */
64
- chartColor: boolean;
65
- showStatus: boolean;
66
- showWidget: boolean;
67
- statusWindow: Window;
68
- budgets: {
69
- dayTokens?: number;
70
- weekTokens?: number;
71
- monthTokens?: number;
72
- dayCost?: number;
73
- weekCost?: number;
74
- monthCost?: number;
75
- };
76
- }
77
-
78
- const DEFAULT_CONFIG: TokenBurnConfig = {
79
- enabled: true,
80
- liveRefresh: true,
81
- refreshMs: 750,
82
- includeCache: true,
83
- chartColor: true,
84
- showStatus: true,
85
- showWidget: false,
86
- statusWindow: "today",
87
- budgets: {},
88
- };
89
-
90
- /** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
91
- export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
92
- if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
93
- if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
94
- return join(home, ".pi", "agent");
95
- }
96
-
97
- /** Pi session logs: TOKENBURN_PI_SESSIONS, PI_CODING_AGENT_SESSION_DIR, then <agent dir>/sessions. */
98
- export function resolveSessionsDir(env: Record<string, string | undefined>, home: string): string {
99
- if (env.TOKENBURN_PI_SESSIONS) return env.TOKENBURN_PI_SESSIONS;
100
- if (env.PI_CODING_AGENT_SESSION_DIR) return env.PI_CODING_AGENT_SESSION_DIR;
101
- return join(resolveAgentDir(env, home), "sessions");
102
- }
103
-
104
- const getConfigDir = () => resolveAgentDir(process.env, homedir());
105
-
106
- const getSessionsDir = () => resolveSessionsDir(process.env, homedir());
107
-
108
- export async function loadConfig(): Promise<TokenBurnConfig> {
109
- try {
110
- const raw = await readFile(join(getConfigDir(), "tokenburn.json"), "utf8");
111
- const parsed = JSON.parse(raw);
112
- return {
113
- enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
114
- liveRefresh: typeof parsed.liveRefresh === "boolean" ? parsed.liveRefresh : DEFAULT_CONFIG.liveRefresh,
115
- refreshMs: sanitizeRefreshMs(parsed.refreshMs),
116
- includeCache: typeof parsed.includeCache === "boolean" ? parsed.includeCache : DEFAULT_CONFIG.includeCache,
117
- chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
118
- showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
119
- showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
120
- statusWindow: (STATUS_WINDOWS as readonly string[]).includes(parsed.statusWindow)
121
- ? parsed.statusWindow
122
- : DEFAULT_CONFIG.statusWindow,
123
- budgets: sanitizeBudgets(parsed.budgets),
124
- };
125
- } catch {
126
- return freshConfig();
127
- }
128
- }
129
-
130
- /** Live-refresh delay: a finite number clamped to 250..60000 ms, else the default. */
131
- export function sanitizeRefreshMs(value: unknown): number {
132
- if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CONFIG.refreshMs;
133
- return Math.min(60_000, Math.max(250, Math.round(value)));
134
- }
135
-
136
- const BUDGET_KEYS = ["dayTokens", "weekTokens", "monthTokens", "dayCost", "weekCost", "monthCost"] as const;
137
-
138
- /** Keep only known budget keys holding positive, finite numbers (the file is hand-editable). */
139
- export function sanitizeBudgets(value: unknown): TokenBurnConfig["budgets"] {
140
- const out: TokenBurnConfig["budgets"] = {};
141
- if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
142
- for (const key of BUDGET_KEYS) {
143
- const n = (value as Record<string, unknown>)[key];
144
- if (typeof n === "number" && Number.isFinite(n) && n > 0) out[key] = n;
145
- }
146
- return out;
147
- }
148
-
149
- /** A config that shares no mutable state with DEFAULT_CONFIG. */
150
- export function freshConfig(): TokenBurnConfig {
151
- return { ...DEFAULT_CONFIG, budgets: {} };
152
- }
153
-
154
- export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
155
- await mkdir(getConfigDir(), { recursive: true });
156
- await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
157
- }
158
-
159
- export function formatNum(n: number): string {
160
- if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B";
161
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
162
- if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
163
- return n.toString();
164
- }
165
-
166
- export function formatCost(c: number): string {
167
- return `$${c.toFixed(2)}`;
168
- }
169
-
170
- interface Turn {
171
- ts: number;
172
- input: number;
173
- output: number;
174
- cacheRead: number;
175
- cacheWrite: number;
176
- cost: number;
177
- }
178
-
179
- /** What was read from disk, for tests and tuning: refreshes must cost O(new bytes). */
180
- export const stats = { bytesRead: 0, fullParses: 0, incrementalParses: 0 };
181
-
182
- export function resetStats(): void {
183
- stats.bytesRead = 0;
184
- stats.fullParses = 0;
185
- stats.incrementalParses = 0;
186
- }
187
-
188
- /**
189
- * Per-session-file parse state. Pi session logs are append-only JSONL, so after the first
190
- * parse only the bytes added since then are read. The state is dropped (full reparse) when
191
- * the file shrank, was rewritten in place, or its head / last-parsed bytes no longer match.
192
- */
193
- interface FileEntry {
194
- size: number;
195
- mtimeMs: number;
196
- /** End of the last COMPLETE (newline-terminated) line that was parsed. */
197
- offset: number;
198
- turns: Turn[];
199
- /** Turn from an unterminated final line; counted now, re-parsed once the line completes. */
200
- tail: Turn[];
201
- /** First bytes of the file, to notice a rewrite. */
202
- head: Buffer;
203
- /** The bytes just before `offset`, same purpose. */
204
- guard: Buffer;
205
- }
206
-
207
- const FILE_CACHE = new Map<string, FileEntry>();
208
- const GUARD_BYTES = 64;
209
- const HEAD_BYTES = 256;
210
- const CHUNK_BYTES = 1 << 20;
211
-
212
- /** A Turn from one JSONL line, or undefined when it carries no usage. */
213
- function turnFromLine(line: Buffer): Turn | undefined {
214
- if (!line.includes('"usage"')) return undefined; // most lines: skip without a JSON parse
215
- try {
216
- const obj = JSON.parse(line.toString("utf8"));
217
- const usage = obj.message?.usage;
218
- if (!obj.timestamp || !usage) return undefined;
219
- const ts = new Date(obj.timestamp).getTime();
220
- if (!Number.isFinite(ts)) return undefined;
221
- return {
222
- ts,
223
- input: Number(usage.input) || 0,
224
- output: Number(usage.output) || 0,
225
- cacheRead: Number(usage.cacheRead) || 0,
226
- cacheWrite: Number(usage.cacheWrite) || 0,
227
- cost: Number(usage.cost?.total) || 0,
228
- };
229
- } catch {
230
- return undefined;
231
- }
232
- }
233
-
234
- async function readAt(fd: FileHandle, buf: Buffer, position: number): Promise<number> {
235
- const { bytesRead } = await fd.read(buf, 0, buf.length, position);
236
- stats.bytesRead += bytesRead;
237
- return bytesRead;
238
- }
239
-
240
- /** Parse complete lines in [start, end). Returns their turns, where they stop, and the unterminated rest. */
241
- async function scan(
242
- fd: FileHandle,
243
- start: number,
244
- end: number,
245
- chunkSize: number,
246
- ): Promise<{ turns: Turn[]; consumed: number; rest: Buffer }> {
247
- const turns: Turn[] = [];
248
- const buf = Buffer.allocUnsafe(Math.min(chunkSize, Math.max(1, end - start)));
249
- let pos = start;
250
- let consumed = start;
251
- let carry: Buffer = Buffer.alloc(0);
252
- while (pos < end) {
253
- const want = Math.min(buf.length, end - pos);
254
- const n = await readAt(fd, want === buf.length ? buf : buf.subarray(0, want), pos);
255
- if (n === 0) break;
256
- const base = pos - carry.length;
257
- const data = carry.length > 0 ? Buffer.concat([carry, buf.subarray(0, n)]) : buf.subarray(0, n);
258
- let lineStart = 0;
259
- for (let nl = data.indexOf(10, lineStart); nl !== -1; nl = data.indexOf(10, lineStart)) {
260
- const t = turnFromLine(data.subarray(lineStart, nl));
261
- if (t) turns.push(t);
262
- lineStart = nl + 1;
263
- }
264
- if (lineStart > 0) consumed = base + lineStart;
265
- carry = Buffer.from(data.subarray(lineStart)); // copy: `buf` is reused
266
- pos += n;
267
- }
268
- return { turns, consumed, rest: carry };
269
- }
270
-
271
- async function bytesAt(fd: FileHandle, position: number, length: number): Promise<Buffer> {
272
- if (length <= 0) return Buffer.alloc(0);
273
- const buf = Buffer.alloc(length);
274
- const n = await readAt(fd, buf, position);
275
- return buf.subarray(0, n);
276
- }
277
-
278
- /** Is the prefix we parsed earlier still what is on disk? */
279
- async function stillAppendOnly(fd: FileHandle, prev: FileEntry): Promise<boolean> {
280
- const head = await bytesAt(fd, 0, prev.head.length);
281
- if (!head.equals(prev.head)) return false;
282
- const guard = await bytesAt(fd, prev.offset - prev.guard.length, prev.guard.length);
283
- return guard.equals(prev.guard);
284
- }
285
-
286
- async function refreshEntry(
287
- file: string,
288
- st: { size: number; mtimeMs: number },
289
- prev: FileEntry | undefined,
290
- chunkSize: number,
291
- ): Promise<FileEntry | undefined> {
292
- let fd: FileHandle;
293
- try {
294
- fd = await open(file, "r");
295
- } catch {
296
- return undefined;
297
- }
298
- try {
299
- const grew = prev !== undefined && st.size > prev.size;
300
- const incremental = grew && (await stillAppendOnly(fd, prev!));
301
- const start = incremental ? prev!.offset : 0;
302
- const turns = incremental ? prev!.turns : [];
303
- if (incremental) stats.incrementalParses++;
304
- else stats.fullParses++;
305
-
306
- const r = await scan(fd, start, st.size, chunkSize);
307
- for (const t of r.turns) turns.push(t);
308
- const tailTurn = r.rest.length > 0 ? turnFromLine(r.rest) : undefined;
309
- return {
310
- size: st.size,
311
- mtimeMs: st.mtimeMs,
312
- offset: r.consumed,
313
- turns,
314
- tail: tailTurn ? [tailTurn] : [],
315
- head: incremental ? prev!.head : await bytesAt(fd, 0, Math.min(HEAD_BYTES, st.size)),
316
- guard: await bytesAt(fd, Math.max(0, r.consumed - GUARD_BYTES), Math.min(GUARD_BYTES, r.consumed)),
317
- };
318
- } catch {
319
- return undefined;
320
- } finally {
321
- await fd.close();
322
- }
323
- }
324
-
325
- /** Size and mtime of a file, or undefined when it cannot be stat'ed. */
326
- export function fileStat(file: string): { size: number; mtimeMs: number } | undefined {
327
- try {
328
- const st = statSync(file);
329
- return { size: st.size, mtimeMs: st.mtimeMs };
330
- } catch {
331
- return undefined;
332
- }
333
- }
334
-
335
- function listSessionFiles(root: string): string[] {
336
- const files: string[] = [];
337
- const walk = (dir: string) => {
338
- let entries;
339
- try {
340
- entries = readdirSync(dir, { withFileTypes: true });
341
- } catch {
342
- return;
343
- }
344
- for (const e of entries) {
345
- const full = join(dir, e.name);
346
- if (e.isDirectory()) walk(full);
347
- else if (e.isFile() && e.name.endsWith(".jsonl")) files.push(full);
348
- }
349
- };
350
- walk(root);
351
- return files;
352
- }
353
-
354
- const emptyStats = (): PeriodStats => ({
355
- tokens: 0,
356
- input: 0,
357
- output: 0,
358
- cacheRead: 0,
359
- cacheWrite: 0,
360
- cost: 0,
361
- sessions: 0,
362
- });
363
-
364
- /** Bring the per-file cache up to date (reading only what changed) and return every file's turns. */
365
- async function loadAll(sessionsRoot: string, chunkSize = CHUNK_BYTES): Promise<Turn[][]> {
366
- const files = listSessionFiles(sessionsRoot);
367
- const live = new Set(files);
368
- for (const key of FILE_CACHE.keys()) if (!live.has(key)) FILE_CACHE.delete(key);
369
-
370
- const out: Turn[][] = [];
371
- for (const file of files) {
372
- const st = fileStat(file);
373
- if (st === undefined) continue; // vanished between listing and stat
374
- let entry = FILE_CACHE.get(file);
375
- if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
376
- const fresh = await refreshEntry(file, st, entry, chunkSize);
377
- if (fresh) FILE_CACHE.set(file, fresh);
378
- else FILE_CACHE.delete(file);
379
- entry = fresh;
380
- }
381
- if (entry) out.push(entry.tail.length > 0 ? entry.turns.concat(entry.tail) : entry.turns);
382
- }
383
- return out;
384
- }
385
-
386
- /** Test hooks: parse with a tiny chunk size (chunk boundaries) / refresh one file directly. */
387
- export const __loadAllForTests = loadAll;
388
- export const __refreshEntryForTests = refreshEntry;
389
-
390
- const turnTokens = (t: Turn, includeCache: boolean) =>
391
- t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
392
-
393
- export async function collectUsage(
394
- sessionsRoot: string,
395
- now: Date = new Date(),
396
- includeCache = true,
397
- ): Promise<TokenBurnReport> {
398
- const startOfDay = bucketStart(now.getTime(), "day");
399
- const startOfWeek = bucketStart(now.getTime(), "week");
400
- const startOfMonth = bucketStart(now.getTime(), "month");
401
- const startOfYear = bucketStart(now.getTime(), "year");
402
-
403
- const report: TokenBurnReport = {
404
- today: emptyStats(),
405
- week: emptyStats(),
406
- month: emptyStats(),
407
- year: emptyStats(),
408
- total: emptyStats(),
409
- };
410
-
411
- const add = (p: PeriodStats, t: Turn) => {
412
- p.tokens += turnTokens(t, includeCache);
413
- p.input += t.input;
414
- p.output += t.output;
415
- p.cacheRead += t.cacheRead;
416
- p.cacheWrite += t.cacheWrite;
417
- p.cost += t.cost;
418
- };
419
-
420
- for (const turns of await loadAll(sessionsRoot)) {
421
- let maxTs = 0;
422
- for (const t of turns) {
423
- if (t.ts > maxTs) maxTs = t.ts;
424
- add(report.total, t);
425
- if (t.ts >= startOfYear) add(report.year, t);
426
- if (t.ts >= startOfMonth) add(report.month, t);
427
- if (t.ts >= startOfWeek) add(report.week, t);
428
- if (t.ts >= startOfDay) add(report.today, t);
429
- }
430
- if (turns.length > 0) report.total.sessions++;
431
- if (maxTs >= startOfYear) report.year.sessions++;
432
- if (maxTs >= startOfMonth) report.month.sessions++;
433
- if (maxTs >= startOfWeek) report.week.sessions++;
434
- if (maxTs >= startOfDay) report.today.sessions++;
435
- }
436
-
437
- return report;
438
- }
439
-
440
- // ---------------------------------------------------------------------------
441
- // Time series + chart
442
- // ---------------------------------------------------------------------------
443
-
444
- export type Unit = "day" | "week" | "month" | "year";
445
-
446
- /** Local-time start of the day / Monday-week / month containing `ts`. */
447
- export function bucketStart(ts: number, unit: Unit): number {
448
- const d = new Date(ts);
449
- if (unit === "day") return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
450
- if (unit === "week") {
451
- const dow = (d.getDay() + 6) % 7; // Monday = 0
452
- return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
453
- }
454
- if (unit === "year") return new Date(d.getFullYear(), 0, 1).getTime();
455
- return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
456
- }
457
-
458
- const pad2 = (n: number) => String(n).padStart(2, "0");
459
-
460
- export function bucketLabel(start: number, unit: Unit): string {
461
- const d = new Date(start);
462
- if (unit === "year") return String(d.getFullYear());
463
- if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
464
- const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
465
- return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
466
- }
467
-
468
- export interface SeriesPoint {
469
- start: number;
470
- label: string;
471
- tokens: number;
472
- cost: number;
473
- }
474
-
475
- export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6, year: 5 };
476
-
477
- /** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
478
- export async function collectSeries(
479
- sessionsRoot: string,
480
- unit: Unit,
481
- count = DEFAULT_COUNT[unit],
482
- now: Date = new Date(),
483
- includeCache = true,
484
- ): Promise<SeriesPoint[]> {
485
- const starts: number[] = [];
486
- for (let i = count - 1; i >= 0; i--) {
487
- const y = now.getFullYear();
488
- const m = now.getMonth();
489
- const day = now.getDate();
490
- const anchor =
491
- unit === "day"
492
- ? new Date(y, m, day - i)
493
- : unit === "week"
494
- ? new Date(y, m, day - i * 7)
495
- : unit === "year"
496
- ? new Date(y - i, 0, 1)
497
- : new Date(y, m - i, 1);
498
- starts.push(bucketStart(anchor.getTime(), unit));
499
- }
500
- const points = new Map<number, SeriesPoint>(
501
- starts.map((start) => [start, { start, label: bucketLabel(start, unit), tokens: 0, cost: 0 }]),
502
- );
503
- for (const turns of await loadAll(sessionsRoot)) {
504
- for (const t of turns) {
505
- const pt = points.get(bucketStart(t.ts, unit));
506
- if (!pt) continue;
507
- pt.tokens += turnTokens(t, includeCache);
508
- pt.cost += t.cost;
509
- }
510
- }
511
- return starts.map((s) => points.get(s)!);
512
- }
513
-
514
- /**
515
- * Whole history: monthly columns while it spans <= 18 months, yearly after that.
516
- * Always at least 2 columns so the chart has a shape.
517
- */
518
- export async function collectAllSeries(
519
- sessionsRoot: string,
520
- now: Date = new Date(),
521
- includeCache = true,
522
- ): Promise<{ unit: Unit; series: SeriesPoint[] }> {
523
- let first = now.getTime();
524
- for (const turns of await loadAll(sessionsRoot)) for (const t of turns) if (t.ts < first) first = t.ts;
525
- const f = new Date(first);
526
- const months = (now.getFullYear() - f.getFullYear()) * 12 + (now.getMonth() - f.getMonth()) + 1;
527
- if (months <= 18) {
528
- const unit: Unit = "month";
529
- return { unit, series: await collectSeries(sessionsRoot, unit, Math.max(2, months), now, includeCache) };
530
- }
531
- const years = now.getFullYear() - f.getFullYear() + 1;
532
- return { unit: "year", series: await collectSeries(sessionsRoot, "year", years, now, includeCache) };
533
- }
534
-
535
- const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
536
- const SPARK = "▁▂▃▄▅▆▇█";
537
-
538
- /** Horizontal bar of `value/max` over `width` cells, with 1/8-cell precision. */
539
- export function bar(value: number, max: number, width: number): string {
540
- if (max <= 0 || value <= 0) return "";
541
- const eighths = Math.max(1, Math.round((value / max) * width * 8));
542
- const full = Math.floor(eighths / 8);
543
- return "█".repeat(full) + EIGHTHS[eighths % 8];
544
- }
545
-
546
- export function sparkline(values: number[]): string {
547
- const max = Math.max(...values, 0);
548
- if (max <= 0) return SPARK[0].repeat(values.length);
549
- return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
550
- }
551
-
552
- const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" };
553
- const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month", year: "year" };
554
- const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
555
-
556
- export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
557
- const plain: Paint = (_k, t) => t;
558
-
559
- /** Two header rows for a column: [top, bottom]. */
560
- function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
561
- const d = new Date(p.start);
562
- if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
563
- if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
564
- if (unit === "year") return [String(d.getFullYear()), ""];
565
- return [MONTHS[d.getMonth()], String(d.getFullYear())];
566
- }
567
-
568
- /**
569
- * Vertical column chart: y-axis, 1/8-row precision, optional budget line,
570
- * highlighted current bucket and over-budget columns, summary footer.
571
- */
572
- export function renderChart(
573
- series: SeriesPoint[],
574
- unit: Unit,
575
- opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint; title?: string } = {},
576
- ): string[] {
577
- const paint = opts.paint ?? plain;
578
- const height = opts.height ?? 8;
579
- const colW = unit === "day" ? 3 : 6; // bar cells + 1 gap
580
- const barW = colW - 1;
581
-
582
- const peakPoint = series.reduce((m, p) => (p.tokens > m.tokens ? p : m), series[0]);
583
- const dataMax = peakPoint?.tokens ?? 0;
584
- const budgetTokens = opts.budget?.tokens;
585
- const scaleMax = Math.max(dataMax, budgetTokens ?? 0);
586
- const total = series.reduce((a, p) => a + p.tokens, 0);
587
- const totalCost = series.reduce((a, p) => a + p.cost, 0);
588
-
589
- const yLabels = [formatNum(scaleMax), formatNum(scaleMax / 2), "0"];
590
- const yW = Math.max(...yLabels.map((l) => l.length)) + 1;
591
- const labelFor = (row: number) => (row === height ? yLabels[0] : row === Math.ceil(height / 2) ? yLabels[1] : "");
592
- const budgetRow = budgetTokens && scaleMax > 0 ? Math.max(1, Math.ceil((budgetTokens / scaleMax) * height)) : 0;
593
-
594
- const isOver = (p: SeriesPoint) =>
595
- opts.budget ? isOverBudget({ tokens: p.tokens, cost: p.cost } as PeriodStats, opts.budget) : false;
596
- const kindOf = (i: number): "bar" | "now" | "over" =>
597
- isOver(series[i]) ? "over" : i === series.length - 1 ? "now" : "bar";
598
-
599
- const lines: string[] = [];
600
- lines.push(
601
- paint("title", opts.title ?? `${UNIT_TITLE[unit]} tokens`) +
602
- paint("dim", ` · ${opts.title ? "" : "last "}${series.length} ${UNIT_NOUN[unit]}${series.length === 1 ? "" : "s"}`) +
603
- (budgetTokens ? paint("budget", ` ┄ budget ${formatNum(budgetTokens)}`) : ""),
604
- );
605
-
606
- for (let row = height; row >= 1; row--) {
607
- let line = paint("axis", `${labelFor(row).padStart(yW)} │`);
608
- for (let i = 0; i < series.length; i++) {
609
- const h = scaleMax > 0 ? (series[i].tokens / scaleMax) * height : 0;
610
- let cell: string;
611
- if (h >= row) cell = "█";
612
- else if (h > row - 1 && series[i].tokens > 0) cell = SPARK[Math.min(7, Math.max(0, Math.floor((h - (row - 1)) * 8) - 1))];
613
- else if (row === 1 && series[i].tokens > 0) cell = "▁";
614
- else cell = row === budgetRow ? "┄" : " ";
615
- const filled = cell !== " " && cell !== "┄";
616
- const body = cell.repeat(barW);
617
- line += filled ? paint(kindOf(i), body) : cell === "┄" ? paint("budget", body) : body;
618
- line += row === budgetRow ? paint("budget", "┄") : " ";
619
- }
620
- lines.push(line);
621
- }
622
-
623
- lines.push(paint("axis", `${"".padStart(yW)} └${"─".repeat(series.length * colW)}`));
624
- const pad = " ".repeat(yW + 2);
625
- const cols = series.map((p, i) => ({ l: columnLabels(p, unit), now: i === series.length - 1 }));
626
- lines.push(pad + cols.map((c) => c.l[0].padEnd(colW)).join(""));
627
- lines.push(paint("dim", pad + cols.map((c) => c.l[1].padEnd(colW)).join("")));
628
- lines.push(pad + cols.map((c) => (c.now ? paint("now", "▲".padEnd(colW)) : " ".repeat(colW))).join(""));
629
-
630
- const avg = series.length ? total / series.length : 0;
631
- lines.push(
632
- paint(
633
- "dim",
634
- `Σ ${formatNum(total)} (${formatCost(totalCost)}) · avg ${formatNum(avg)}/${UNIT_NOUN[unit]}` +
635
- (dataMax > 0 ? ` · peak ${formatNum(dataMax)} (${bucketLabel(peakPoint.start, unit)})` : ""),
636
- ),
637
- );
638
- return lines;
639
- }
640
-
641
- /** Clear the per-file cache (tests). */
642
- export function clearCache(): void {
643
- FILE_CACHE.clear();
644
- }
645
-
646
- export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
647
- const b = cfg.budgets;
648
- if (w === "today") return { tokens: b.dayTokens, cost: b.dayCost };
649
- if (w === "week") return { tokens: b.weekTokens, cost: b.weekCost };
650
- if (w === "month") return { tokens: b.monthTokens, cost: b.monthCost };
651
- return {};
652
- }
653
-
654
- export function isOverBudget(p: PeriodStats, b: { tokens?: number; cost?: number }): boolean {
655
- return (b.tokens !== undefined && p.tokens > b.tokens) || (b.cost !== undefined && p.cost > b.cost);
656
- }
657
-
658
- // ---------------------------------------------------------------------------
659
- // Live refresh scheduling
660
- // ---------------------------------------------------------------------------
661
-
662
- export interface Timers {
663
- set: (fn: () => void, ms: number) => unknown;
664
- clear: (handle: unknown) => void;
665
- }
666
-
667
- const realTimers: Timers = {
668
- set: (fn, ms) => setTimeout(fn, ms),
669
- clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
670
- };
671
-
672
- /**
673
- * Runs `run` at most once per `delayMs` window however often `schedule()` is called
674
- * (trailing edge), and never two runs at once: a request that arrives mid-run triggers one
675
- * more run afterwards. A burst of 50 messages costs one refresh. Failures are swallowed;
676
- * the timer never keeps the process alive.
677
- */
678
- export function createCoalescer(
679
- run: () => Promise<void>,
680
- delayMs: number | (() => number),
681
- timers: Timers = realTimers,
682
- ) {
683
- let timer: unknown;
684
- let running = false;
685
- let dirty = false;
686
-
687
- const execute = async () => {
688
- if (running) {
689
- dirty = true;
690
- return;
691
- }
692
- running = true;
693
- try {
694
- await run();
695
- } catch {
696
- /* a failed refresh must never surface */
697
- } finally {
698
- running = false;
699
- }
700
- if (dirty) {
701
- dirty = false;
702
- schedule();
703
- }
704
- };
705
-
706
- const schedule = () => {
707
- if (timer !== undefined) return; // one is already pending: coalesce
708
- const ms = typeof delayMs === "function" ? delayMs() : delayMs;
709
- timer = timers.set(() => {
710
- timer = undefined;
711
- void execute();
712
- }, ms);
713
- (timer as { unref?: () => void } | undefined)?.unref?.();
714
- };
715
-
716
- const cancel = () => {
717
- if (timer !== undefined) timers.clear(timer);
718
- timer = undefined;
719
- dirty = false;
720
- };
721
-
722
- /** Run now (dropping any pending delayed run). */
723
- const flush = () => {
724
- if (timer !== undefined) timers.clear(timer);
725
- timer = undefined;
726
- return execute();
727
- };
728
-
729
- return { schedule, cancel, flush, isPending: () => timer !== undefined };
730
- }
731
-
732
- /** Map chart roles to pi theme colors. */
733
- export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint {
734
- const color: Record<string, string> = {
735
- bar: "accent",
736
- now: "success",
737
- over: "error",
738
- axis: "dim",
739
- budget: "warning",
740
- dim: "dim",
741
- title: "accent",
742
- };
743
- return (kind, text) => theme.fg(color[kind], text);
744
- }
20
+ import { handlers } from "./command/handlers.ts";
21
+ import type { Host } from "./command/host.ts";
22
+ import { completions } from "./command/completions.ts";
23
+ import { parseCommand } from "./command/parse.ts";
24
+ import { collectUsage } from "./core/aggregate.ts";
25
+ import { freshConfig, getSessionsDir, loadConfig, saveConfig, type TokenBurnConfig } from "./core/config.ts";
26
+ import type { UsageFilter, UsageOptions } from "./core/select.ts";
27
+ import { budgetWarnings } from "./features/budgets/budgets.ts";
28
+ import { createCoalescer } from "./features/live/coalescer.ts";
29
+ import { renderStatusLine, renderWidget } from "./features/status-line/render.ts";
30
+
31
+ // Public API (tests and other tools import from here).
32
+ export * from "./core/types.ts";
33
+ export * from "./core/config.ts";
34
+ export * from "./core/format.ts";
35
+ export * from "./core/parser.ts";
36
+ export * from "./core/time.ts";
37
+ export * from "./core/aggregate.ts";
38
+ export * from "./core/paint.ts";
39
+ export * from "./core/select.ts";
40
+ export * from "./command/parse.ts";
41
+ export * from "./command/completions.ts";
42
+ export * from "./features/report/chart.ts";
43
+ export * from "./features/report/report.ts";
44
+ export * from "./features/live/coalescer.ts";
45
+ export * from "./features/budgets/budgets.ts";
46
+ export * from "./features/status-line/status.ts";
47
+ export * from "./features/breakdown/query.ts";
48
+ export * from "./features/breakdown/table.ts";
49
+ export * from "./features/pricing/report.ts";
50
+ export * from "./features/export/export.ts";
745
51
 
746
52
  export default function tokenBurnExtension(pi: ExtensionAPI) {
747
53
  let config: TokenBurnConfig = freshConfig();
54
+ /** Budget warnings already shown (id = budget + period bucket + level): each shows once. */
55
+ const warned = new Set<string>();
56
+
57
+ /** Config filter (+ price table) merged with per-command filters. */
58
+ const usageOpts = (extra?: UsageFilter): UsageOptions => ({
59
+ filter: { ...config.filter, ...extra },
60
+ prices: config.prices,
61
+ priceMode: config.priceMode,
62
+ });
748
63
 
64
+ /** Redraw the status line and panel, and show any budget warning that is due. */
749
65
  const updateUI = async (ctx: ExtensionContext) => {
750
66
  if (!ctx.hasUI) return;
751
67
  if (!config.enabled) {
@@ -754,49 +70,20 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
754
70
  ctx.ui.setWidget("tokenburn-panel", []);
755
71
  return;
756
72
  }
757
- const report = await collectUsage(getSessionsDir(), new Date(), config.includeCache);
73
+ const now = new Date();
74
+ const report = await collectUsage(getSessionsDir(), now, config.includeCache, usageOpts());
758
75
  const theme = ctx.ui.theme;
759
76
 
760
- // Status bar update
761
- if (config.showStatus) {
762
- const stat = report[config.statusWindow];
763
- const isOver = isOverBudget(stat, budgetsFor(config, config.statusWindow));
764
- const fire = isOver ? theme.fg("error", "🔥 OVER BUDGET") : theme.fg("warning", "🔥");
765
- const tokensText = theme.fg(isOver ? "error" : "accent", formatNum(stat.tokens));
766
- const costText = theme.fg("dim", `(${formatCost(stat.cost)})`);
767
- const label = theme.fg("dim", `[${config.statusWindow}]`);
77
+ ctx.ui.setStatus("tokenburn", config.showStatus ? renderStatusLine(report, config, theme, now.getTime()) : "");
768
78
 
769
- ctx.ui.setStatus("tokenburn", `${fire} ${tokensText} ${costText} ${label}`);
770
- } else {
771
- ctx.ui.setStatus("tokenburn", "");
79
+ for (const w of budgetWarnings(report, config, now.getTime())) {
80
+ if (warned.has(w.id)) continue;
81
+ warned.add(w.id);
82
+ ctx.ui.notify(w.message, "warning");
772
83
  }
773
84
 
774
- // Widget panel update
775
- if (config.showWidget) {
776
- const border = theme.fg("dim", "─".repeat(46));
777
- const header = theme.fg("accent", " 🔥 TokenBurn Dashboard ");
778
- const line = (label: string, p: PeriodStats, b: { tokens?: number; cost?: number } = {}) => {
779
- const over = isOverBudget(p, b);
780
- const tokStr = (over ? theme.fg("error", "!") : " ") + formatNum(p.tokens).padStart(8);
781
- const inStr = `(in:${formatNum(p.input)} out:${formatNum(p.output)})`.padEnd(20);
782
- const costStr = formatCost(p.cost).padStart(8);
783
- return ` ${theme.fg("muted", label.padEnd(8))} ${tokStr} ${theme.fg("dim", inStr)} ${costStr}`;
784
- };
785
-
786
- const lines = [
787
- `┌${header}${"─".repeat(21)}┐`,
788
- line("Today", report.today, budgetsFor(config, "today")),
789
- line("Week", report.week, budgetsFor(config, "week")),
790
- line("Month", report.month, budgetsFor(config, "month")),
791
- line("Year", report.year),
792
- line("Total", report.total),
793
- `└${"─".repeat(46)}┘`,
794
- ];
795
-
796
- ctx.ui.setWidget("tokenburn-panel", lines, { placement: "belowEditor" });
797
- } else {
798
- ctx.ui.setWidget("tokenburn-panel", []);
799
- }
85
+ if (config.showWidget) ctx.ui.setWidget("tokenburn-panel", renderWidget(report, config, theme), { placement: "belowEditor" });
86
+ else ctx.ui.setWidget("tokenburn-panel", []);
800
87
  };
801
88
 
802
89
  // The latest context, used by the (delayed) live refresh. Dropped at shutdown so a reloaded
@@ -839,264 +126,25 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
839
126
  });
840
127
 
841
128
  pi.registerCommand("tokenburn", {
842
- description: "TokenBurn: /tokenburn [day|week|month|year|all] sets the status bar; /tokenburn chart <day|week|month|year|all> shows the chart",
129
+ description: "TokenBurn: /tokenburn [hour|day|week|month|year|all] sets the status bar; models, providers, projects, sessions show tables; chart <view> shows the chart",
843
130
  getArgumentCompletions: (prefix: string) => completions(prefix),
844
131
  handler: async (args, ctx) => {
845
- const cmd = parseCommand(args);
846
132
  latestCtx = ctx;
847
- const persist = async () => {
848
- await saveConfig(config);
849
- await updateUI(ctx);
133
+ const host: Host = {
134
+ ctx,
135
+ config,
136
+ usageOpts,
137
+ persist: async () => {
138
+ await saveConfig(config);
139
+ await updateUI(ctx);
140
+ },
141
+ // Settings changed while disabled are kept, but nothing shows until it is enabled.
142
+ note: (msg) => ctx.ui.notify(config.enabled ? msg : `${msg} (tokenburn is disabled; /tokenburn enable to show it)`, "info"),
143
+ cancelRefresh: () => refresher.cancel(),
144
+ resetWarnings: () => warned.clear(),
850
145
  };
851
- // Settings changed while disabled are kept, but nothing shows until it is enabled.
852
- const note = (msg: string) => ctx.ui.notify(config.enabled ? msg : `${msg} (tokenburn is disabled; /tokenburn enable to show it)`, "info");
853
-
854
- switch (cmd.kind) {
855
- case "window":
856
- config.showWidget = !config.showWidget;
857
- await persist();
858
- note(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`);
859
- return;
860
-
861
- case "status":
862
- config.statusWindow = cmd.window;
863
- await persist();
864
- note(`tokenburn status window: ${cmd.window}`);
865
- return;
866
-
867
- case "cycle": {
868
- const next = STATUS_WINDOWS[(STATUS_WINDOWS.indexOf(config.statusWindow) + 1) % STATUS_WINDOWS.length];
869
- config.statusWindow = next;
870
- await persist();
871
- note(`tokenburn status window: ${next}`);
872
- return;
873
- }
874
-
875
- case "cache":
876
- config.includeCache = !config.includeCache;
877
- await persist();
878
- note(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`);
879
- return;
880
-
881
- case "budget": {
882
- const key = `${cmd.period}${cmd.isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
883
- config.budgets[key] = cmd.amount;
884
- await persist();
885
- note(`tokenburn ${cmd.period} budget: ${cmd.isCost ? formatCost(cmd.amount) : formatNum(cmd.amount) + " tokens"}`);
886
- return;
887
- }
888
-
889
- case "enable":
890
- if (config.enabled) {
891
- ctx.ui.notify("tokenburn is already enabled", "info");
892
- return;
893
- }
894
- config.enabled = true;
895
- await persist();
896
- ctx.ui.notify("tokenburn enabled", "info");
897
- return;
898
-
899
- case "disable":
900
- if (!config.enabled) {
901
- ctx.ui.notify("tokenburn is already disabled", "info");
902
- return;
903
- }
904
- config.enabled = false;
905
- refresher.cancel();
906
- await persist(); // clears the status line and the panel
907
- ctx.ui.notify("tokenburn disabled: nothing runs in the background. /tokenburn enable turns it back on (charts still work on demand)", "info");
908
- return;
909
-
910
- case "live":
911
- config.liveRefresh = !config.liveRefresh;
912
- if (!config.liveRefresh) refresher.cancel();
913
- await persist();
914
- note(`tokenburn live refresh ${config.liveRefresh ? "on (after each assistant message)" : "off (only when a turn ends)"}`);
915
- return;
916
-
917
- case "invalid":
918
- ctx.ui.notify(cmd.message, "warning");
919
- return;
920
-
921
- case "report": {
922
- const root = getSessionsDir();
923
- const now = new Date();
924
- const report = await collectUsage(root, now, config.includeCache);
925
- const paint = config.chartColor ? themePaint(ctx.ui.theme) : undefined;
926
-
927
- let chart: string[];
928
- if (cmd.view === "all") {
929
- const all = await collectAllSeries(root, now, config.includeCache);
930
- chart = renderChart(all.series, all.unit, { paint, title: "All time" });
931
- } else {
932
- const series = await collectSeries(root, cmd.view, DEFAULT_COUNT[cmd.view], now, config.includeCache);
933
- const budgetWindow: Window | undefined =
934
- cmd.view === "day" ? "today" : cmd.view === "year" ? undefined : cmd.view;
935
- chart = renderChart(series, cmd.view, {
936
- paint,
937
- budget: budgetWindow ? budgetsFor(config, budgetWindow) : undefined,
938
- });
939
- }
940
-
941
- const row = (name: string, p: PeriodStats) =>
942
- `${name.padEnd(10)} ${formatNum(p.tokens).padStart(8)} tokens ${formatCost(p.cost).padStart(9)} · in:${formatNum(p.input)} out:${formatNum(p.output)} cache:${formatNum(p.cacheRead + p.cacheWrite)} · ${p.sessions} sess`;
943
- const rule = "─".repeat(60);
944
- ctx.ui.notify(
945
- [
946
- "🔥 TokenBurn Report",
947
- rule,
948
- row("Today", report.today),
949
- row("This Week", report.week),
950
- row("This Month", report.month),
951
- row("This Year", report.year),
952
- row("All Time", report.total),
953
- rule,
954
- ...chart,
955
- rule,
956
- "chart <view> · <view> sets the status bar · cycle · window · budget · cache · live · on|off",
957
- ].join("\n"),
958
- "info",
959
- );
960
- return;
961
- }
962
- }
146
+ const cmd = parseCommand(args);
147
+ await (handlers[cmd.kind] as (c: typeof cmd, h: Host) => Promise<void>)(cmd, host);
963
148
  },
964
149
  });
965
150
  }
966
-
967
- // ---------------------------------------------------------------------------
968
- // Command parsing + completions (pure, unit-tested)
969
- // ---------------------------------------------------------------------------
970
-
971
- export const REPORT_VIEWS = ["day", "week", "month", "year", "all"] as const;
972
- export type ReportView = (typeof REPORT_VIEWS)[number];
973
- export const BUDGET_PERIODS = ["day", "week", "month"] as const;
974
-
975
- export type Command =
976
- | { kind: "report"; view: ReportView }
977
- | { kind: "status"; window: Window }
978
- | { kind: "cycle" }
979
- | { kind: "window" }
980
- | { kind: "cache" }
981
- | { kind: "enable" }
982
- | { kind: "disable" }
983
- | { kind: "live" }
984
- | { kind: "budget"; period: (typeof BUDGET_PERIODS)[number]; amount: number; isCost: boolean }
985
- | { kind: "invalid"; message: string };
986
-
987
- export const USAGE =
988
- "Usage: /tokenburn [day|week|month|year|all] (status-bar period) · chart [day|week|month|year|all] · status <today|week|month|year|total> · cycle · window · budget <day|week|month> <tokens|$cost> · cache · live · enable · disable";
989
-
990
- /** Words that pick the status-bar period: `day` shows today, `all` shows the all-time total. */
991
- const WORD_TO_WINDOW: Record<string, Window> = {
992
- day: "today",
993
- today: "today",
994
- week: "week",
995
- month: "month",
996
- year: "year",
997
- all: "total",
998
- total: "total",
999
- };
1000
-
1001
- /** Views the chart can show. */
1002
- const asView = (w: string | undefined): ReportView | undefined =>
1003
- w === "today" ? "day" : w === "total" ? "all" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
1004
-
1005
- export function parseCommand(args: string | undefined): Command {
1006
- const [cmd, p1, p2] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
1007
- if (!cmd) return { kind: "report", view: "day" };
1008
-
1009
- // /tokenburn day|week|month|year|all → what the bottom status line shows
1010
- const window = WORD_TO_WINDOW[cmd];
1011
- if (window) return { kind: "status", window };
1012
-
1013
- switch (cmd) {
1014
- case "chart":
1015
- case "report": {
1016
- if (p1 === undefined) return { kind: "report", view: "day" };
1017
- const v = asView(p1);
1018
- return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown chart "${p1}". ${USAGE}` };
1019
- }
1020
- case "status":
1021
- return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
1022
- ? { kind: "status", window: p1 as Window }
1023
- : { kind: "invalid", message: `Usage: /tokenburn status <${STATUS_WINDOWS.join("|")}>` };
1024
- case "cycle":
1025
- return { kind: "cycle" };
1026
- case "window":
1027
- case "widget":
1028
- return { kind: "window" };
1029
- case "cache":
1030
- return { kind: "cache" };
1031
- case "enable":
1032
- case "on":
1033
- return { kind: "enable" };
1034
- case "disable":
1035
- case "off":
1036
- return { kind: "disable" };
1037
- case "live":
1038
- return { kind: "live" };
1039
- case "budget": {
1040
- const isCost = (p2 ?? "").startsWith("$");
1041
- const amount = Number((p2 ?? "").replace(/^\$/, ""));
1042
- if (!(BUDGET_PERIODS as readonly string[]).includes(p1 ?? "") || !Number.isFinite(amount) || amount <= 0) {
1043
- return { kind: "invalid", message: "Usage: /tokenburn budget <day|week|month> <tokens | $cost>" };
1044
- }
1045
- return { kind: "budget", period: p1 as (typeof BUDGET_PERIODS)[number], amount, isCost };
1046
- }
1047
- default:
1048
- return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
1049
- }
1050
- }
1051
-
1052
- export interface CompletionItem {
1053
- value: string;
1054
- /** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
1055
- label: string;
1056
- description?: string;
1057
- }
1058
-
1059
- const CHART_SPAN: Record<ReportView, string> = {
1060
- day: "last 14 days",
1061
- week: "last 8 weeks",
1062
- month: "last 6 months",
1063
- year: "last 5 years",
1064
- all: "whole history",
1065
- };
1066
-
1067
- const TOP_LEVEL: CompletionItem[] = [
1068
- { value: "day", label: "day", description: "Status bar: today" },
1069
- { value: "week", label: "week", description: "Status bar: this week" },
1070
- { value: "month", label: "month", description: "Status bar: this month" },
1071
- { value: "year", label: "year", description: "Status bar: this year" },
1072
- { value: "all", label: "all", description: "Status bar: all-time total" },
1073
- { value: "chart ", label: "chart", description: "Report + chart: day, week, month, year or all" },
1074
- { value: "status ", label: "status", description: "Set the status-bar period explicitly" },
1075
- { value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
1076
- { value: "window", label: "window", description: "Toggle the panel below the editor" },
1077
- { value: "budget ", label: "budget", description: "Set a day/week/month budget" },
1078
- { value: "cache", label: "cache", description: "Toggle counting cache tokens" },
1079
- { value: "live", label: "live", description: "Toggle refreshing after each message" },
1080
- { value: "enable", label: "enable", description: "Turn tokenburn on (status line, panel, refresh)" },
1081
- { value: "disable", label: "disable", description: "Turn tokenburn off: nothing runs in the background" },
1082
- ];
1083
-
1084
- /** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
1085
- export function completions(prefix: string): CompletionItem[] | null {
1086
- const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
1087
- // `tail` = " " when the completed text still needs another word (a budget amount).
1088
- const sub = (head: string, words: readonly string[], desc: (w: string) => string, tail = ""): CompletionItem[] =>
1089
- words.map((w) => ({ value: `${head} ${w}${tail}`, label: w, description: desc(w) }));
1090
-
1091
- let pool: CompletionItem[];
1092
- if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
1093
- else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
1094
- pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`, " ");
1095
- else if (/^(report|chart) /.test(text)) {
1096
- const head = text.split(" ")[0];
1097
- pool = sub(head, REPORT_VIEWS, (w) => `Chart: ${CHART_SPAN[w as ReportView]}`);
1098
- } else pool = TOP_LEVEL;
1099
-
1100
- const out = pool.filter((i) => i.value.startsWith(text));
1101
- return out.length > 0 ? out : null;
1102
- }