tokmon 0.9.1 → 0.10.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 (3) hide show
  1. package/README.md +3 -1
  2. package/dist/cli.js +114 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -73,6 +73,7 @@ Then just run `tokmon`. Press `q` to quit.
73
73
  - **Today / This Week / This Month** — cost and token summaries
74
74
  - **Burn rate** — current $/hr
75
75
  - **Rate Limits** — real-time session (5h), weekly (7d), and Sonnet utilization with reset countdowns, fetched from Anthropic's OAuth API
76
+ - **Peak / Off-Peak badge** — shown in the header, fetched from [promoclock.co](https://promoclock.co) (peak hours drain session limits faster)
76
77
 
77
78
  ### Table
78
79
 
@@ -96,7 +97,8 @@ Sort by date or cost with `o`.
96
97
 
97
98
  Press `s` to open. Persisted to `~/.config/tokmon/config.json` (macOS/Linux) or `%APPDATA%\tokmon\config.json` (Windows).
98
99
 
99
- - **Refresh interval** — adjust with `←` `→`
100
+ - **Refresh interval** — dashboard poll rate (default: 2s)
101
+ - **Billing poll** — rate limits API poll rate (default: 5m, min 1m to avoid 429s)
100
102
  - **Clear screen** — clears terminal on launch (like `watch`)
101
103
 
102
104
  ## How It Works
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { MouseProvider } from "@zenobius/ink-mouse";
8
8
  import { readFile, writeFile, mkdir } from "fs/promises";
9
9
  import { join } from "path";
10
10
  import { homedir } from "os";
11
- var DEFAULTS = { interval: 2, clearScreen: true };
11
+ var DEFAULTS = { interval: 2, billingInterval: 5, clearScreen: true };
12
12
  function configDir() {
13
13
  if (process.platform === "win32") {
14
14
  return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "tokmon");
@@ -297,10 +297,18 @@ async function getAccessToken() {
297
297
  }
298
298
  return readCredentialsFile();
299
299
  }
300
- var EMPTY = { session: null, weekly: null, sonnet: null, extraUsage: null, error: null };
300
+ var EMPTY = { session: null, weekly: null, sonnet: null, extraUsage: null, peak: null, error: null };
301
301
  async function fetchBilling() {
302
302
  const token = await getAccessToken();
303
303
  if (!token) return { ...EMPTY, error: "No OAuth token \u2014 run claude and log in" };
304
+ const [usageRes, peak] = await Promise.all([
305
+ fetchUsage(token),
306
+ fetchPeakStatus()
307
+ ]);
308
+ if ("error" in usageRes) return { ...EMPTY, peak, error: usageRes.error };
309
+ return { ...usageRes.data, peak, error: null };
310
+ }
311
+ async function fetchUsage(token) {
304
312
  try {
305
313
  const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
306
314
  headers: {
@@ -310,31 +318,54 @@ async function fetchBilling() {
310
318
  },
311
319
  signal: AbortSignal.timeout(1e4)
312
320
  });
313
- if (res.status === 429) return { ...EMPTY, error: "Rate limited \u2014 retrying in 2m" };
314
- if (res.status === 401) return { ...EMPTY, error: "Token expired \u2014 restart Claude Code" };
315
- if (!res.ok) return { ...EMPTY, error: `API ${res.status}` };
321
+ if (res.status === 429) return { error: "Rate limited \u2014 retrying next poll" };
322
+ if (res.status === 401) return { error: "Token expired \u2014 restart Claude Code" };
323
+ if (!res.ok) return { error: `API ${res.status}` };
324
+ const data = await res.json();
325
+ return {
326
+ data: {
327
+ session: data.five_hour ? {
328
+ utilization: data.five_hour.utilization,
329
+ resetsAt: formatReset(data.five_hour.resets_at)
330
+ } : null,
331
+ weekly: data.seven_day ? {
332
+ utilization: data.seven_day.utilization,
333
+ resetsAt: formatReset(data.seven_day.resets_at)
334
+ } : null,
335
+ sonnet: data.seven_day_sonnet ? {
336
+ utilization: data.seven_day_sonnet.utilization,
337
+ resetsAt: formatReset(data.seven_day_sonnet.resets_at)
338
+ } : null,
339
+ extraUsage: data.extra_usage?.is_enabled ? {
340
+ limit: data.extra_usage.monthly_limit / 100,
341
+ used: data.extra_usage.used_credits / 100
342
+ } : null
343
+ }
344
+ };
345
+ } catch {
346
+ return { error: "Network error" };
347
+ }
348
+ }
349
+ async function fetchPeakStatus() {
350
+ try {
351
+ const res = await fetch("https://promoclock.co/api/status", {
352
+ headers: { "Accept": "application/json", "User-Agent": "tokmon" },
353
+ signal: AbortSignal.timeout(3e3)
354
+ });
355
+ if (!res.ok) return null;
316
356
  const data = await res.json();
357
+ let state;
358
+ if (data.isPeak === true || data.status === "peak") state = "peak";
359
+ else if (data.isWeekend === true || data.status === "weekend") state = "weekend";
360
+ else if (data.isOffPeak === true || data.status === "off_peak" || data.status === "off-peak") state = "off-peak";
361
+ else return null;
317
362
  return {
318
- session: data.five_hour ? {
319
- utilization: data.five_hour.utilization,
320
- resetsAt: formatReset(data.five_hour.resets_at)
321
- } : null,
322
- weekly: data.seven_day ? {
323
- utilization: data.seven_day.utilization,
324
- resetsAt: formatReset(data.seven_day.resets_at)
325
- } : null,
326
- sonnet: data.seven_day_sonnet ? {
327
- utilization: data.seven_day_sonnet.utilization,
328
- resetsAt: formatReset(data.seven_day_sonnet.resets_at)
329
- } : null,
330
- extraUsage: data.extra_usage?.is_enabled ? {
331
- limit: data.extra_usage.monthly_limit / 100,
332
- used: data.extra_usage.used_credits / 100
333
- } : null,
334
- error: null
363
+ state,
364
+ label: state === "peak" ? "Peak" : state === "weekend" ? "Weekend" : "Off-Peak",
365
+ minutesUntilChange: typeof data.minutesUntilChange === "number" ? data.minutesUntilChange : null
335
366
  };
336
367
  } catch {
337
- return { ...EMPTY, error: "Network error" };
368
+ return null;
338
369
  }
339
370
  }
340
371
  function formatReset(iso) {
@@ -387,7 +418,7 @@ var VIEWS = ["Daily", "Weekly", "Monthly"];
387
418
  var SORTS = ["date \u2191", "date \u2193", "cost \u2191", "cost \u2193"];
388
419
  var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
389
420
  var MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
390
- var DEFAULT_CONFIG = { interval: 2, clearScreen: true };
421
+ var DEFAULT_CONFIG = { interval: 2, billingInterval: 5, clearScreen: true };
391
422
  var IS_TTY = process.stdin.isTTY === true;
392
423
  function App({ interval: cliInterval }) {
393
424
  const [dashboard, setDashboard] = useState(null);
@@ -438,6 +469,7 @@ function App({ interval: cliInterval }) {
438
469
  clearInterval(id);
439
470
  };
440
471
  }, [interval2]);
472
+ const billingMs = cfg.billingInterval * 6e4;
441
473
  useEffect(() => {
442
474
  let active = true;
443
475
  const load = () => fetchBilling().then((b) => {
@@ -445,12 +477,12 @@ function App({ interval: cliInterval }) {
445
477
  }).catch(() => {
446
478
  });
447
479
  load();
448
- const id = setInterval(load, 12e4);
480
+ const id = setInterval(load, billingMs);
449
481
  return () => {
450
482
  active = false;
451
483
  clearInterval(id);
452
484
  };
453
- }, []);
485
+ }, [billingMs]);
454
486
  useEffect(() => {
455
487
  if (tab !== 1) return;
456
488
  if (tableLoadedOnce.current && table) return;
@@ -503,23 +535,27 @@ function App({ interval: cliInterval }) {
503
535
  };
504
536
  }, [tab]);
505
537
  useInput((input, key) => {
538
+ if (input === "q") {
539
+ exit();
540
+ return;
541
+ }
506
542
  if (showSettings) {
507
543
  if (key.escape || input === "s") setShowSettings(false);
508
544
  if (key.upArrow) setSettingsCursor((c) => Math.max(0, c - 1));
509
- if (key.downArrow) setSettingsCursor((c) => Math.min(1, c + 1));
545
+ if (key.downArrow) setSettingsCursor((c) => Math.min(2, c + 1));
510
546
  if (settingsCursor === 0) {
511
547
  if (key.leftArrow) updateConfig((c) => ({ ...c, interval: Math.max(1, c.interval - 1) }));
512
548
  if (key.rightArrow) updateConfig((c) => ({ ...c, interval: c.interval + 1 }));
513
549
  }
514
- if (settingsCursor === 1 && (key.leftArrow || key.rightArrow || key.return)) {
550
+ if (settingsCursor === 1) {
551
+ if (key.leftArrow) updateConfig((c) => ({ ...c, billingInterval: Math.max(1, c.billingInterval - 1) }));
552
+ if (key.rightArrow) updateConfig((c) => ({ ...c, billingInterval: c.billingInterval + 1 }));
553
+ }
554
+ if (settingsCursor === 2 && (key.leftArrow || key.rightArrow || key.return)) {
515
555
  updateConfig((c) => ({ ...c, clearScreen: !c.clearScreen }));
516
556
  }
517
557
  return;
518
558
  }
519
- if (input === "q") {
520
- exit();
521
- return;
522
- }
523
559
  if (input === "s") {
524
560
  setShowSettings(true);
525
561
  return;
@@ -626,7 +662,13 @@ function App({ interval: cliInterval }) {
626
662
  "s"
627
663
  ] })
628
664
  ] }),
629
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: time(updated) })
665
+ /* @__PURE__ */ jsxs(Box, { children: [
666
+ billing?.peak && /* @__PURE__ */ jsxs(Fragment, { children: [
667
+ /* @__PURE__ */ jsx(PeakBadge, { peak: billing.peak }),
668
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " })
669
+ ] }),
670
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: time(updated) })
671
+ ] })
630
672
  ] }),
631
673
  showSettings ? /* @__PURE__ */ jsx(SettingsView, { config: cfg, cursor: settingsCursor }) : /* @__PURE__ */ jsxs(Fragment, { children: [
632
674
  /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
@@ -721,7 +763,7 @@ function SettingsView({ config: config2, cursor }) {
721
763
  cursor === 0 ? "\u25B8" : " ",
722
764
  " "
723
765
  ] }),
724
- /* @__PURE__ */ jsx(Text, { children: "Refresh interval " }),
766
+ /* @__PURE__ */ jsx(Text, { children: "Refresh interval " }),
725
767
  /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
726
768
  "\u25C2",
727
769
  " "
@@ -740,7 +782,26 @@ function SettingsView({ config: config2, cursor }) {
740
782
  cursor === 1 ? "\u25B8" : " ",
741
783
  " "
742
784
  ] }),
743
- /* @__PURE__ */ jsx(Text, { children: "Clear screen " }),
785
+ /* @__PURE__ */ jsx(Text, { children: "Billing poll " }),
786
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
787
+ "\u25C2",
788
+ " "
789
+ ] }),
790
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "yellow", children: [
791
+ config2.billingInterval,
792
+ "m"
793
+ ] }),
794
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
795
+ " ",
796
+ "\u25B8"
797
+ ] })
798
+ ] }),
799
+ /* @__PURE__ */ jsxs(Box, { children: [
800
+ /* @__PURE__ */ jsxs(Text, { color: cursor === 2 ? "green" : void 0, children: [
801
+ cursor === 2 ? "\u25B8" : " ",
802
+ " "
803
+ ] }),
804
+ /* @__PURE__ */ jsx(Text, { children: "Clear screen " }),
744
805
  /* @__PURE__ */ jsx(Text, { bold: true, color: config2.clearScreen ? "green" : "red", children: config2.clearScreen ? "on" : "off" })
745
806
  ] }),
746
807
  /* @__PURE__ */ jsx(Box, { height: 1 }),
@@ -812,6 +873,24 @@ function DashboardView({ data, billing }) {
812
873
  )
813
874
  ] });
814
875
  }
876
+ function PeakBadge({ peak }) {
877
+ const color = peak.state === "peak" ? "red" : "green";
878
+ return /* @__PURE__ */ jsxs(Box, { children: [
879
+ /* @__PURE__ */ jsx(Text, { color, children: "\u25CF " }),
880
+ /* @__PURE__ */ jsx(Text, { bold: true, color, children: peak.label }),
881
+ peak.minutesUntilChange !== null && peak.minutesUntilChange > 0 && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
882
+ " (",
883
+ fmtMinutes(peak.minutesUntilChange),
884
+ ")"
885
+ ] })
886
+ ] });
887
+ }
888
+ function fmtMinutes(mins) {
889
+ if (mins < 60) return `${mins}m`;
890
+ const h = Math.floor(mins / 60);
891
+ const m = mins % 60;
892
+ return m === 0 ? `${h}h` : `${h}h ${m}m`;
893
+ }
815
894
  function LimitBar({ label, pct, resets }) {
816
895
  const width = 30;
817
896
  const filled = Math.round(pct / 100 * width);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokmon",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Terminal dashboard for Claude Code usage and costs",
5
5
  "type": "module",
6
6
  "bin": {