pi-tokenburn 0.1.0 โ†’ 0.3.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
+ - **Views:** `/tokenburn day|week|month|year|all`. Status bar period: `/tokenburn status <window>` or `/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,37 @@ 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` ยท `day` ยท `today` | Report + **daily** chart (last 14 days) |
31
+ | `/tokenburn week` | Report + **weekly** chart (last 8 weeks) |
32
+ | `/tokenburn month` | Report + **monthly** chart (last 6 months) |
33
+ | `/tokenburn year` | Report + **yearly** chart (last 5 years) |
34
+ | `/tokenburn all` | Report + whole history (monthly columns up to 18 months, yearly beyond) |
35
+ | `/tokenburn report <view>` ยท `chart <view>` | Same as above, explicit form |
36
+ | `/tokenburn status <today\|week\|month\|year\|total>` | Set the status-bar period |
37
+ | `/tokenburn cycle` | Rotate the status-bar period: today โ†’ week โ†’ month โ†’ year โ†’ total |
29
38
  | `/tokenburn window` | Toggle the panel below the editor |
30
- | `/tokenburn status <today\|week\|month\|total>` | Period shown in the status bar |
31
39
  | `/tokenburn budget <day\|week\|month> <tokens>` | Token budget, e.g. `budget day 5000000` |
32
40
  | `/tokenburn budget <day\|week\|month> $<cost>` | Dollar budget, e.g. `budget week $50` |
33
41
  | `/tokenburn cache` | Toggle counting cache read/write tokens |
34
42
 
43
+ Tab-completion works for all of the above. Unknown input prints usage instead of failing.
44
+
45
+ Chart example (`/tokenburn`, with a 600M day budget):
46
+
47
+ ```
48
+ Daily tokens ยท last 14 days โ”„ budget 600.00M
49
+ 1.13B โ”‚ โ–ˆโ–ˆ
50
+ โ”‚ โ–ˆโ–ˆ
51
+ โ”‚โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ–ˆโ–ˆโ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ–ˆโ–ˆโ”„โ–†โ–†โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„
52
+ 565.16M โ”‚ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–‡โ–‡
53
+ โ”‚ โ–…โ–… โ–ˆโ–ˆ โ–‚โ–‚ โ–ˆโ–ˆ โ–โ– โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–„โ–„ โ–‡โ–‡
54
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
55
+ 26 27 28 29 30 01 02 03 04 05 06 07 08 09
56
+ Sa Su Mo Tu We Th Fr Sa Su Mo Tu We Th Fr
57
+ โ–ฒ
58
+ ฮฃ 3.83B ($1264.30) ยท avg 273.80M/day ยท peak 1.13B (09-29 Tue)
59
+ ```
60
+
35
61
  Weeks start on Monday, in local time.
36
62
 
37
63
  ## Config
@@ -41,6 +67,7 @@ Weeks start on Monday, in local time.
41
67
  ```json
42
68
  {
43
69
  "includeCache": true,
70
+ "chartColor": true,
44
71
  "showStatus": true,
45
72
  "showWidget": false,
46
73
  "statusWindow": "today",
@@ -50,14 +77,37 @@ Weeks start on Monday, in local time.
50
77
 
51
78
  - `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
79
 
80
+ - `chartColor` (default `true`): color the chart using your pi theme (current period green, over-budget red, budget line yellow). Set `false` for plain text.
81
+
53
82
  Environment overrides for the session directory: `TOKENBURN_PI_SESSIONS`, `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`.
54
83
 
55
- ## Develop
84
+ ## Development
56
85
 
57
86
  ```bash
58
- bun test
87
+ bun install
88
+ just check # typecheck + tests + pack check + nushell tests (what CI runs)
89
+ just test # bun test only
59
90
  ```
60
91
 
92
+ CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
93
+ `just --list` shows every task.
94
+
95
+ ## Releases (automatic)
96
+
97
+ | Workflow | When | What |
98
+ |---|---|---|
99
+ | **CI** | push / PR | quality gate on Linux; tests on macOS, Windows and extreme time zones |
100
+ | **Nightly Dependency Update** | every night (GitHub 02:00 UTC, Gitea 02:30) | `bun update` within ranges, verify on all platforms, commit `chore(deps)`; then **publish a patch** when a *runtime* dependency was upgraded or `feat`/`fix`/`perf` commits are waiting since the last tag |
101
+ | **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent), creates the release |
102
+
103
+ Dev-tooling-only bumps are committed but never released on their own. A downgrade blocks the whole update.
104
+
105
+ Manual release: `just bump patch` (or `minor` / `major` / `X.Y.Z`), then `just release-push`.
106
+
107
+ **Secrets** (repo settings): `NPM_TOKEN` โ€” npm *granular access token* with publish rights and "bypass 2FA" (required for CI publishing). Optional: `GH_PAT` (lets a tag push trigger the release itself), `GITEA_TOKEN` (Gitea).
108
+
109
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
110
+
61
111
  ## Scope
62
112
 
63
113
  Pi sessions only. For Zed, Claude Code, Codex, Gemini and more, use the Rust [tokenburn](https://github.com/sorinirimies/tokenburn).
@@ -44,15 +44,21 @@ export interface TokenBurnReport {
44
44
  today: PeriodStats;
45
45
  week: PeriodStats;
46
46
  month: PeriodStats;
47
+ year: PeriodStats;
47
48
  total: PeriodStats;
48
49
  }
49
50
 
51
+ export const STATUS_WINDOWS = ["today", "week", "month", "year", "total"] as const;
52
+ export type Window = (typeof STATUS_WINDOWS)[number];
53
+
50
54
  export interface TokenBurnConfig {
51
55
  /** Count cache read/write tokens in totals (matches the tokenburn CLI). */
52
56
  includeCache: boolean;
57
+ /** Color the report chart (set false if your terminal garbles ANSI in notifications). */
58
+ chartColor: boolean;
53
59
  showStatus: boolean;
54
60
  showWidget: boolean;
55
- statusWindow: "today" | "week" | "month" | "total";
61
+ statusWindow: Window;
56
62
  budgets: {
57
63
  dayTokens?: number;
58
64
  weekTokens?: number;
@@ -65,6 +71,7 @@ export interface TokenBurnConfig {
65
71
 
66
72
  const DEFAULT_CONFIG: TokenBurnConfig = {
67
73
  includeCache: true,
74
+ chartColor: true,
68
75
  showStatus: true,
69
76
  showWidget: false,
70
77
  statusWindow: "today",
@@ -89,18 +96,24 @@ export async function loadConfig(): Promise<TokenBurnConfig> {
89
96
  const parsed = JSON.parse(raw);
90
97
  return {
91
98
  includeCache: typeof parsed.includeCache === "boolean" ? parsed.includeCache : DEFAULT_CONFIG.includeCache,
99
+ chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
92
100
  showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
93
101
  showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
94
- statusWindow: ["today", "week", "month", "total"].includes(parsed.statusWindow)
102
+ statusWindow: (STATUS_WINDOWS as readonly string[]).includes(parsed.statusWindow)
95
103
  ? parsed.statusWindow
96
104
  : DEFAULT_CONFIG.statusWindow,
97
- budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? parsed.budgets : {},
105
+ budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? { ...parsed.budgets } : {},
98
106
  };
99
107
  } catch {
100
- return { ...DEFAULT_CONFIG };
108
+ return freshConfig();
101
109
  }
102
110
  }
103
111
 
112
+ /** A config that shares no mutable state with DEFAULT_CONFIG. */
113
+ export function freshConfig(): TokenBurnConfig {
114
+ return { ...DEFAULT_CONFIG, budgets: {} };
115
+ }
116
+
104
117
  export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
105
118
  await mkdir(getConfigDir(), { recursive: true });
106
119
  await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
@@ -188,29 +201,54 @@ const emptyStats = (): PeriodStats => ({
188
201
  sessions: 0,
189
202
  });
190
203
 
204
+ /** Refresh the per-file cache and return every session file's parsed turns. */
205
+ async function loadAll(sessionsRoot: string): Promise<Turn[][]> {
206
+ const files = listSessionFiles(sessionsRoot);
207
+ const live = new Set(files);
208
+ for (const key of FILE_CACHE.keys()) if (!live.has(key)) FILE_CACHE.delete(key);
209
+
210
+ const out: Turn[][] = [];
211
+ for (const file of files) {
212
+ let sig: string;
213
+ try {
214
+ const st = statSync(file);
215
+ sig = `${st.mtimeMs}:${st.size}`;
216
+ } catch {
217
+ continue;
218
+ }
219
+ let entry = FILE_CACHE.get(file);
220
+ if (!entry || entry.sig !== sig) {
221
+ entry = { sig, turns: await parseFile(file) };
222
+ FILE_CACHE.set(file, entry);
223
+ }
224
+ out.push(entry.turns);
225
+ }
226
+ return out;
227
+ }
228
+
229
+ const turnTokens = (t: Turn, includeCache: boolean) =>
230
+ t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
231
+
191
232
  export async function collectUsage(
192
233
  sessionsRoot: string,
193
234
  now: Date = new Date(),
194
235
  includeCache = true,
195
236
  ): 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();
237
+ const startOfDay = bucketStart(now.getTime(), "day");
238
+ const startOfWeek = bucketStart(now.getTime(), "week");
239
+ const startOfMonth = bucketStart(now.getTime(), "month");
240
+ const startOfYear = bucketStart(now.getTime(), "year");
200
241
 
201
242
  const report: TokenBurnReport = {
202
243
  today: emptyStats(),
203
244
  week: emptyStats(),
204
245
  month: emptyStats(),
246
+ year: emptyStats(),
205
247
  total: emptyStats(),
206
248
  };
207
249
 
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
250
  const add = (p: PeriodStats, t: Turn) => {
213
- p.tokens += t.input + t.output + (includeCache ? t.cacheRead + t.cacheWrite : 0);
251
+ p.tokens += turnTokens(t, includeCache);
214
252
  p.input += t.input;
215
253
  p.output += t.output;
216
254
  p.cacheRead += t.cacheRead;
@@ -218,29 +256,18 @@ export async function collectUsage(
218
256
  p.cost += t.cost;
219
257
  };
220
258
 
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
-
259
+ for (const turns of await loadAll(sessionsRoot)) {
235
260
  let maxTs = 0;
236
- for (const t of entry.turns) {
261
+ for (const t of turns) {
237
262
  if (t.ts > maxTs) maxTs = t.ts;
238
263
  add(report.total, t);
264
+ if (t.ts >= startOfYear) add(report.year, t);
239
265
  if (t.ts >= startOfMonth) add(report.month, t);
240
266
  if (t.ts >= startOfWeek) add(report.week, t);
241
267
  if (t.ts >= startOfDay) add(report.today, t);
242
268
  }
243
- if (entry.turns.length > 0) report.total.sessions++;
269
+ if (turns.length > 0) report.total.sessions++;
270
+ if (maxTs >= startOfYear) report.year.sessions++;
244
271
  if (maxTs >= startOfMonth) report.month.sessions++;
245
272
  if (maxTs >= startOfWeek) report.week.sessions++;
246
273
  if (maxTs >= startOfDay) report.today.sessions++;
@@ -249,13 +276,212 @@ export async function collectUsage(
249
276
  return report;
250
277
  }
251
278
 
279
+ // ---------------------------------------------------------------------------
280
+ // Time series + chart
281
+ // ---------------------------------------------------------------------------
282
+
283
+ export type Unit = "day" | "week" | "month" | "year";
284
+
285
+ /** Local-time start of the day / Monday-week / month containing `ts`. */
286
+ export function bucketStart(ts: number, unit: Unit): number {
287
+ const d = new Date(ts);
288
+ if (unit === "day") return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
289
+ if (unit === "week") {
290
+ const dow = (d.getDay() + 6) % 7; // Monday = 0
291
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
292
+ }
293
+ if (unit === "year") return new Date(d.getFullYear(), 0, 1).getTime();
294
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
295
+ }
296
+
297
+ const pad2 = (n: number) => String(n).padStart(2, "0");
298
+
299
+ export function bucketLabel(start: number, unit: Unit): string {
300
+ const d = new Date(start);
301
+ if (unit === "year") return String(d.getFullYear());
302
+ if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
303
+ const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
304
+ return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
305
+ }
306
+
307
+ export interface SeriesPoint {
308
+ start: number;
309
+ label: string;
310
+ tokens: number;
311
+ cost: number;
312
+ }
313
+
314
+ export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6, year: 5 };
315
+
316
+ /** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
317
+ export async function collectSeries(
318
+ sessionsRoot: string,
319
+ unit: Unit,
320
+ count = DEFAULT_COUNT[unit],
321
+ now: Date = new Date(),
322
+ includeCache = true,
323
+ ): Promise<SeriesPoint[]> {
324
+ const starts: number[] = [];
325
+ for (let i = count - 1; i >= 0; i--) {
326
+ const y = now.getFullYear();
327
+ const m = now.getMonth();
328
+ const day = now.getDate();
329
+ const anchor =
330
+ unit === "day"
331
+ ? new Date(y, m, day - i)
332
+ : unit === "week"
333
+ ? new Date(y, m, day - i * 7)
334
+ : unit === "year"
335
+ ? new Date(y - i, 0, 1)
336
+ : new Date(y, m - i, 1);
337
+ starts.push(bucketStart(anchor.getTime(), unit));
338
+ }
339
+ const points = new Map<number, SeriesPoint>(
340
+ starts.map((start) => [start, { start, label: bucketLabel(start, unit), tokens: 0, cost: 0 }]),
341
+ );
342
+ for (const turns of await loadAll(sessionsRoot)) {
343
+ for (const t of turns) {
344
+ const pt = points.get(bucketStart(t.ts, unit));
345
+ if (!pt) continue;
346
+ pt.tokens += turnTokens(t, includeCache);
347
+ pt.cost += t.cost;
348
+ }
349
+ }
350
+ return starts.map((s) => points.get(s)!);
351
+ }
352
+
353
+ /**
354
+ * Whole history: monthly columns while it spans <= 18 months, yearly after that.
355
+ * Always at least 2 columns so the chart has a shape.
356
+ */
357
+ export async function collectAllSeries(
358
+ sessionsRoot: string,
359
+ now: Date = new Date(),
360
+ includeCache = true,
361
+ ): Promise<{ unit: Unit; series: SeriesPoint[] }> {
362
+ let first = now.getTime();
363
+ for (const turns of await loadAll(sessionsRoot)) for (const t of turns) if (t.ts < first) first = t.ts;
364
+ const f = new Date(first);
365
+ const months = (now.getFullYear() - f.getFullYear()) * 12 + (now.getMonth() - f.getMonth()) + 1;
366
+ if (months <= 18) {
367
+ const unit: Unit = "month";
368
+ return { unit, series: await collectSeries(sessionsRoot, unit, Math.max(2, months), now, includeCache) };
369
+ }
370
+ const years = now.getFullYear() - f.getFullYear() + 1;
371
+ return { unit: "year", series: await collectSeries(sessionsRoot, "year", years, now, includeCache) };
372
+ }
373
+
374
+ const EIGHTHS = ["", "โ–", "โ–Ž", "โ–", "โ–Œ", "โ–‹", "โ–Š", "โ–‰"];
375
+ const SPARK = "โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ";
376
+
377
+ /** Horizontal bar of `value/max` over `width` cells, with 1/8-cell precision. */
378
+ export function bar(value: number, max: number, width: number): string {
379
+ if (max <= 0 || value <= 0) return "";
380
+ const eighths = Math.max(1, Math.round((value / max) * width * 8));
381
+ const full = Math.floor(eighths / 8);
382
+ return "โ–ˆ".repeat(full) + EIGHTHS[eighths % 8];
383
+ }
384
+
385
+ export function sparkline(values: number[]): string {
386
+ const max = Math.max(...values, 0);
387
+ if (max <= 0) return SPARK[0].repeat(values.length);
388
+ return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
389
+ }
390
+
391
+ const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" };
392
+ const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month", year: "year" };
393
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
394
+
395
+ export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
396
+ const plain: Paint = (_k, t) => t;
397
+
398
+ /** Two header rows for a column: [top, bottom]. */
399
+ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
400
+ const d = new Date(p.start);
401
+ if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
402
+ if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
403
+ if (unit === "year") return [String(d.getFullYear()), ""];
404
+ return [MONTHS[d.getMonth()], String(d.getFullYear())];
405
+ }
406
+
407
+ /**
408
+ * Vertical column chart: y-axis, 1/8-row precision, optional budget line,
409
+ * highlighted current bucket and over-budget columns, summary footer.
410
+ */
411
+ export function renderChart(
412
+ series: SeriesPoint[],
413
+ unit: Unit,
414
+ opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint; title?: string } = {},
415
+ ): string[] {
416
+ const paint = opts.paint ?? plain;
417
+ const height = opts.height ?? 8;
418
+ const colW = unit === "day" ? 3 : 6; // bar cells + 1 gap
419
+ const barW = colW - 1;
420
+
421
+ const peakPoint = series.reduce((m, p) => (p.tokens > m.tokens ? p : m), series[0]);
422
+ const dataMax = peakPoint?.tokens ?? 0;
423
+ const budgetTokens = opts.budget?.tokens;
424
+ const scaleMax = Math.max(dataMax, budgetTokens ?? 0);
425
+ const total = series.reduce((a, p) => a + p.tokens, 0);
426
+ const totalCost = series.reduce((a, p) => a + p.cost, 0);
427
+
428
+ const yLabels = [formatNum(scaleMax), formatNum(scaleMax / 2), "0"];
429
+ const yW = Math.max(...yLabels.map((l) => l.length)) + 1;
430
+ const labelFor = (row: number) => (row === height ? yLabels[0] : row === Math.ceil(height / 2) ? yLabels[1] : "");
431
+ const budgetRow = budgetTokens && scaleMax > 0 ? Math.max(1, Math.ceil((budgetTokens / scaleMax) * height)) : 0;
432
+
433
+ const isOver = (p: SeriesPoint) =>
434
+ opts.budget ? isOverBudget({ tokens: p.tokens, cost: p.cost } as PeriodStats, opts.budget) : false;
435
+ const kindOf = (i: number): "bar" | "now" | "over" =>
436
+ isOver(series[i]) ? "over" : i === series.length - 1 ? "now" : "bar";
437
+
438
+ const lines: string[] = [];
439
+ lines.push(
440
+ paint("title", opts.title ?? `${UNIT_TITLE[unit]} tokens`) +
441
+ paint("dim", ` ยท ${opts.title ? "" : "last "}${series.length} ${UNIT_NOUN[unit]}${series.length === 1 ? "" : "s"}`) +
442
+ (budgetTokens ? paint("budget", ` โ”„ budget ${formatNum(budgetTokens)}`) : ""),
443
+ );
444
+
445
+ for (let row = height; row >= 1; row--) {
446
+ let line = paint("axis", `${labelFor(row).padStart(yW)} โ”‚`);
447
+ for (let i = 0; i < series.length; i++) {
448
+ const h = scaleMax > 0 ? (series[i].tokens / scaleMax) * height : 0;
449
+ let cell: string;
450
+ if (h >= row) cell = "โ–ˆ";
451
+ else if (h > row - 1 && series[i].tokens > 0) cell = SPARK[Math.min(7, Math.max(0, Math.floor((h - (row - 1)) * 8) - 1))];
452
+ else if (row === 1 && series[i].tokens > 0) cell = "โ–";
453
+ else cell = row === budgetRow ? "โ”„" : " ";
454
+ const filled = cell !== " " && cell !== "โ”„";
455
+ const body = cell.repeat(barW);
456
+ line += filled ? paint(kindOf(i), body) : cell === "โ”„" ? paint("budget", body) : body;
457
+ line += row === budgetRow ? paint("budget", "โ”„") : " ";
458
+ }
459
+ lines.push(line);
460
+ }
461
+
462
+ lines.push(paint("axis", `${"".padStart(yW)} โ””${"โ”€".repeat(series.length * colW)}`));
463
+ const pad = " ".repeat(yW + 2);
464
+ const cols = series.map((p, i) => ({ l: columnLabels(p, unit), now: i === series.length - 1 }));
465
+ lines.push(pad + cols.map((c) => c.l[0].padEnd(colW)).join(""));
466
+ lines.push(paint("dim", pad + cols.map((c) => c.l[1].padEnd(colW)).join("")));
467
+ lines.push(pad + cols.map((c) => (c.now ? paint("now", "โ–ฒ".padEnd(colW)) : " ".repeat(colW))).join(""));
468
+
469
+ const avg = series.length ? total / series.length : 0;
470
+ lines.push(
471
+ paint(
472
+ "dim",
473
+ `ฮฃ ${formatNum(total)} (${formatCost(totalCost)}) ยท avg ${formatNum(avg)}/${UNIT_NOUN[unit]}` +
474
+ (dataMax > 0 ? ` ยท peak ${formatNum(dataMax)} (${bucketLabel(peakPoint.start, unit)})` : ""),
475
+ ),
476
+ );
477
+ return lines;
478
+ }
479
+
252
480
  /** Clear the per-file cache (tests). */
253
481
  export function clearCache(): void {
254
482
  FILE_CACHE.clear();
255
483
  }
256
484
 
257
- export type Window = "today" | "week" | "month" | "total";
258
-
259
485
  export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
260
486
  const b = cfg.budgets;
261
487
  if (w === "today") return { tokens: b.dayTokens, cost: b.dayCost };
@@ -268,8 +494,22 @@ export function isOverBudget(p: PeriodStats, b: { tokens?: number; cost?: number
268
494
  return (b.tokens !== undefined && p.tokens > b.tokens) || (b.cost !== undefined && p.cost > b.cost);
269
495
  }
270
496
 
497
+ /** Map chart roles to pi theme colors. */
498
+ export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint {
499
+ const color: Record<string, string> = {
500
+ bar: "accent",
501
+ now: "success",
502
+ over: "error",
503
+ axis: "dim",
504
+ budget: "warning",
505
+ dim: "dim",
506
+ title: "accent",
507
+ };
508
+ return (kind, text) => theme.fg(color[kind], text);
509
+ }
510
+
271
511
  export default function tokenBurnExtension(pi: ExtensionAPI) {
272
- let config: TokenBurnConfig = { ...DEFAULT_CONFIG };
512
+ let config: TokenBurnConfig = freshConfig();
273
513
 
274
514
  const updateUI = async (ctx: ExtensionContext) => {
275
515
  if (!ctx.hasUI) return;
@@ -307,6 +547,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
307
547
  line("Today", report.today, budgetsFor(config, "today")),
308
548
  line("Week", report.week, budgetsFor(config, "week")),
309
549
  line("Month", report.month, budgetsFor(config, "month")),
550
+ line("Year", report.year),
310
551
  line("Total", report.total),
311
552
  `โ””${"โ”€".repeat(46)}โ”˜`,
312
553
  ];
@@ -328,70 +569,199 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
328
569
  });
329
570
 
330
571
  pi.registerCommand("tokenburn", {
331
- description: "TokenBurn stats: show report, toggle widget, or set budgets",
332
- getArgumentCompletions: (prefix: string) => {
333
- const opts = ["report", "window", "cache", "status <today|week|month|total>", "budget <day|week|month> <tokens|$cost>"];
334
- return opts.filter((o) => o.startsWith(prefix)).map((value) => ({ value }));
335
- },
572
+ description: "TokenBurn: /tokenburn [day|week|month|year|all] chart, status <window>, cycle, window, budget, cache",
573
+ getArgumentCompletions: (prefix: string) => completions(prefix),
336
574
  handler: async (args, ctx) => {
337
- const [cmd, p1, p2] = (args ?? "").trim().split(/\s+/).filter(Boolean);
338
-
339
- if (cmd === "window" || cmd === "widget") {
340
- config.showWidget = !config.showWidget;
575
+ const cmd = parseCommand(args);
576
+ const persist = async () => {
341
577
  await saveConfig(config);
342
578
  await updateUI(ctx);
343
- ctx.ui.notify(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`, "info");
344
- return;
345
- }
579
+ };
346
580
 
347
- if (cmd === "status" && ["today", "week", "month", "total"].includes(p1)) {
348
- config.statusWindow = p1 as any;
349
- await saveConfig(config);
350
- await updateUI(ctx);
351
- ctx.ui.notify(`tokenburn status window set to: ${p1}`, "info");
352
- return;
353
- }
581
+ switch (cmd.kind) {
582
+ case "window":
583
+ config.showWidget = !config.showWidget;
584
+ await persist();
585
+ ctx.ui.notify(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`, "info");
586
+ return;
354
587
 
355
- if (cmd === "cache") {
356
- config.includeCache = !config.includeCache;
357
- await saveConfig(config);
358
- await updateUI(ctx);
359
- ctx.ui.notify(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`, "info");
360
- return;
361
- }
588
+ case "status":
589
+ config.statusWindow = cmd.window;
590
+ await persist();
591
+ ctx.ui.notify(`tokenburn status window: ${cmd.window}`, "info");
592
+ return;
362
593
 
363
- if (cmd === "budget") {
364
- const isCost = (p2 ?? "").startsWith("$");
365
- const val = Number((p2 ?? "").replace(/^\$/, ""));
366
- if (!["day", "week", "month"].includes(p1 ?? "") || !Number.isFinite(val) || val <= 0) {
367
- ctx.ui.notify("Usage: /tokenburn budget <day|week|month> <tokens | $cost>", "warning");
594
+ case "cycle": {
595
+ const next = STATUS_WINDOWS[(STATUS_WINDOWS.indexOf(config.statusWindow) + 1) % STATUS_WINDOWS.length];
596
+ config.statusWindow = next;
597
+ await persist();
598
+ ctx.ui.notify(`tokenburn status window: ${next}`, "info");
599
+ return;
600
+ }
601
+
602
+ case "cache":
603
+ config.includeCache = !config.includeCache;
604
+ await persist();
605
+ ctx.ui.notify(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`, "info");
606
+ return;
607
+
608
+ case "budget": {
609
+ const key = `${cmd.period}${cmd.isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
610
+ config.budgets[key] = cmd.amount;
611
+ await persist();
612
+ ctx.ui.notify(
613
+ `tokenburn ${cmd.period} budget: ${cmd.isCost ? formatCost(cmd.amount) : formatNum(cmd.amount) + " tokens"}`,
614
+ "info",
615
+ );
368
616
  return;
369
617
  }
370
- const key = `${p1}${isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
371
- config.budgets[key] = val;
372
- await saveConfig(config);
373
- await updateUI(ctx);
374
- ctx.ui.notify(`tokenburn ${p1} budget: ${isCost ? formatCost(val) : formatNum(val) + " tokens"}`, "info");
375
- return;
376
- }
377
618
 
378
- // Default: print report
379
- const report = await collectUsage(getSessionsDir(), new Date(), config.includeCache);
380
- 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`;
382
-
383
- const output = [
384
- "๐Ÿ”ฅ TokenBurn Report",
385
- "--------------------------------------------------",
386
- formatRow("Today", report.today),
387
- formatRow("This Week", report.week),
388
- formatRow("This Month", report.month),
389
- formatRow("All Time", report.total),
390
- "--------------------------------------------------",
391
- `Use '/tokenburn window' to toggle persistent status window.`,
392
- ].join("\n");
393
-
394
- ctx.ui.notify(output, "info");
619
+ case "invalid":
620
+ ctx.ui.notify(cmd.message, "warning");
621
+ return;
622
+
623
+ case "report": {
624
+ const root = getSessionsDir();
625
+ const now = new Date();
626
+ const report = await collectUsage(root, now, config.includeCache);
627
+ const paint = config.chartColor ? themePaint(ctx.ui.theme) : undefined;
628
+
629
+ let chart: string[];
630
+ if (cmd.view === "all") {
631
+ const all = await collectAllSeries(root, now, config.includeCache);
632
+ chart = renderChart(all.series, all.unit, { paint, title: "All time" });
633
+ } else {
634
+ const series = await collectSeries(root, cmd.view, DEFAULT_COUNT[cmd.view], now, config.includeCache);
635
+ const budgetWindow: Window | undefined =
636
+ cmd.view === "day" ? "today" : cmd.view === "year" ? undefined : cmd.view;
637
+ chart = renderChart(series, cmd.view, {
638
+ paint,
639
+ budget: budgetWindow ? budgetsFor(config, budgetWindow) : undefined,
640
+ });
641
+ }
642
+
643
+ const row = (name: string, p: PeriodStats) =>
644
+ `${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`;
645
+ const rule = "โ”€".repeat(60);
646
+ ctx.ui.notify(
647
+ [
648
+ "๐Ÿ”ฅ TokenBurn Report",
649
+ rule,
650
+ row("Today", report.today),
651
+ row("This Week", report.week),
652
+ row("This Month", report.month),
653
+ row("This Year", report.year),
654
+ row("All Time", report.total),
655
+ rule,
656
+ ...chart,
657
+ rule,
658
+ "/tokenburn day|week|month|year|all ยท status <window> ยท cycle ยท window ยท budget ยท cache",
659
+ ].join("\n"),
660
+ "info",
661
+ );
662
+ return;
663
+ }
664
+ }
395
665
  },
396
666
  });
397
667
  }
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // Command parsing + completions (pure, unit-tested)
671
+ // ---------------------------------------------------------------------------
672
+
673
+ export const REPORT_VIEWS = ["day", "week", "month", "year", "all"] as const;
674
+ export type ReportView = (typeof REPORT_VIEWS)[number];
675
+ export const BUDGET_PERIODS = ["day", "week", "month"] as const;
676
+
677
+ export type Command =
678
+ | { kind: "report"; view: ReportView }
679
+ | { kind: "status"; window: Window }
680
+ | { kind: "cycle" }
681
+ | { kind: "window" }
682
+ | { kind: "cache" }
683
+ | { kind: "budget"; period: (typeof BUDGET_PERIODS)[number]; amount: number; isCost: boolean }
684
+ | { kind: "invalid"; message: string };
685
+
686
+ export const USAGE =
687
+ "Usage: /tokenburn [day|week|month|year|all] ยท status <today|week|month|year|total> ยท cycle ยท window ยท budget <day|week|month> <tokens|$cost> ยท cache";
688
+
689
+ const asView = (w: string | undefined): ReportView | undefined =>
690
+ w === "today" ? "day" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
691
+
692
+ export function parseCommand(args: string | undefined): Command {
693
+ const [cmd, p1, p2] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
694
+ if (!cmd) return { kind: "report", view: "day" };
695
+
696
+ const view = asView(cmd);
697
+ if (view) return { kind: "report", view };
698
+
699
+ switch (cmd) {
700
+ case "report":
701
+ case "chart": {
702
+ if (p1 === undefined) return { kind: "report", view: "day" };
703
+ const v = asView(p1);
704
+ return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown view "${p1}". ${USAGE}` };
705
+ }
706
+ case "status":
707
+ return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
708
+ ? { kind: "status", window: p1 as Window }
709
+ : { kind: "invalid", message: `Usage: /tokenburn status <${STATUS_WINDOWS.join("|")}>` };
710
+ case "cycle":
711
+ return { kind: "cycle" };
712
+ case "window":
713
+ case "widget":
714
+ return { kind: "window" };
715
+ case "cache":
716
+ return { kind: "cache" };
717
+ case "budget": {
718
+ const isCost = (p2 ?? "").startsWith("$");
719
+ const amount = Number((p2 ?? "").replace(/^\$/, ""));
720
+ if (!(BUDGET_PERIODS as readonly string[]).includes(p1 ?? "") || !Number.isFinite(amount) || amount <= 0) {
721
+ return { kind: "invalid", message: "Usage: /tokenburn budget <day|week|month> <tokens | $cost>" };
722
+ }
723
+ return { kind: "budget", period: p1 as (typeof BUDGET_PERIODS)[number], amount, isCost };
724
+ }
725
+ default:
726
+ return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
727
+ }
728
+ }
729
+
730
+ export interface CompletionItem {
731
+ value: string;
732
+ /** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
733
+ label: string;
734
+ description?: string;
735
+ }
736
+
737
+ const TOP_LEVEL: CompletionItem[] = [
738
+ { value: "day", label: "day", description: "Report + daily chart (last 14 days)" },
739
+ { value: "week", label: "week", description: "Report + weekly chart (last 8 weeks)" },
740
+ { value: "month", label: "month", description: "Report + monthly chart (last 6 months)" },
741
+ { value: "year", label: "year", description: "Report + yearly chart (last 5 years)" },
742
+ { value: "all", label: "all", description: "Report + whole history" },
743
+ { value: "status ", label: "status", description: "Set the status-bar period" },
744
+ { value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
745
+ { value: "window", label: "window", description: "Toggle the panel below the editor" },
746
+ { value: "budget ", label: "budget", description: "Set a day/week/month budget" },
747
+ { value: "cache", label: "cache", description: "Toggle counting cache tokens" },
748
+ ];
749
+
750
+ /** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
751
+ export function completions(prefix: string): CompletionItem[] | null {
752
+ const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
753
+ const sub = (head: string, words: readonly string[], desc: (w: string) => string): CompletionItem[] =>
754
+ words.map((w) => ({ value: `${head} ${w}`, label: w, description: desc(w) }));
755
+
756
+ let pool: CompletionItem[];
757
+ if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
758
+ else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
759
+ pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`);
760
+ else if (/^(report|chart) /.test(text)) {
761
+ const head = text.split(" ")[0];
762
+ pool = sub(head, REPORT_VIEWS, (w) => `${head} for ${w}`);
763
+ } else pool = TOP_LEVEL;
764
+
765
+ const out = pool.filter((i) => i.value.startsWith(text));
766
+ return out.length > 0 ? out : null;
767
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -11,6 +11,20 @@
11
11
  },
12
12
  "homepage": "https://github.com/sorinirimies/pi-tokenburn#readme",
13
13
  "files": ["extensions", "README.md", "LICENSE"],
14
- "pi": { "extensions": ["./extensions/tokenburn.ts"] },
15
- "peerDependencies": { "@earendil-works/pi-coding-agent": "*" }
14
+ "pi": {
15
+ "extensions": ["./extensions/tokenburn.ts"]
16
+ },
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "typecheck": "tsc --noEmit",
20
+ "check": "nu scripts/ci/quality_gate.nu"
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*"
24
+ },
25
+ "devDependencies": {
26
+ "@earendil-works/pi-coding-agent": "^1.1.0",
27
+ "@types/bun": "^1.4.2",
28
+ "typescript": "^7.0.2"
29
+ }
16
30
  }