pi-tokenburn 0.1.0 โ†’ 0.2.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/README.md CHANGED
@@ -9,6 +9,8 @@ Totals match the `tokenburn` CLI exactly.
9
9
  ## Features
10
10
 
11
11
  - **Status bar:** `๐Ÿ”ฅ 1.20M ($52.79) [today]`, turns red with `OVER BUDGET` past your limit.
12
+ - **Report chart:** `/tokenburn` shows the summary plus a vertical column chart (y-axis, budget line, current period marked, over-budget columns in red, peak/avg footer). Daily (14 days), weekly (8 weeks) or monthly (6 months).
13
+ - **Switch periods fast:** `/tokenburn week`, `/tokenburn month`, `/tokenburn cycle`.
12
14
  - **Window panel (optional):** Today / Week / Month / Total below the editor, with in/out tokens and cost.
13
15
  - **Budgets** per day, week and month, in tokens and/or dollars.
14
16
  - **Fast:** per-file cache keyed on mtime+size. First scan of ~400 MB of sessions takes under a second; refreshes after that take milliseconds. Refresh never blocks the agent loop.
@@ -25,13 +27,31 @@ pi install git:github.com/sorinirimies/pi-tokenburn
25
27
 
26
28
  | Command | What it does |
27
29
  |---|---|
28
- | `/tokenburn` | Print the Today / Week / Month / All-time report |
30
+ | `/tokenburn` | Report: Today / Week / Month / All-time + **daily** chart |
31
+ | `/tokenburn report week` ยท `report month` | Same report with a **weekly** / **monthly** chart (also `chart month`) |
32
+ | `/tokenburn status <today\|week\|month\|total>` | Set the status-bar period (shortcut: `/tokenburn total`) |
33
+ | `/tokenburn cycle` | Rotate the status-bar period: today โ†’ week โ†’ month โ†’ total |
29
34
  | `/tokenburn window` | Toggle the panel below the editor |
30
- | `/tokenburn status <today\|week\|month\|total>` | Period shown in the status bar |
31
35
  | `/tokenburn budget <day\|week\|month> <tokens>` | Token budget, e.g. `budget day 5000000` |
32
36
  | `/tokenburn budget <day\|week\|month> $<cost>` | Dollar budget, e.g. `budget week $50` |
33
37
  | `/tokenburn cache` | Toggle counting cache read/write tokens |
34
38
 
39
+ Chart example (`/tokenburn`, with a 600M day budget):
40
+
41
+ ```
42
+ Daily tokens ยท last 14 days โ”„ budget 600.00M
43
+ 1.13B โ”‚ โ–ˆโ–ˆ
44
+ โ”‚ โ–ˆโ–ˆ
45
+ โ”‚โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ–ˆโ–ˆโ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ–ˆโ–ˆโ”„โ–†โ–†โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„
46
+ 565.16M โ”‚ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–‡โ–‡
47
+ โ”‚ โ–…โ–… โ–ˆโ–ˆ โ–‚โ–‚ โ–ˆโ–ˆ โ–โ– โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–„โ–„ โ–‡โ–‡
48
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
49
+ 26 27 28 29 30 01 02 03 04 05 06 07 08 09
50
+ Sa Su Mo Tu We Th Fr Sa Su Mo Tu We Th Fr
51
+ โ–ฒ
52
+ ฮฃ 3.83B ($1264.30) ยท avg 273.80M/day ยท peak 1.13B (09-29 Tue)
53
+ ```
54
+
35
55
  Weeks start on Monday, in local time.
36
56
 
37
57
  ## Config
@@ -41,6 +61,7 @@ Weeks start on Monday, in local time.
41
61
  ```json
42
62
  {
43
63
  "includeCache": true,
64
+ "chartColor": true,
44
65
  "showStatus": true,
45
66
  "showWidget": false,
46
67
  "statusWindow": "today",
@@ -50,6 +71,8 @@ Weeks start on Monday, in local time.
50
71
 
51
72
  - `includeCache` (default `true`): count cache read/write tokens in totals, like the `tokenburn` CLI. Cache reads dominate (often 95%+ of tokens) but are billed far cheaper; set `false` to budget on input + output only.
52
73
 
74
+ - `chartColor` (default `true`): color the chart using your pi theme (current period green, over-budget red, budget line yellow). Set `false` for plain text.
75
+
53
76
  Environment overrides for the session directory: `TOKENBURN_PI_SESSIONS`, `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`.
54
77
 
55
78
  ## Develop
@@ -50,6 +50,8 @@ export interface TokenBurnReport {
50
50
  export interface TokenBurnConfig {
51
51
  /** Count cache read/write tokens in totals (matches the tokenburn CLI). */
52
52
  includeCache: boolean;
53
+ /** Color the report chart (set false if your terminal garbles ANSI in notifications). */
54
+ chartColor: boolean;
53
55
  showStatus: boolean;
54
56
  showWidget: boolean;
55
57
  statusWindow: "today" | "week" | "month" | "total";
@@ -65,6 +67,7 @@ export interface TokenBurnConfig {
65
67
 
66
68
  const DEFAULT_CONFIG: TokenBurnConfig = {
67
69
  includeCache: true,
70
+ chartColor: true,
68
71
  showStatus: true,
69
72
  showWidget: false,
70
73
  statusWindow: "today",
@@ -89,6 +92,7 @@ export async function loadConfig(): Promise<TokenBurnConfig> {
89
92
  const parsed = JSON.parse(raw);
90
93
  return {
91
94
  includeCache: typeof parsed.includeCache === "boolean" ? parsed.includeCache : DEFAULT_CONFIG.includeCache,
95
+ chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
92
96
  showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
93
97
  showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
94
98
  statusWindow: ["today", "week", "month", "total"].includes(parsed.statusWindow)
@@ -188,15 +192,42 @@ const emptyStats = (): PeriodStats => ({
188
192
  sessions: 0,
189
193
  });
190
194
 
195
+ /** Refresh the per-file cache and return every session file's parsed turns. */
196
+ async function loadAll(sessionsRoot: string): Promise<Turn[][]> {
197
+ const files = listSessionFiles(sessionsRoot);
198
+ const live = new Set(files);
199
+ for (const key of FILE_CACHE.keys()) if (!live.has(key)) FILE_CACHE.delete(key);
200
+
201
+ const out: Turn[][] = [];
202
+ for (const file of files) {
203
+ let sig: string;
204
+ try {
205
+ const st = statSync(file);
206
+ sig = `${st.mtimeMs}:${st.size}`;
207
+ } catch {
208
+ continue;
209
+ }
210
+ let entry = FILE_CACHE.get(file);
211
+ if (!entry || entry.sig !== sig) {
212
+ entry = { sig, turns: await parseFile(file) };
213
+ FILE_CACHE.set(file, entry);
214
+ }
215
+ out.push(entry.turns);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ const turnTokens = (t: Turn, includeCache: boolean) =>
221
+ t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
222
+
191
223
  export async function collectUsage(
192
224
  sessionsRoot: string,
193
225
  now: Date = new Date(),
194
226
  includeCache = true,
195
227
  ): Promise<TokenBurnReport> {
196
- const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
197
- const dayOfWeek = (now.getDay() + 6) % 7; // Monday = 0
198
- const startOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - dayOfWeek).getTime();
199
- const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
228
+ const startOfDay = bucketStart(now.getTime(), "day");
229
+ const startOfWeek = bucketStart(now.getTime(), "week");
230
+ const startOfMonth = bucketStart(now.getTime(), "month");
200
231
 
201
232
  const report: TokenBurnReport = {
202
233
  today: emptyStats(),
@@ -205,12 +236,8 @@ export async function collectUsage(
205
236
  total: emptyStats(),
206
237
  };
207
238
 
208
- const files = listSessionFiles(sessionsRoot);
209
- const live = new Set(files);
210
- for (const key of FILE_CACHE.keys()) if (!live.has(key)) FILE_CACHE.delete(key);
211
-
212
239
  const add = (p: PeriodStats, t: Turn) => {
213
- p.tokens += t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
240
+ p.tokens += turnTokens(t, includeCache);
214
241
  p.input += t.input;
215
242
  p.output += t.output;
216
243
  p.cacheRead += t.cacheRead;
@@ -218,29 +245,16 @@ export async function collectUsage(
218
245
  p.cost += t.cost;
219
246
  };
220
247
 
221
- for (const file of files) {
222
- let sig: string;
223
- try {
224
- const st = statSync(file);
225
- sig = `${st.mtimeMs}:${st.size}`;
226
- } catch {
227
- continue;
228
- }
229
- let entry = FILE_CACHE.get(file);
230
- if (!entry || entry.sig !== sig) {
231
- entry = { sig, turns: await parseFile(file) };
232
- FILE_CACHE.set(file, entry);
233
- }
234
-
248
+ for (const turns of await loadAll(sessionsRoot)) {
235
249
  let maxTs = 0;
236
- for (const t of entry.turns) {
250
+ for (const t of turns) {
237
251
  if (t.ts > maxTs) maxTs = t.ts;
238
252
  add(report.total, t);
239
253
  if (t.ts >= startOfMonth) add(report.month, t);
240
254
  if (t.ts >= startOfWeek) add(report.week, t);
241
255
  if (t.ts >= startOfDay) add(report.today, t);
242
256
  }
243
- if (entry.turns.length > 0) report.total.sessions++;
257
+ if (turns.length > 0) report.total.sessions++;
244
258
  if (maxTs >= startOfMonth) report.month.sessions++;
245
259
  if (maxTs >= startOfWeek) report.week.sessions++;
246
260
  if (maxTs >= startOfDay) report.today.sessions++;
@@ -249,6 +263,181 @@ export async function collectUsage(
249
263
  return report;
250
264
  }
251
265
 
266
+ // ---------------------------------------------------------------------------
267
+ // Time series + chart
268
+ // ---------------------------------------------------------------------------
269
+
270
+ export type Unit = "day" | "week" | "month";
271
+
272
+ /** Local-time start of the day / Monday-week / month containing `ts`. */
273
+ export function bucketStart(ts: number, unit: Unit): number {
274
+ const d = new Date(ts);
275
+ if (unit === "day") return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
276
+ if (unit === "week") {
277
+ const dow = (d.getDay() + 6) % 7; // Monday = 0
278
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
279
+ }
280
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
281
+ }
282
+
283
+ const pad2 = (n: number) => String(n).padStart(2, "0");
284
+
285
+ export function bucketLabel(start: number, unit: Unit): string {
286
+ const d = new Date(start);
287
+ if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
288
+ const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
289
+ return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
290
+ }
291
+
292
+ export interface SeriesPoint {
293
+ start: number;
294
+ label: string;
295
+ tokens: number;
296
+ cost: number;
297
+ }
298
+
299
+ export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6 };
300
+
301
+ /** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
302
+ export async function collectSeries(
303
+ sessionsRoot: string,
304
+ unit: Unit,
305
+ count = DEFAULT_COUNT[unit],
306
+ now: Date = new Date(),
307
+ includeCache = true,
308
+ ): Promise<SeriesPoint[]> {
309
+ const starts: number[] = [];
310
+ for (let i = count - 1; i >= 0; i--) {
311
+ const y = now.getFullYear();
312
+ const m = now.getMonth();
313
+ const day = now.getDate();
314
+ const anchor =
315
+ unit === "day"
316
+ ? new Date(y, m, day - i)
317
+ : unit === "week"
318
+ ? new Date(y, m, day - i * 7)
319
+ : new Date(y, m - i, 1);
320
+ starts.push(bucketStart(anchor.getTime(), unit));
321
+ }
322
+ const points = new Map<number, SeriesPoint>(
323
+ starts.map((start) => [start, { start, label: bucketLabel(start, unit), tokens: 0, cost: 0 }]),
324
+ );
325
+ for (const turns of await loadAll(sessionsRoot)) {
326
+ for (const t of turns) {
327
+ const pt = points.get(bucketStart(t.ts, unit));
328
+ if (!pt) continue;
329
+ pt.tokens += turnTokens(t, includeCache);
330
+ pt.cost += t.cost;
331
+ }
332
+ }
333
+ return starts.map((s) => points.get(s)!);
334
+ }
335
+
336
+ const EIGHTHS = ["", "โ–", "โ–Ž", "โ–", "โ–Œ", "โ–‹", "โ–Š", "โ–‰"];
337
+ const SPARK = "โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ";
338
+
339
+ /** Horizontal bar of `value/max` over `width` cells, with 1/8-cell precision. */
340
+ export function bar(value: number, max: number, width: number): string {
341
+ if (max <= 0 || value <= 0) return "";
342
+ const eighths = Math.max(1, Math.round((value / max) * width * 8));
343
+ const full = Math.floor(eighths / 8);
344
+ return "โ–ˆ".repeat(full) + EIGHTHS[eighths % 8];
345
+ }
346
+
347
+ export function sparkline(values: number[]): string {
348
+ const max = Math.max(...values, 0);
349
+ if (max <= 0) return SPARK[0].repeat(values.length);
350
+ return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
351
+ }
352
+
353
+ const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly" };
354
+ const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month" };
355
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
356
+
357
+ export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
358
+ const plain: Paint = (_k, t) => t;
359
+
360
+ /** Two header rows for a column: [top, bottom]. */
361
+ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
362
+ const d = new Date(p.start);
363
+ if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
364
+ if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
365
+ return [MONTHS[d.getMonth()], String(d.getFullYear())];
366
+ }
367
+
368
+ /**
369
+ * Vertical column chart: y-axis, 1/8-row precision, optional budget line,
370
+ * highlighted current bucket and over-budget columns, summary footer.
371
+ */
372
+ export function renderChart(
373
+ series: SeriesPoint[],
374
+ unit: Unit,
375
+ opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint } = {},
376
+ ): string[] {
377
+ const paint = opts.paint ?? plain;
378
+ const height = opts.height ?? 8;
379
+ const colW = unit === "day" ? 3 : 6; // bar cells + 1 gap
380
+ const barW = colW - 1;
381
+
382
+ const peakPoint = series.reduce((m, p) => (p.tokens > m.tokens ? p : m), series[0]);
383
+ const dataMax = peakPoint?.tokens ?? 0;
384
+ const budgetTokens = opts.budget?.tokens;
385
+ const scaleMax = Math.max(dataMax, budgetTokens ?? 0);
386
+ const total = series.reduce((a, p) => a + p.tokens, 0);
387
+ const totalCost = series.reduce((a, p) => a + p.cost, 0);
388
+
389
+ const yLabels = [formatNum(scaleMax), formatNum(scaleMax / 2), "0"];
390
+ const yW = Math.max(...yLabels.map((l) => l.length)) + 1;
391
+ const labelFor = (row: number) => (row === height ? yLabels[0] : row === Math.ceil(height / 2) ? yLabels[1] : "");
392
+ const budgetRow = budgetTokens && scaleMax > 0 ? Math.max(1, Math.ceil((budgetTokens / scaleMax) * height)) : 0;
393
+
394
+ const isOver = (p: SeriesPoint) =>
395
+ opts.budget ? isOverBudget({ tokens: p.tokens, cost: p.cost } as PeriodStats, opts.budget) : false;
396
+ const kindOf = (i: number): "bar" | "now" | "over" =>
397
+ isOver(series[i]) ? "over" : i === series.length - 1 ? "now" : "bar";
398
+
399
+ const lines: string[] = [];
400
+ lines.push(
401
+ paint("title", `${UNIT_TITLE[unit]} tokens`) +
402
+ paint("dim", ` ยท last ${series.length} ${UNIT_NOUN[unit]}s`) +
403
+ (budgetTokens ? paint("budget", ` โ”„ budget ${formatNum(budgetTokens)}`) : ""),
404
+ );
405
+
406
+ for (let row = height; row >= 1; row--) {
407
+ let line = paint("axis", `${labelFor(row).padStart(yW)} โ”‚`);
408
+ for (let i = 0; i < series.length; i++) {
409
+ const h = scaleMax > 0 ? (series[i].tokens / scaleMax) * height : 0;
410
+ let cell: string;
411
+ if (h >= row) cell = "โ–ˆ";
412
+ else if (h > row - 1 && series[i].tokens > 0) cell = SPARK[Math.min(7, Math.max(0, Math.floor((h - (row - 1)) * 8) - 1))];
413
+ else if (row === 1 && series[i].tokens > 0) cell = "โ–";
414
+ else cell = row === budgetRow ? "โ”„" : " ";
415
+ const filled = cell !== " " && cell !== "โ”„";
416
+ const body = cell.repeat(barW);
417
+ line += filled ? paint(kindOf(i), body) : cell === "โ”„" ? paint("budget", body) : body;
418
+ line += row === budgetRow ? paint("budget", "โ”„") : " ";
419
+ }
420
+ lines.push(line);
421
+ }
422
+
423
+ lines.push(paint("axis", `${"".padStart(yW)} โ””${"โ”€".repeat(series.length * colW)}`));
424
+ const pad = " ".repeat(yW + 2);
425
+ const cols = series.map((p, i) => ({ l: columnLabels(p, unit), now: i === series.length - 1 }));
426
+ lines.push(pad + cols.map((c) => c.l[0].padEnd(colW)).join(""));
427
+ lines.push(paint("dim", pad + cols.map((c) => c.l[1].padEnd(colW)).join("")));
428
+ lines.push(pad + cols.map((c) => (c.now ? paint("now", "โ–ฒ".padEnd(colW)) : " ".repeat(colW))).join(""));
429
+
430
+ const avg = series.length ? total / series.length : 0;
431
+ lines.push(
432
+ paint(
433
+ "dim",
434
+ `ฮฃ ${formatNum(total)} (${formatCost(totalCost)}) ยท avg ${formatNum(avg)}/${UNIT_NOUN[unit]}` +
435
+ (dataMax > 0 ? ` ยท peak ${formatNum(dataMax)} (${bucketLabel(peakPoint.start, unit)})` : ""),
436
+ ),
437
+ );
438
+ return lines;
439
+ }
440
+
252
441
  /** Clear the per-file cache (tests). */
253
442
  export function clearCache(): void {
254
443
  FILE_CACHE.clear();
@@ -268,6 +457,20 @@ export function isOverBudget(p: PeriodStats, b: { tokens?: number; cost?: number
268
457
  return (b.tokens !== undefined && p.tokens > b.tokens) || (b.cost !== undefined && p.cost > b.cost);
269
458
  }
270
459
 
460
+ /** Map chart roles to pi theme colors. */
461
+ export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint {
462
+ const color: Record<string, string> = {
463
+ bar: "accent",
464
+ now: "success",
465
+ over: "error",
466
+ axis: "dim",
467
+ budget: "warning",
468
+ dim: "dim",
469
+ title: "accent",
470
+ };
471
+ return (kind, text) => theme.fg(color[kind], text);
472
+ }
473
+
271
474
  export default function tokenBurnExtension(pi: ExtensionAPI) {
272
475
  let config: TokenBurnConfig = { ...DEFAULT_CONFIG };
273
476
 
@@ -330,7 +533,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
330
533
  pi.registerCommand("tokenburn", {
331
534
  description: "TokenBurn stats: show report, toggle widget, or set budgets",
332
535
  getArgumentCompletions: (prefix: string) => {
333
- const opts = ["report", "window", "cache", "status <today|week|month|total>", "budget <day|week|month> <tokens|$cost>"];
536
+ const opts = ["report", "day", "week", "month", "today", "total", "cycle", "window", "cache", "status <today|week|month|total>", "budget <day|week|month> <tokens|$cost>"];
334
537
  return opts.filter((o) => o.startsWith(prefix)).map((value) => ({ value }));
335
538
  },
336
539
  handler: async (args, ctx) => {
@@ -344,11 +547,26 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
344
547
  return;
345
548
  }
346
549
 
347
- if (cmd === "status" && ["today", "week", "month", "total"].includes(p1)) {
348
- config.statusWindow = p1 as any;
550
+ const WINDOWS = ["today", "week", "month", "total"] as const;
551
+ const setWindow = async (w: (typeof WINDOWS)[number]) => {
552
+ config.statusWindow = w;
349
553
  await saveConfig(config);
350
554
  await updateUI(ctx);
351
- ctx.ui.notify(`tokenburn status window set to: ${p1}`, "info");
555
+ ctx.ui.notify(`tokenburn status window: ${w}`, "info");
556
+ };
557
+
558
+ // /tokenburn today|week|month|total โ€” shortcut for the status bar period
559
+ if ((WINDOWS as readonly string[]).includes(cmd ?? "")) {
560
+ await setWindow(cmd as (typeof WINDOWS)[number]);
561
+ return;
562
+ }
563
+ if (cmd === "status" && (WINDOWS as readonly string[]).includes(p1 ?? "")) {
564
+ await setWindow(p1 as (typeof WINDOWS)[number]);
565
+ return;
566
+ }
567
+ // /tokenburn cycle โ€” today โ†’ week โ†’ month โ†’ total โ†’ today
568
+ if (cmd === "cycle") {
569
+ await setWindow(WINDOWS[(WINDOWS.indexOf(config.statusWindow) + 1) % WINDOWS.length]);
352
570
  return;
353
571
  }
354
572
 
@@ -375,20 +593,26 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
375
593
  return;
376
594
  }
377
595
 
378
- // Default: print report
596
+ // /tokenburn [report] [day|week|month] โ€” summary + chart
597
+ const unitArg = (cmd === "report" || cmd === "chart" ? p1 : cmd) as string | undefined;
598
+ const unit: Unit = unitArg === "week" || unitArg === "month" ? unitArg : "day";
379
599
  const report = await collectUsage(getSessionsDir(), new Date(), config.includeCache);
600
+ const series = await collectSeries(getSessionsDir(), unit, DEFAULT_COUNT[unit], new Date(), config.includeCache);
601
+ const budgetKey = unit === "day" ? "today" : unit === "week" ? "week" : "month";
380
602
  const formatRow = (name: string, p: PeriodStats) =>
381
- `${name.padEnd(8)}: ${formatNum(p.tokens).padStart(8)} tokens (${formatCost(p.cost)}) ยท in:${formatNum(p.input)} out:${formatNum(p.output)} cache:${formatNum(p.cacheRead + p.cacheWrite)} ยท ${p.sessions} sessions`;
603
+ `${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`;
382
604
 
383
605
  const output = [
384
606
  "๐Ÿ”ฅ TokenBurn Report",
385
- "--------------------------------------------------",
607
+ "โ”€".repeat(60),
386
608
  formatRow("Today", report.today),
387
609
  formatRow("This Week", report.week),
388
610
  formatRow("This Month", report.month),
389
611
  formatRow("All Time", report.total),
390
- "--------------------------------------------------",
391
- `Use '/tokenburn window' to toggle persistent status window.`,
612
+ "โ”€".repeat(60),
613
+ ...renderChart(series, unit, { budget: budgetsFor(config, budgetKey as Window), paint: config.chartColor ? themePaint(ctx.ui.theme) : undefined }),
614
+ "โ”€".repeat(60),
615
+ "/tokenburn [day|week|month] chart ยท today|week|month|total|cycle status bar ยท window ยท budget ยท cache",
392
616
  ].join("\n");
393
617
 
394
618
  ctx.ui.notify(output, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension for live token usage monitoring and budget tracking (Day / Week / Month / Total). 0 prompt tokens.",
5
5
  "keywords": ["pi-package", "pi-extension", "tokenburn", "tokens", "analytics", "budget", "stats"],
6
6
  "license": "MIT",