pi-tokenburn 0.2.0 → 0.3.1

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
@@ -10,7 +10,7 @@ Totals match the `tokenburn` CLI exactly.
10
10
 
11
11
  - **Status bar:** `🔥 1.20M ($52.79) [today]`, turns red with `OVER BUDGET` past your limit.
12
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`.
13
+ - **Views:** `/tokenburn day|week|month|year|all`. Status bar period: `/tokenburn status <window>` or `/tokenburn cycle`.
14
14
  - **Window panel (optional):** Today / Week / Month / Total below the editor, with in/out tokens and cost.
15
15
  - **Budgets** per day, week and month, in tokens and/or dollars.
16
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.
@@ -27,15 +27,21 @@ pi install git:github.com/sorinirimies/pi-tokenburn
27
27
 
28
28
  | Command | What it does |
29
29
  |---|---|
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 |
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 |
34
38
  | `/tokenburn window` | Toggle the panel below the editor |
35
39
  | `/tokenburn budget <day\|week\|month> <tokens>` | Token budget, e.g. `budget day 5000000` |
36
40
  | `/tokenburn budget <day\|week\|month> $<cost>` | Dollar budget, e.g. `budget week $50` |
37
41
  | `/tokenburn cache` | Toggle counting cache read/write tokens |
38
42
 
43
+ Tab-completion works for all of the above. Unknown input prints usage instead of failing.
44
+
39
45
  Chart example (`/tokenburn`, with a 600M day budget):
40
46
 
41
47
  ```
@@ -75,12 +81,36 @@ Weeks start on Monday, in local time.
75
81
 
76
82
  Environment overrides for the session directory: `TOKENBURN_PI_SESSIONS`, `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`.
77
83
 
78
- ## Develop
84
+ ## Development
79
85
 
80
86
  ```bash
81
- bun test
87
+ bun install
88
+ just check # typecheck + tests + pack check + nushell tests (what CI runs)
89
+ just test # bun test only (with coverage)
90
+ just coverage # tests + a coverage table
82
91
  ```
83
92
 
93
+ **Coverage:** every `bun test` collects coverage (`bunfig.toml`) and **fails below 95% lines / 95% functions**. CI shows the table on the run page and uploads `lcov.info`.
94
+
95
+ CI scripts are [nushell](https://www.nushell.sh) (`scripts/`), the same ones locally and in GitHub / Gitea Actions.
96
+ `just --list` shows every task.
97
+
98
+ ## Releases (automatic)
99
+
100
+ | Workflow | When | What |
101
+ |---|---|---|
102
+ | **CI** | push / PR | quality gate on Linux; tests on macOS, Windows and extreme time zones |
103
+ | **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 |
104
+ | **Release** | tag `vX.Y.Z` | validates the tag against `package.json`, runs the gate, `npm publish` (idempotent), creates the release |
105
+
106
+ Dev-tooling-only bumps are committed but never released on their own. A downgrade blocks the whole update.
107
+
108
+ Manual release: `just bump patch` (or `minor` / `major` / `X.Y.Z`), then `just release-push`.
109
+
110
+ **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).
111
+
112
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org); the changelog is generated by git-cliff.
113
+
84
114
  ## Scope
85
115
 
86
116
  Pi sessions only. For Zed, Claude Code, Codex, Gemini and more, use the Rust [tokenburn](https://github.com/sorinirimies/tokenburn).
@@ -44,9 +44,13 @@ 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;
@@ -54,7 +58,7 @@ export interface TokenBurnConfig {
54
58
  chartColor: boolean;
55
59
  showStatus: boolean;
56
60
  showWidget: boolean;
57
- statusWindow: "today" | "week" | "month" | "total";
61
+ statusWindow: Window;
58
62
  budgets: {
59
63
  dayTokens?: number;
60
64
  weekTokens?: number;
@@ -74,18 +78,24 @@ const DEFAULT_CONFIG: TokenBurnConfig = {
74
78
  budgets: {},
75
79
  };
76
80
 
77
- function getConfigDir(): string {
78
- if (process.env.PI_CODING_AGENT_DIR) return process.env.PI_CODING_AGENT_DIR;
79
- if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "pi", "agent");
80
- return join(homedir(), ".pi", "agent");
81
+ /** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
82
+ export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
83
+ if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
84
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
85
+ return join(home, ".pi", "agent");
81
86
  }
82
87
 
83
- function getSessionsDir(): string {
84
- if (process.env.TOKENBURN_PI_SESSIONS) return process.env.TOKENBURN_PI_SESSIONS;
85
- if (process.env.PI_CODING_AGENT_SESSION_DIR) return process.env.PI_CODING_AGENT_SESSION_DIR;
86
- return join(getConfigDir(), "sessions");
88
+ /** Pi session logs: TOKENBURN_PI_SESSIONS, PI_CODING_AGENT_SESSION_DIR, then <agent dir>/sessions. */
89
+ export function resolveSessionsDir(env: Record<string, string | undefined>, home: string): string {
90
+ if (env.TOKENBURN_PI_SESSIONS) return env.TOKENBURN_PI_SESSIONS;
91
+ if (env.PI_CODING_AGENT_SESSION_DIR) return env.PI_CODING_AGENT_SESSION_DIR;
92
+ return join(resolveAgentDir(env, home), "sessions");
87
93
  }
88
94
 
95
+ const getConfigDir = () => resolveAgentDir(process.env, homedir());
96
+
97
+ const getSessionsDir = () => resolveSessionsDir(process.env, homedir());
98
+
89
99
  export async function loadConfig(): Promise<TokenBurnConfig> {
90
100
  try {
91
101
  const raw = await readFile(join(getConfigDir(), "tokenburn.json"), "utf8");
@@ -95,16 +105,21 @@ export async function loadConfig(): Promise<TokenBurnConfig> {
95
105
  chartColor: typeof parsed.chartColor === "boolean" ? parsed.chartColor : DEFAULT_CONFIG.chartColor,
96
106
  showStatus: typeof parsed.showStatus === "boolean" ? parsed.showStatus : DEFAULT_CONFIG.showStatus,
97
107
  showWidget: typeof parsed.showWidget === "boolean" ? parsed.showWidget : DEFAULT_CONFIG.showWidget,
98
- statusWindow: ["today", "week", "month", "total"].includes(parsed.statusWindow)
108
+ statusWindow: (STATUS_WINDOWS as readonly string[]).includes(parsed.statusWindow)
99
109
  ? parsed.statusWindow
100
110
  : DEFAULT_CONFIG.statusWindow,
101
- budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? parsed.budgets : {},
111
+ budgets: typeof parsed.budgets === "object" && parsed.budgets !== null ? { ...parsed.budgets } : {},
102
112
  };
103
113
  } catch {
104
- return { ...DEFAULT_CONFIG };
114
+ return freshConfig();
105
115
  }
106
116
  }
107
117
 
118
+ /** A config that shares no mutable state with DEFAULT_CONFIG. */
119
+ export function freshConfig(): TokenBurnConfig {
120
+ return { ...DEFAULT_CONFIG, budgets: {} };
121
+ }
122
+
108
123
  export async function saveConfig(cfg: TokenBurnConfig): Promise<void> {
109
124
  await mkdir(getConfigDir(), { recursive: true });
110
125
  await writeFile(join(getConfigDir(), "tokenburn.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
@@ -163,6 +178,16 @@ async function parseFile(file: string): Promise<Turn[]> {
163
178
  return turns;
164
179
  }
165
180
 
181
+ /** Cache key for a file: mtime + size. `undefined` when it cannot be stat'ed. */
182
+ export function fileSignature(file: string): string | undefined {
183
+ try {
184
+ const st = statSync(file);
185
+ return `${st.mtimeMs}:${st.size}`;
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ }
190
+
166
191
  function listSessionFiles(root: string): string[] {
167
192
  const files: string[] = [];
168
193
  const walk = (dir: string) => {
@@ -200,13 +225,8 @@ async function loadAll(sessionsRoot: string): Promise<Turn[][]> {
200
225
 
201
226
  const out: Turn[][] = [];
202
227
  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
- }
228
+ const sig = fileSignature(file);
229
+ if (sig === undefined) continue; // vanished between listing and stat
210
230
  let entry = FILE_CACHE.get(file);
211
231
  if (!entry || entry.sig !== sig) {
212
232
  entry = { sig, turns: await parseFile(file) };
@@ -228,11 +248,13 @@ export async function collectUsage(
228
248
  const startOfDay = bucketStart(now.getTime(), "day");
229
249
  const startOfWeek = bucketStart(now.getTime(), "week");
230
250
  const startOfMonth = bucketStart(now.getTime(), "month");
251
+ const startOfYear = bucketStart(now.getTime(), "year");
231
252
 
232
253
  const report: TokenBurnReport = {
233
254
  today: emptyStats(),
234
255
  week: emptyStats(),
235
256
  month: emptyStats(),
257
+ year: emptyStats(),
236
258
  total: emptyStats(),
237
259
  };
238
260
 
@@ -250,11 +272,13 @@ export async function collectUsage(
250
272
  for (const t of turns) {
251
273
  if (t.ts > maxTs) maxTs = t.ts;
252
274
  add(report.total, t);
275
+ if (t.ts >= startOfYear) add(report.year, t);
253
276
  if (t.ts >= startOfMonth) add(report.month, t);
254
277
  if (t.ts >= startOfWeek) add(report.week, t);
255
278
  if (t.ts >= startOfDay) add(report.today, t);
256
279
  }
257
280
  if (turns.length > 0) report.total.sessions++;
281
+ if (maxTs >= startOfYear) report.year.sessions++;
258
282
  if (maxTs >= startOfMonth) report.month.sessions++;
259
283
  if (maxTs >= startOfWeek) report.week.sessions++;
260
284
  if (maxTs >= startOfDay) report.today.sessions++;
@@ -267,7 +291,7 @@ export async function collectUsage(
267
291
  // Time series + chart
268
292
  // ---------------------------------------------------------------------------
269
293
 
270
- export type Unit = "day" | "week" | "month";
294
+ export type Unit = "day" | "week" | "month" | "year";
271
295
 
272
296
  /** Local-time start of the day / Monday-week / month containing `ts`. */
273
297
  export function bucketStart(ts: number, unit: Unit): number {
@@ -277,6 +301,7 @@ export function bucketStart(ts: number, unit: Unit): number {
277
301
  const dow = (d.getDay() + 6) % 7; // Monday = 0
278
302
  return new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
279
303
  }
304
+ if (unit === "year") return new Date(d.getFullYear(), 0, 1).getTime();
280
305
  return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
281
306
  }
282
307
 
@@ -284,6 +309,7 @@ const pad2 = (n: number) => String(n).padStart(2, "0");
284
309
 
285
310
  export function bucketLabel(start: number, unit: Unit): string {
286
311
  const d = new Date(start);
312
+ if (unit === "year") return String(d.getFullYear());
287
313
  if (unit === "month") return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
288
314
  const base = `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
289
315
  return unit === "day" ? `${base} ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]}` : `${base} wk`;
@@ -296,7 +322,7 @@ export interface SeriesPoint {
296
322
  cost: number;
297
323
  }
298
324
 
299
- export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6 };
325
+ export const DEFAULT_COUNT: Record<Unit, number> = { day: 14, week: 8, month: 6, year: 5 };
300
326
 
301
327
  /** Last `count` buckets ending at `now`, oldest first, empty buckets zero-filled. */
302
328
  export async function collectSeries(
@@ -316,7 +342,9 @@ export async function collectSeries(
316
342
  ? new Date(y, m, day - i)
317
343
  : unit === "week"
318
344
  ? new Date(y, m, day - i * 7)
319
- : new Date(y, m - i, 1);
345
+ : unit === "year"
346
+ ? new Date(y - i, 0, 1)
347
+ : new Date(y, m - i, 1);
320
348
  starts.push(bucketStart(anchor.getTime(), unit));
321
349
  }
322
350
  const points = new Map<number, SeriesPoint>(
@@ -333,6 +361,27 @@ export async function collectSeries(
333
361
  return starts.map((s) => points.get(s)!);
334
362
  }
335
363
 
364
+ /**
365
+ * Whole history: monthly columns while it spans <= 18 months, yearly after that.
366
+ * Always at least 2 columns so the chart has a shape.
367
+ */
368
+ export async function collectAllSeries(
369
+ sessionsRoot: string,
370
+ now: Date = new Date(),
371
+ includeCache = true,
372
+ ): Promise<{ unit: Unit; series: SeriesPoint[] }> {
373
+ let first = now.getTime();
374
+ for (const turns of await loadAll(sessionsRoot)) for (const t of turns) if (t.ts < first) first = t.ts;
375
+ const f = new Date(first);
376
+ const months = (now.getFullYear() - f.getFullYear()) * 12 + (now.getMonth() - f.getMonth()) + 1;
377
+ if (months <= 18) {
378
+ const unit: Unit = "month";
379
+ return { unit, series: await collectSeries(sessionsRoot, unit, Math.max(2, months), now, includeCache) };
380
+ }
381
+ const years = now.getFullYear() - f.getFullYear() + 1;
382
+ return { unit: "year", series: await collectSeries(sessionsRoot, "year", years, now, includeCache) };
383
+ }
384
+
336
385
  const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
337
386
  const SPARK = "▁▂▃▄▅▆▇█";
338
387
 
@@ -350,8 +399,8 @@ export function sparkline(values: number[]): string {
350
399
  return values.map((v) => SPARK[Math.min(7, Math.round((v / max) * 7))]).join("");
351
400
  }
352
401
 
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" };
402
+ const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" };
403
+ const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month", year: "year" };
355
404
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
356
405
 
357
406
  export type Paint = (kind: "bar" | "now" | "over" | "axis" | "budget" | "dim" | "title", text: string) => string;
@@ -362,6 +411,7 @@ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
362
411
  const d = new Date(p.start);
363
412
  if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()]];
364
413
  if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
414
+ if (unit === "year") return [String(d.getFullYear()), ""];
365
415
  return [MONTHS[d.getMonth()], String(d.getFullYear())];
366
416
  }
367
417
 
@@ -372,7 +422,7 @@ function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
372
422
  export function renderChart(
373
423
  series: SeriesPoint[],
374
424
  unit: Unit,
375
- opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint } = {},
425
+ opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint; title?: string } = {},
376
426
  ): string[] {
377
427
  const paint = opts.paint ?? plain;
378
428
  const height = opts.height ?? 8;
@@ -398,8 +448,8 @@ export function renderChart(
398
448
 
399
449
  const lines: string[] = [];
400
450
  lines.push(
401
- paint("title", `${UNIT_TITLE[unit]} tokens`) +
402
- paint("dim", ` · last ${series.length} ${UNIT_NOUN[unit]}s`) +
451
+ paint("title", opts.title ?? `${UNIT_TITLE[unit]} tokens`) +
452
+ paint("dim", ` · ${opts.title ? "" : "last "}${series.length} ${UNIT_NOUN[unit]}${series.length === 1 ? "" : "s"}`) +
403
453
  (budgetTokens ? paint("budget", ` ┄ budget ${formatNum(budgetTokens)}`) : ""),
404
454
  );
405
455
 
@@ -443,8 +493,6 @@ export function clearCache(): void {
443
493
  FILE_CACHE.clear();
444
494
  }
445
495
 
446
- export type Window = "today" | "week" | "month" | "total";
447
-
448
496
  export function budgetsFor(cfg: TokenBurnConfig, w: Window): { tokens?: number; cost?: number } {
449
497
  const b = cfg.budgets;
450
498
  if (w === "today") return { tokens: b.dayTokens, cost: b.dayCost };
@@ -472,7 +520,7 @@ export function themePaint(theme: { fg: (c: any, t: string) => string }): Paint
472
520
  }
473
521
 
474
522
  export default function tokenBurnExtension(pi: ExtensionAPI) {
475
- let config: TokenBurnConfig = { ...DEFAULT_CONFIG };
523
+ let config: TokenBurnConfig = freshConfig();
476
524
 
477
525
  const updateUI = async (ctx: ExtensionContext) => {
478
526
  if (!ctx.hasUI) return;
@@ -510,6 +558,7 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
510
558
  line("Today", report.today, budgetsFor(config, "today")),
511
559
  line("Week", report.week, budgetsFor(config, "week")),
512
560
  line("Month", report.month, budgetsFor(config, "month")),
561
+ line("Year", report.year),
513
562
  line("Total", report.total),
514
563
  `└${"─".repeat(46)}┘`,
515
564
  ];
@@ -531,91 +580,200 @@ export default function tokenBurnExtension(pi: ExtensionAPI) {
531
580
  });
532
581
 
533
582
  pi.registerCommand("tokenburn", {
534
- description: "TokenBurn stats: show report, toggle widget, or set budgets",
535
- getArgumentCompletions: (prefix: string) => {
536
- const opts = ["report", "day", "week", "month", "today", "total", "cycle", "window", "cache", "status <today|week|month|total>", "budget <day|week|month> <tokens|$cost>"];
537
- return opts.filter((o) => o.startsWith(prefix)).map((value) => ({ value }));
538
- },
583
+ description: "TokenBurn: /tokenburn [day|week|month|year|all] chart, status <window>, cycle, window, budget, cache",
584
+ getArgumentCompletions: (prefix: string) => completions(prefix),
539
585
  handler: async (args, ctx) => {
540
- const [cmd, p1, p2] = (args ?? "").trim().split(/\s+/).filter(Boolean);
541
-
542
- if (cmd === "window" || cmd === "widget") {
543
- config.showWidget = !config.showWidget;
586
+ const cmd = parseCommand(args);
587
+ const persist = async () => {
544
588
  await saveConfig(config);
545
589
  await updateUI(ctx);
546
- ctx.ui.notify(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`, "info");
547
- return;
548
- }
549
-
550
- const WINDOWS = ["today", "week", "month", "total"] as const;
551
- const setWindow = async (w: (typeof WINDOWS)[number]) => {
552
- config.statusWindow = w;
553
- await saveConfig(config);
554
- await updateUI(ctx);
555
- ctx.ui.notify(`tokenburn status window: ${w}`, "info");
556
590
  };
557
591
 
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]);
570
- return;
571
- }
592
+ switch (cmd.kind) {
593
+ case "window":
594
+ config.showWidget = !config.showWidget;
595
+ await persist();
596
+ ctx.ui.notify(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`, "info");
597
+ return;
572
598
 
573
- if (cmd === "cache") {
574
- config.includeCache = !config.includeCache;
575
- await saveConfig(config);
576
- await updateUI(ctx);
577
- ctx.ui.notify(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`, "info");
578
- return;
579
- }
599
+ case "status":
600
+ config.statusWindow = cmd.window;
601
+ await persist();
602
+ ctx.ui.notify(`tokenburn status window: ${cmd.window}`, "info");
603
+ return;
580
604
 
581
- if (cmd === "budget") {
582
- const isCost = (p2 ?? "").startsWith("$");
583
- const val = Number((p2 ?? "").replace(/^\$/, ""));
584
- if (!["day", "week", "month"].includes(p1 ?? "") || !Number.isFinite(val) || val <= 0) {
585
- ctx.ui.notify("Usage: /tokenburn budget <day|week|month> <tokens | $cost>", "warning");
605
+ case "cycle": {
606
+ const next = STATUS_WINDOWS[(STATUS_WINDOWS.indexOf(config.statusWindow) + 1) % STATUS_WINDOWS.length];
607
+ config.statusWindow = next;
608
+ await persist();
609
+ ctx.ui.notify(`tokenburn status window: ${next}`, "info");
610
+ return;
611
+ }
612
+
613
+ case "cache":
614
+ config.includeCache = !config.includeCache;
615
+ await persist();
616
+ ctx.ui.notify(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`, "info");
617
+ return;
618
+
619
+ case "budget": {
620
+ const key = `${cmd.period}${cmd.isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
621
+ config.budgets[key] = cmd.amount;
622
+ await persist();
623
+ ctx.ui.notify(
624
+ `tokenburn ${cmd.period} budget: ${cmd.isCost ? formatCost(cmd.amount) : formatNum(cmd.amount) + " tokens"}`,
625
+ "info",
626
+ );
586
627
  return;
587
628
  }
588
- const key = `${p1}${isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
589
- config.budgets[key] = val;
590
- await saveConfig(config);
591
- await updateUI(ctx);
592
- ctx.ui.notify(`tokenburn ${p1} budget: ${isCost ? formatCost(val) : formatNum(val) + " tokens"}`, "info");
593
- return;
594
- }
595
629
 
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";
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";
602
- const formatRow = (name: string, p: PeriodStats) =>
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`;
604
-
605
- const output = [
606
- "🔥 TokenBurn Report",
607
- "─".repeat(60),
608
- formatRow("Today", report.today),
609
- formatRow("This Week", report.week),
610
- formatRow("This Month", report.month),
611
- formatRow("All Time", report.total),
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",
616
- ].join("\n");
617
-
618
- ctx.ui.notify(output, "info");
630
+ case "invalid":
631
+ ctx.ui.notify(cmd.message, "warning");
632
+ return;
633
+
634
+ case "report": {
635
+ const root = getSessionsDir();
636
+ const now = new Date();
637
+ const report = await collectUsage(root, now, config.includeCache);
638
+ const paint = config.chartColor ? themePaint(ctx.ui.theme) : undefined;
639
+
640
+ let chart: string[];
641
+ if (cmd.view === "all") {
642
+ const all = await collectAllSeries(root, now, config.includeCache);
643
+ chart = renderChart(all.series, all.unit, { paint, title: "All time" });
644
+ } else {
645
+ const series = await collectSeries(root, cmd.view, DEFAULT_COUNT[cmd.view], now, config.includeCache);
646
+ const budgetWindow: Window | undefined =
647
+ cmd.view === "day" ? "today" : cmd.view === "year" ? undefined : cmd.view;
648
+ chart = renderChart(series, cmd.view, {
649
+ paint,
650
+ budget: budgetWindow ? budgetsFor(config, budgetWindow) : undefined,
651
+ });
652
+ }
653
+
654
+ const row = (name: string, p: PeriodStats) =>
655
+ `${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`;
656
+ const rule = "─".repeat(60);
657
+ ctx.ui.notify(
658
+ [
659
+ "🔥 TokenBurn Report",
660
+ rule,
661
+ row("Today", report.today),
662
+ row("This Week", report.week),
663
+ row("This Month", report.month),
664
+ row("This Year", report.year),
665
+ row("All Time", report.total),
666
+ rule,
667
+ ...chart,
668
+ rule,
669
+ "/tokenburn day|week|month|year|all · status <window> · cycle · window · budget · cache",
670
+ ].join("\n"),
671
+ "info",
672
+ );
673
+ return;
674
+ }
675
+ }
619
676
  },
620
677
  });
621
678
  }
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // Command parsing + completions (pure, unit-tested)
682
+ // ---------------------------------------------------------------------------
683
+
684
+ export const REPORT_VIEWS = ["day", "week", "month", "year", "all"] as const;
685
+ export type ReportView = (typeof REPORT_VIEWS)[number];
686
+ export const BUDGET_PERIODS = ["day", "week", "month"] as const;
687
+
688
+ export type Command =
689
+ | { kind: "report"; view: ReportView }
690
+ | { kind: "status"; window: Window }
691
+ | { kind: "cycle" }
692
+ | { kind: "window" }
693
+ | { kind: "cache" }
694
+ | { kind: "budget"; period: (typeof BUDGET_PERIODS)[number]; amount: number; isCost: boolean }
695
+ | { kind: "invalid"; message: string };
696
+
697
+ export const USAGE =
698
+ "Usage: /tokenburn [day|week|month|year|all] · status <today|week|month|year|total> · cycle · window · budget <day|week|month> <tokens|$cost> · cache";
699
+
700
+ const asView = (w: string | undefined): ReportView | undefined =>
701
+ w === "today" ? "day" : (REPORT_VIEWS as readonly string[]).includes(w ?? "") ? (w as ReportView) : undefined;
702
+
703
+ export function parseCommand(args: string | undefined): Command {
704
+ const [cmd, p1, p2] = (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
705
+ if (!cmd) return { kind: "report", view: "day" };
706
+
707
+ const view = asView(cmd);
708
+ if (view) return { kind: "report", view };
709
+
710
+ switch (cmd) {
711
+ case "report":
712
+ case "chart": {
713
+ if (p1 === undefined) return { kind: "report", view: "day" };
714
+ const v = asView(p1);
715
+ return v ? { kind: "report", view: v } : { kind: "invalid", message: `Unknown view "${p1}". ${USAGE}` };
716
+ }
717
+ case "status":
718
+ return (STATUS_WINDOWS as readonly string[]).includes(p1 ?? "")
719
+ ? { kind: "status", window: p1 as Window }
720
+ : { kind: "invalid", message: `Usage: /tokenburn status <${STATUS_WINDOWS.join("|")}>` };
721
+ case "cycle":
722
+ return { kind: "cycle" };
723
+ case "window":
724
+ case "widget":
725
+ return { kind: "window" };
726
+ case "cache":
727
+ return { kind: "cache" };
728
+ case "budget": {
729
+ const isCost = (p2 ?? "").startsWith("$");
730
+ const amount = Number((p2 ?? "").replace(/^\$/, ""));
731
+ if (!(BUDGET_PERIODS as readonly string[]).includes(p1 ?? "") || !Number.isFinite(amount) || amount <= 0) {
732
+ return { kind: "invalid", message: "Usage: /tokenburn budget <day|week|month> <tokens | $cost>" };
733
+ }
734
+ return { kind: "budget", period: p1 as (typeof BUDGET_PERIODS)[number], amount, isCost };
735
+ }
736
+ default:
737
+ return { kind: "invalid", message: `Unknown option "${cmd}". ${USAGE}` };
738
+ }
739
+ }
740
+
741
+ export interface CompletionItem {
742
+ value: string;
743
+ /** Required by pi: it calls `label.endsWith(...)` when applying a completion. */
744
+ label: string;
745
+ description?: string;
746
+ }
747
+
748
+ const TOP_LEVEL: CompletionItem[] = [
749
+ { value: "day", label: "day", description: "Report + daily chart (last 14 days)" },
750
+ { value: "week", label: "week", description: "Report + weekly chart (last 8 weeks)" },
751
+ { value: "month", label: "month", description: "Report + monthly chart (last 6 months)" },
752
+ { value: "year", label: "year", description: "Report + yearly chart (last 5 years)" },
753
+ { value: "all", label: "all", description: "Report + whole history" },
754
+ { value: "status ", label: "status", description: "Set the status-bar period" },
755
+ { value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
756
+ { value: "window", label: "window", description: "Toggle the panel below the editor" },
757
+ { value: "budget ", label: "budget", description: "Set a day/week/month budget" },
758
+ { value: "cache", label: "cache", description: "Toggle counting cache tokens" },
759
+ ];
760
+
761
+ /** `prefix` is the whole argument text typed so far (pi replaces it with `value`). */
762
+ export function completions(prefix: string): CompletionItem[] | null {
763
+ const text = (prefix ?? "").toLowerCase().replace(/^\s+/, "");
764
+ // `tail` = " " when the completed text still needs another word (a budget amount).
765
+ const sub = (head: string, words: readonly string[], desc: (w: string) => string, tail = ""): CompletionItem[] =>
766
+ words.map((w) => ({ value: `${head} ${w}${tail}`, label: w, description: desc(w) }));
767
+
768
+ let pool: CompletionItem[];
769
+ if (text.startsWith("status ")) pool = sub("status", STATUS_WINDOWS, (w) => `Show ${w} in the status bar`);
770
+ else if (text.startsWith("budget ") && !/^budget \S+ /.test(text))
771
+ pool = sub("budget", BUDGET_PERIODS, (w) => `Set the ${w} budget (tokens or $cost)`, " ");
772
+ else if (/^(report|chart) /.test(text)) {
773
+ const head = text.split(" ")[0];
774
+ pool = sub(head, REPORT_VIEWS, (w) => `${head} for ${w}`);
775
+ } else pool = TOP_LEVEL;
776
+
777
+ const out = pool.filter((i) => i.value.startsWith(text));
778
+ return out.length > 0 ? out : null;
779
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tokenburn",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
  }