tokens-metric 0.2.4 → 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.
@@ -39,6 +39,20 @@ export function estimateCostUSD(model, u) {
39
39
  u.cache_creation_input_tokens * p.cacheWrite * perTok +
40
40
  u.cache_read_input_tokens * p.cacheRead * perTok);
41
41
  }
42
+ export function categoryCostUSD(model, category, tokens) {
43
+ const key = priceKey(model);
44
+ if (!key)
45
+ return null;
46
+ const p = PRICES_PER_MTOK[key];
47
+ const rate = category === 'input'
48
+ ? p.in
49
+ : category === 'output'
50
+ ? p.out
51
+ : category === 'cacheWrite'
52
+ ? p.cacheWrite
53
+ : p.cacheRead;
54
+ return (tokens * rate) / 1_000_000;
55
+ }
42
56
  export function fmtUSD(n) {
43
57
  if (n < 0.01)
44
58
  return `$${n.toFixed(4)}`;
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { addUsage, EMPTY_USAGE } from './types.js';
5
+ const STORE_VERSION = 1;
6
+ const STORE_PATH = () => join(homedir(), '.tokens-metric', 'history.json');
7
+ /**
8
+ * Read the on-disk store. Returns an empty map if the file does not exist
9
+ * or is unreadable/malformed — the caller can keep working from live data.
10
+ */
11
+ export function loadStore() {
12
+ const path = STORE_PATH();
13
+ if (!existsSync(path))
14
+ return new Map();
15
+ try {
16
+ const raw = readFileSync(path, 'utf8');
17
+ const parsed = JSON.parse(raw);
18
+ if (parsed?.version !== STORE_VERSION || !parsed.days)
19
+ return new Map();
20
+ const out = new Map();
21
+ for (const [date, day] of Object.entries(parsed.days)) {
22
+ const dayMs = parseDateKey(date);
23
+ if (dayMs === null)
24
+ continue;
25
+ const byModel = {};
26
+ for (const [model, usage] of Object.entries(day.byModel ?? {})) {
27
+ byModel[model] = sanitizeUsage(usage);
28
+ }
29
+ out.set(dayMs, {
30
+ byModel,
31
+ sessions: new Set(Array.isArray(day.sessions) ? day.sessions : []),
32
+ });
33
+ }
34
+ return out;
35
+ }
36
+ catch {
37
+ return new Map();
38
+ }
39
+ }
40
+ /**
41
+ * Persist a snapshot of per-day aggregates atomically. Failures are
42
+ * swallowed — losing one write is recoverable on next refresh.
43
+ */
44
+ export function saveStore(days) {
45
+ const path = STORE_PATH();
46
+ try {
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ const out = { version: STORE_VERSION, days: {} };
49
+ for (const [dayMs, agg] of days) {
50
+ out.days[formatDateKey(dayMs)] = {
51
+ byModel: agg.byModel,
52
+ sessions: Array.from(agg.sessions),
53
+ };
54
+ }
55
+ const tmp = `${path}.tmp`;
56
+ writeFileSync(tmp, JSON.stringify(out), 'utf8');
57
+ renameSync(tmp, path);
58
+ }
59
+ catch {
60
+ // ignore — next refresh will retry
61
+ }
62
+ }
63
+ export function mergeDayInto(target, dayMs, byModel, sessions) {
64
+ const existing = target.get(dayMs) ?? { byModel: {}, sessions: new Set() };
65
+ for (const [model, usage] of Object.entries(byModel)) {
66
+ existing.byModel[model] = addUsage(existing.byModel[model] ?? EMPTY_USAGE(), usage);
67
+ }
68
+ for (const sid of sessions)
69
+ existing.sessions.add(sid);
70
+ target.set(dayMs, existing);
71
+ }
72
+ export function replaceDayIn(target, dayMs, byModel, sessions) {
73
+ const next = { byModel: {}, sessions: new Set(sessions) };
74
+ for (const [model, usage] of Object.entries(byModel)) {
75
+ next.byModel[model] = { ...usage };
76
+ }
77
+ target.set(dayMs, next);
78
+ }
79
+ function formatDateKey(ms) {
80
+ const d = new Date(ms);
81
+ const y = d.getFullYear();
82
+ const m = String(d.getMonth() + 1).padStart(2, '0');
83
+ const day = String(d.getDate()).padStart(2, '0');
84
+ return `${y}-${m}-${day}`;
85
+ }
86
+ function parseDateKey(s) {
87
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
88
+ if (!m)
89
+ return null;
90
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0, 0);
91
+ return Number.isFinite(d.getTime()) ? d.getTime() : null;
92
+ }
93
+ function sanitizeUsage(u) {
94
+ const x = (u ?? {});
95
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
96
+ return {
97
+ input_tokens: n(x.input_tokens),
98
+ output_tokens: n(x.output_tokens),
99
+ cache_creation_input_tokens: n(x.cache_creation_input_tokens),
100
+ cache_read_input_tokens: n(x.cache_read_input_tokens),
101
+ };
102
+ }
@@ -0,0 +1,269 @@
1
+ import { readdirSync, statSync, createReadStream, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { claudeHome } from './detect.js';
5
+ import { addUsage, EMPTY_USAGE, totalTokens } from './types.js';
6
+ import { estimateCostUSD } from './format.js';
7
+ import { loadStore, replaceDayIn, saveStore, } from './history-store.js';
8
+ const cache = new Map();
9
+ let storedDays = null;
10
+ export async function buildHistory(now = Date.now()) {
11
+ if (storedDays === null)
12
+ storedDays = loadStore();
13
+ const root = join(claudeHome(), 'projects');
14
+ if (!existsSync(root)) {
15
+ // No transcripts available; surface whatever the store has.
16
+ return aggregate(now);
17
+ }
18
+ const files = listAllJsonl(root);
19
+ for (const f of files) {
20
+ const cached = cache.get(f.path);
21
+ if (!cached || cached.mtimeMs < f.mtimeMs) {
22
+ const parsed = await parseFile(f.path);
23
+ cache.set(f.path, {
24
+ mtimeMs: f.mtimeMs,
25
+ byDay: parsed.byDay,
26
+ sessionId: parsed.sessionId,
27
+ earliestEventMs: parsed.earliestEventMs,
28
+ latestEventMs: parsed.latestEventMs,
29
+ cwd: parsed.cwd,
30
+ });
31
+ }
32
+ }
33
+ const known = new Set(files.map((f) => f.path));
34
+ for (const key of Array.from(cache.keys())) {
35
+ if (!known.has(key))
36
+ cache.delete(key);
37
+ }
38
+ return aggregate(now);
39
+ }
40
+ function aggregate(now) {
41
+ const startToday = startOfDay(now);
42
+ const start7 = startToday - 6 * 86_400_000;
43
+ const start30 = startToday - 29 * 86_400_000;
44
+ // Build the live per-day map from the in-memory cache. Each file knows its
45
+ // sessionId, so we attach it to every day that file contributed to.
46
+ const liveDays = new Map();
47
+ for (const [, file] of cache) {
48
+ for (const [dayStart, models] of file.byDay) {
49
+ const entry = liveDays.get(dayStart) ?? { byModel: {}, sessions: new Set() };
50
+ entry.sessions.add(file.sessionId);
51
+ for (const [model, usage] of Object.entries(models)) {
52
+ entry.byModel[model] = addUsage(entry.byModel[model] ?? EMPTY_USAGE(), usage);
53
+ }
54
+ liveDays.set(dayStart, entry);
55
+ }
56
+ }
57
+ // Merge: start from the stored archive, then overwrite any day where live
58
+ // data exists (live transcripts are the authoritative source for days they
59
+ // cover; the store keeps days whose transcripts no longer exist on disk).
60
+ const merged = new Map();
61
+ if (storedDays) {
62
+ for (const [day, agg] of storedDays) {
63
+ merged.set(day, {
64
+ byModel: { ...agg.byModel },
65
+ sessions: new Set(agg.sessions),
66
+ });
67
+ }
68
+ }
69
+ for (const [day, agg] of liveDays) {
70
+ replaceDayIn(merged, day, agg.byModel, agg.sessions);
71
+ }
72
+ // Persist the merged view so historical days survive transcript deletion or
73
+ // a future Claude Code log rotation.
74
+ storedDays = merged;
75
+ saveStore(merged);
76
+ // Earliest data point — prefer the stored archive's earliest day key (since
77
+ // events from deleted transcripts only live there now), but the live
78
+ // earliest event timestamp is more precise when it sits within the archive.
79
+ let oldest = null;
80
+ for (const day of merged.keys()) {
81
+ if (oldest === null || day < oldest)
82
+ oldest = day;
83
+ }
84
+ for (const [, file] of cache) {
85
+ const ts = file.earliestEventMs;
86
+ if (ts !== null && (oldest === null || ts < oldest))
87
+ oldest = ts;
88
+ }
89
+ const snap = {
90
+ today: { byModel: {}, sessions: new Set() },
91
+ d7: { byModel: {}, sessions: new Set() },
92
+ d30: { byModel: {}, sessions: new Set() },
93
+ scannedFiles: cache.size,
94
+ generatedAt: now,
95
+ oldestMtimeMs: oldest,
96
+ };
97
+ for (const [dayStart, agg] of merged) {
98
+ const buckets = [];
99
+ if (dayStart >= startToday)
100
+ buckets.push(snap.today);
101
+ if (dayStart >= start7)
102
+ buckets.push(snap.d7);
103
+ if (dayStart >= start30)
104
+ buckets.push(snap.d30);
105
+ if (buckets.length === 0)
106
+ continue;
107
+ for (const b of buckets) {
108
+ for (const sid of agg.sessions)
109
+ b.sessions.add(sid);
110
+ for (const [model, usage] of Object.entries(agg.byModel)) {
111
+ b.byModel[model] = addUsage(b.byModel[model] ?? EMPTY_USAGE(), usage);
112
+ }
113
+ }
114
+ }
115
+ return snap;
116
+ }
117
+ export function bucketTokens(b) {
118
+ let n = 0;
119
+ for (const u of Object.values(b.byModel))
120
+ n += totalTokens(u);
121
+ return n;
122
+ }
123
+ export function bucketCostUSD(b) {
124
+ let total = 0;
125
+ let any = false;
126
+ for (const [model, u] of Object.entries(b.byModel)) {
127
+ const c = estimateCostUSD(model, u);
128
+ if (c !== null) {
129
+ total += c;
130
+ any = true;
131
+ }
132
+ }
133
+ return any ? total : null;
134
+ }
135
+ export function bucketTopModel(b) {
136
+ let topModel = null;
137
+ let topTokens = -1;
138
+ for (const [model, u] of Object.entries(b.byModel)) {
139
+ const t = totalTokens(u);
140
+ if (t > topTokens) {
141
+ topTokens = t;
142
+ topModel = model;
143
+ }
144
+ }
145
+ return topModel;
146
+ }
147
+ function emptySnapshot(now, scannedFiles) {
148
+ return {
149
+ today: { byModel: {}, sessions: new Set() },
150
+ d7: { byModel: {}, sessions: new Set() },
151
+ d30: { byModel: {}, sessions: new Set() },
152
+ scannedFiles,
153
+ generatedAt: now,
154
+ oldestMtimeMs: null,
155
+ };
156
+ }
157
+ function startOfDay(ms) {
158
+ const d = new Date(ms);
159
+ d.setHours(0, 0, 0, 0);
160
+ return d.getTime();
161
+ }
162
+ function listAllJsonl(root) {
163
+ const out = [];
164
+ for (const projectDir of safeReaddir(root)) {
165
+ const full = join(root, projectDir);
166
+ let st;
167
+ try {
168
+ st = statSync(full);
169
+ }
170
+ catch {
171
+ continue;
172
+ }
173
+ if (!st.isDirectory())
174
+ continue;
175
+ for (const f of safeReaddir(full)) {
176
+ if (!f.endsWith('.jsonl'))
177
+ continue;
178
+ const p = join(full, f);
179
+ try {
180
+ const s = statSync(p);
181
+ out.push({ path: p, mtimeMs: s.mtimeMs });
182
+ }
183
+ catch {
184
+ // ignore
185
+ }
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ function safeReaddir(p) {
191
+ try {
192
+ return readdirSync(p);
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ }
198
+ async function parseFile(path) {
199
+ const byDay = new Map();
200
+ const sessionId = (path.split('/').pop() ?? path).replace(/\.jsonl$/, '');
201
+ let earliestEventMs = null;
202
+ let latestEventMs = null;
203
+ let cwd;
204
+ const stream = createReadStream(path, { encoding: 'utf8' });
205
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
206
+ for await (const line of rl) {
207
+ if (!line.trim())
208
+ continue;
209
+ let evt;
210
+ try {
211
+ evt = JSON.parse(line);
212
+ }
213
+ catch {
214
+ continue;
215
+ }
216
+ if (typeof evt?.cwd === 'string' && !cwd)
217
+ cwd = evt.cwd;
218
+ const ts = typeof evt?.timestamp === 'string' ? Date.parse(evt.timestamp) : NaN;
219
+ if (Number.isFinite(ts)) {
220
+ if (earliestEventMs === null || ts < earliestEventMs)
221
+ earliestEventMs = ts;
222
+ if (latestEventMs === null || ts > latestEventMs)
223
+ latestEventMs = ts;
224
+ }
225
+ const message = evt?.message;
226
+ const usage = message?.usage;
227
+ if (!usage)
228
+ continue;
229
+ if (!Number.isFinite(ts))
230
+ continue;
231
+ const day = startOfDay(ts);
232
+ const model = typeof message.model === 'string' ? message.model : 'unknown';
233
+ const u = {
234
+ input_tokens: numberOr0(usage.input_tokens),
235
+ output_tokens: numberOr0(usage.output_tokens),
236
+ cache_creation_input_tokens: numberOr0(usage.cache_creation_input_tokens),
237
+ cache_read_input_tokens: numberOr0(usage.cache_read_input_tokens),
238
+ };
239
+ const bucket = byDay.get(day) ?? {};
240
+ bucket[model] = addUsage(bucket[model] ?? EMPTY_USAGE(), u);
241
+ byDay.set(day, bucket);
242
+ }
243
+ return { byDay, sessionId, earliestEventMs, latestEventMs, cwd };
244
+ }
245
+ function numberOr0(v) {
246
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
247
+ }
248
+ /**
249
+ * Returns all sessions (transcripts) that had activity today, sorted by
250
+ * start time descending (most recent first). Marks the active one.
251
+ */
252
+ export function getTodaySessions(now, activePath) {
253
+ const startToday = startOfDay(now);
254
+ const out = [];
255
+ for (const [path, file] of cache) {
256
+ const todayUsage = file.byDay.get(startToday);
257
+ if (!todayUsage)
258
+ continue;
259
+ out.push({
260
+ sessionId: file.sessionId,
261
+ cwd: file.cwd,
262
+ startedAt: file.earliestEventMs,
263
+ endedAt: file.latestEventMs,
264
+ byModel: todayUsage,
265
+ isActive: path === activePath,
266
+ });
267
+ }
268
+ return out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
269
+ }
package/dist/tui/bars.js CHANGED
@@ -25,17 +25,25 @@ const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
25
25
  * series max. Returns empty cells when there's no data yet.
26
26
  */
27
27
  export function sparkline(values, width) {
28
+ return sparklineCells(values, width)
29
+ .map((c) => c.char)
30
+ .join('');
31
+ }
32
+ /**
33
+ * Per-cell version of `sparkline` so the caller can color each character
34
+ * individually based on intensity.
35
+ */
36
+ export function sparklineCells(values, width) {
28
37
  if (width <= 0)
29
- return '';
38
+ return [];
30
39
  const tail = values.slice(-width);
31
40
  const padded = tail.length < width ? Array(width - tail.length).fill(0).concat(tail) : tail;
32
41
  const max = Math.max(1, ...padded);
33
- return padded
34
- .map((v) => {
42
+ return padded.map((v) => {
35
43
  if (v <= 0)
36
- return ' ';
37
- const idx = Math.min(SPARK.length - 1, Math.floor((v / max) * (SPARK.length - 1)));
38
- return SPARK[idx];
39
- })
40
- .join('');
44
+ return { char: ' ', intensity: 0 };
45
+ const ratio = v / max;
46
+ const idx = Math.min(SPARK.length - 1, Math.floor(ratio * (SPARK.length - 1)));
47
+ return { char: SPARK[idx], intensity: ratio };
48
+ });
41
49
  }
package/dist/tui/index.js CHANGED
@@ -5,17 +5,20 @@ import { render, Box, Text, useApp, useInput } from 'ink';
5
5
  import { findActiveTranscript, listTranscripts } from '../core/parser.js';
6
6
  import { tailTranscript } from '../core/tailer.js';
7
7
  import { detectAuth } from '../core/detect.js';
8
- import { estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
8
+ import { categoryCostUSD, estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
9
+ import { buildHistory, bucketCostUSD, bucketTokens, bucketTopModel, getTodaySessions, } from '../core/history.js';
9
10
  import { totalTokens } from '../core/types.js';
10
- import { anonymizePath, maskUserId } from '../core/privacy.js';
11
+ import { anonymizePath } from '../core/privacy.js';
11
12
  import { HELP_TEXT, parseArgs } from '../core/args.js';
12
- import { bar, sparkline } from './bars.js';
13
+ import pkg from '../../package.json' assert { type: 'json' };
14
+ import { bar, sparklineCells } from './bars.js';
13
15
  const OPTS = parseArgs(process.argv);
14
16
  if (OPTS.help) {
15
17
  process.stdout.write(HELP_TEXT);
16
18
  process.exit(0);
17
19
  }
18
20
  const RESCAN_MS = 3_000;
21
+ const HISTORY_REFRESH_MS = 60_000;
19
22
  const SPARK_WIDTH = 32;
20
23
  const BAR_WIDTH = 20;
21
24
  function App() {
@@ -23,11 +26,12 @@ function App() {
23
26
  const [auth] = useState(() => detectAuth());
24
27
  const [stats, setStats] = useState(null);
25
28
  const [transcriptPath, setTranscriptPath] = useState(null);
26
- const [rate, setRate] = useState(0);
27
29
  const [series, setSeries] = useState(() => Array(SPARK_WIDTH).fill(0));
28
30
  const [now, setNow] = useState(Date.now());
31
+ const [lastTailAt, setLastTailAt] = useState(null);
32
+ const [history, setHistory] = useState(null);
33
+ const startedAtRef = useRef(Date.now());
29
34
  const lastTotalRef = useRef(0);
30
- const lastSampleAtRef = useRef(Date.now());
31
35
  // useInput requires raw mode (interactive TTY). Skip it when stdin is piped
32
36
  // or otherwise non-interactive, so `node dist/tui/index.js | cat` still
33
37
  // renders instead of crashing.
@@ -39,6 +43,27 @@ function App() {
39
43
  exit();
40
44
  });
41
45
  }
46
+ // Historical aggregate over all transcripts. Refreshed on an interval —
47
+ // the live session is already shown in SessionPanel, so minute-grain
48
+ // accuracy on today's bucket is enough and keeps full parses out of the
49
+ // hot path.
50
+ useEffect(() => {
51
+ let cancelled = false;
52
+ const refresh = () => {
53
+ buildHistory()
54
+ .then((h) => {
55
+ if (!cancelled)
56
+ setHistory(h);
57
+ })
58
+ .catch(() => undefined);
59
+ };
60
+ refresh();
61
+ const t = setInterval(refresh, HISTORY_REFRESH_MS);
62
+ return () => {
63
+ cancelled = true;
64
+ clearInterval(t);
65
+ };
66
+ }, []);
42
67
  // Wall-clock tick + sparkline slot rotation (1 Hz).
43
68
  useEffect(() => {
44
69
  const t = setInterval(() => {
@@ -62,14 +87,12 @@ function App() {
62
87
  // before returning, but its first notify() fires before any listener
63
88
  // is attached, so we'd otherwise wait for the next appended line.
64
89
  lastTotalRef.current = totalTokens(handle.stats.totals);
65
- lastSampleAtRef.current = Date.now();
90
+ setLastTailAt(Date.now());
66
91
  setStats({ ...handle.stats });
67
92
  handle.onUpdate((s) => {
93
+ setLastTailAt(Date.now());
68
94
  const tot = totalTokens(s.totals);
69
95
  const delta = Math.max(0, tot - lastTotalRef.current);
70
- const dt = (Date.now() - lastSampleAtRef.current) / 60_000;
71
- if (dt > 0 && delta > 0)
72
- setRate(delta / dt);
73
96
  if (delta > 0) {
74
97
  setSeries((arr) => {
75
98
  const next = arr.slice();
@@ -78,7 +101,6 @@ function App() {
78
101
  });
79
102
  }
80
103
  lastTotalRef.current = tot;
81
- lastSampleAtRef.current = Date.now();
82
104
  setStats({ ...s });
83
105
  });
84
106
  }
@@ -96,61 +118,162 @@ function App() {
96
118
  };
97
119
  // eslint-disable-next-line react-hooks/exhaustive-deps
98
120
  }, []);
99
- const transcripts = listTranscripts().slice(0, 5);
100
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Header, { auth: auth }), _jsxs(Box, { marginTop: 1, flexDirection: "row", gap: 1, children: [_jsx(SessionPanel, { stats: stats, rate: rate, now: now, series: series }), _jsx(BreakdownPanel, { stats: stats })] }), _jsx(Box, { marginTop: 1, children: _jsx(TranscriptsPanel, { transcripts: transcripts, activePath: transcriptPath, now: now }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "magenta", children: "q" }), " quit \u00B7", ' ', _jsx(Text, { color: "magenta", children: "live" }), " tail of ~/.claude/projects \u00B7 pricing is", ' ', _jsx(Text, { italic: true, children: "API-equivalent" }), ", not your real bill on a subscription"] }) })] }));
121
+ const allTranscripts = listTranscripts();
122
+ const transcripts = allTranscripts.slice(0, 5);
123
+ const today = countToday(allTranscripts, now);
124
+ const ratePerSec = series.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
125
+ const todaySessions = getTodaySessions(now, transcriptPath);
126
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Header, { auth: auth, sessionsToday: today.sessions, projectsToday: today.projects, lastTailAt: lastTailAt, startedAt: startedAtRef.current, now: now }), _jsxs(Box, { marginTop: 1, flexDirection: "row", gap: 1, children: [_jsx(SessionPanel, { stats: stats, ratePerSec: ratePerSec, now: now, series: series }), _jsx(BreakdownPanel, { stats: stats })] }), _jsx(Box, { marginTop: 1, children: _jsx(HistoryPanel, { history: history }) }), todaySessions.length > 0 && (_jsx(Box, { marginTop: 1, children: _jsx(TodaySessionsPanel, { sessions: todaySessions, now: now }) })), _jsx(Box, { marginTop: 1, children: _jsx(TranscriptsPanel, { transcripts: transcripts, activePath: transcriptPath, now: now }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "magenta", children: "q" }), " quit \u00B7", ' ', _jsx(Text, { color: "magenta", children: "live" }), " tail of ~/.claude/projects \u00B7 pricing is", ' ', _jsx(Text, { italic: true, children: "API-equivalent" }), ", not your real bill on a subscription"] }) })] }));
101
127
  }
102
128
  // ── Header ───────────────────────────────────────────────────────────────────
103
- function Header({ auth }) {
129
+ function Header({ auth, sessionsToday, projectsToday, lastTailAt, startedAt, now, }) {
104
130
  const ok = auth.installed && auth.loggedIn;
105
131
  const dot = ok ? 'green' : auth.installed ? 'yellow' : 'red';
106
- const planChip = planChipFor(auth);
107
- return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "\u258C tokens-metric " }), _jsx(Text, { dimColor: true, children: "v0.1.0 \u2014 real-time Claude Code usage" })] }), _jsx(Box, { marginTop: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: dot, children: "\u25CF" }), ' ', auth.installed ? 'Claude Code detected' : 'Claude Code NOT detected', ' ', _jsx(Text, { ...planChip.style, children: planChip.label }), auth.userIdShort && (_jsx(Text, { dimColor: true, children: ` user ${maskUserId(auth.userIdShort, OPTS.reveal)}` }))] }) }), auth.hint && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["\u21B3 ", auth.hint] }) }))] }));
108
- }
109
- function planChipFor(auth) {
110
- if (!auth.loggedIn)
111
- return { label: ' LOGGED-OUT ', style: { color: 'red', bold: true } };
112
- switch (auth.planHint) {
113
- case 'api':
114
- return { label: ' API ', style: { color: 'yellow', bold: true } };
115
- case 'team-or-enterprise':
116
- return { label: ' TEAM / ENTERPRISE ', style: { color: 'magenta', bold: true } };
117
- case 'paid':
118
- return { label: ' PRO / MAX ', style: { color: 'green', bold: true } };
119
- case 'free':
120
- return { label: ' FREE ', style: { color: 'blue', bold: true } };
121
- default:
122
- return { label: ' SUBSCRIPTION ', style: { color: 'cyan', bold: true } };
132
+ const tailLabel = lastTailAt ? `updated ${timeAgo(now - lastTailAt)} ago` : 'waiting…';
133
+ const tailColor = !lastTailAt ? 'gray' : now - lastTailAt < 10_000 ? 'green' : 'yellow';
134
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "\u258C tokens-metric " }), _jsxs(Text, { dimColor: true, children: ["v", pkg.version, " \u2014 real-time Claude Code usage"] })] }), _jsx(Box, { marginTop: 0, children: _jsxs(Text, { children: [_jsx(Text, { color: dot, children: "\u25CF" }), ' ', auth.installed ? 'Claude Code detected' : 'Claude Code NOT detected', _jsx(Text, { dimColor: true, children: ' · ' }), _jsx(Text, { children: sessionsToday }), _jsx(Text, { dimColor: true, children: ` ${plural(sessionsToday, 'session', 'sessions')} · ` }), _jsx(Text, { children: projectsToday }), _jsx(Text, { dimColor: true, children: ` ${plural(projectsToday, 'project', 'projects')} today` })] }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "watching ~/.claude/projects \u00B7 " }), _jsx(Text, { color: tailColor, children: tailLabel }), _jsx(Text, { dimColor: true, children: ' · uptime ' }), _jsx(Text, { children: timeAgo(now - startedAt) })] })] }));
135
+ }
136
+ function countToday(transcripts, now) {
137
+ const startOfDay = new Date(now);
138
+ startOfDay.setHours(0, 0, 0, 0);
139
+ const cutoff = startOfDay.getTime();
140
+ const projects = new Set();
141
+ let sessions = 0;
142
+ for (const t of transcripts) {
143
+ if (t.mtimeMs < cutoff)
144
+ continue;
145
+ sessions++;
146
+ projects.add(t.cwd);
123
147
  }
148
+ return { sessions, projects: projects.size };
124
149
  }
125
150
  // ── Session panel ────────────────────────────────────────────────────────────
126
- function SessionPanel({ stats, rate, now, series, }) {
127
- return (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", flexGrow: 1, minWidth: 42, children: [_jsx(Text, { bold: true, color: "green", children: "Active session" }), !stats ? (_jsx(Text, { dimColor: true, children: "Waiting for a Claude Code session\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(KV, { k: "Model", v: _jsx(Text, { color: "cyan", children: stats.lastModel ?? '—' }) }), _jsx(KV, { k: "Msgs ", v: _jsx(Text, { children: stats.messageCount }) }), _jsx(KV, { k: "Last ", v: stats.lastEventAt ? (_jsxs(Text, { children: [timeAgo(now - stats.lastEventAt), " ago"] })) : (_jsx(Text, { dimColor: true, children: "\u2014" })) }), _jsx(KV, { k: "cwd ", v: _jsx(Text, { dimColor: true, wrap: "truncate-middle", children: OPTS.reveal ? (stats.cwd ?? '—') : anonymizePath(stats.cwd) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "\u03A3 " }), _jsx(Text, { color: "cyan", bold: true, children: fmtNumber(totalTokens(stats.totals)) }), _jsx(Text, { dimColor: true, children: " tokens" })] }), (() => {
151
+ function SessionPanel({ stats, ratePerSec, now, series, }) {
152
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", flexGrow: 1, minWidth: 42, children: [_jsxs(Text, { bold: true, color: "green", children: ['Active session', stats?.startedAt ? (_jsx(Text, { color: "green", bold: false, children: ` · since ${fmtTime(stats.startedAt)}` })) : null] }), !stats ? (_jsx(Text, { dimColor: true, children: "Waiting for a Claude Code session\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(KV, { k: "Model", v: _jsx(Text, { color: "cyan", children: stats.lastModel ?? '—' }) }), _jsx(KV, { k: "Msgs ", v: _jsx(Text, { children: stats.messageCount }) }), _jsx(KV, { k: "Last ", v: stats.lastEventAt ? (_jsxs(Text, { children: [timeAgo(now - stats.lastEventAt), " ago"] })) : (_jsx(Text, { dimColor: true, children: "\u2014" })) }), _jsx(KV, { k: "cwd ", v: _jsx(Text, { dimColor: true, wrap: "truncate-middle", children: OPTS.reveal ? (stats.cwd ?? '—') : anonymizePath(stats.cwd) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "\u03A3 " }), _jsx(Text, { color: "cyan", bold: true, children: fmtNumber(totalTokens(stats.totals)) }), _jsx(Text, { dimColor: true, children: " tokens" })] }), (() => {
128
153
  const cost = estimateCostUSD(stats.lastModel ?? '', stats.totals);
129
154
  return cost !== null ? (_jsxs(Text, { dimColor: true, children: ["~", fmtUSD(cost), " API-equivalent"] })) : null;
130
- })(), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "~ " }), _jsx(Text, { color: "yellow", children: fmtNumber(Math.round(rate)) }), _jsx(Text, { dimColor: true, children: " tok/min" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["activity (last ", SPARK_WIDTH, "s)"] }), _jsx(Text, { color: "yellow", children: sparkline(series, SPARK_WIDTH) })] })] }))] }));
155
+ })(), Math.round(ratePerSec) > 0 && (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "~ " }), _jsx(Text, { color: "yellow", children: fmtNumber(Math.round(ratePerSec)) }), _jsxs(Text, { dimColor: true, children: [" tok/s (avg last ", SPARK_WIDTH, "s)"] })] }))] }), series.some((n) => n > 0) ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["activity (last ", SPARK_WIDTH, "s)"] }), _jsx(Text, { dimColor: true, children: " peak " }), _jsx(Text, { bold: true, color: "yellow", children: fmtNumber(Math.max(...series)) }), _jsx(Text, { dimColor: true, children: "/s" })] }), _jsx(Text, { children: sparklineCells(series, SPARK_WIDTH).map((cell, i) => (_jsx(Text, { color: intensityColor(cell.intensity), children: cell.char }, i))) }), _jsx(Text, { dimColor: true, children: axisLabel(SPARK_WIDTH) })] })) : (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["idle \u2014 no activity in the last ", SPARK_WIDTH, "s"] }) }))] }))] }));
131
156
  }
132
157
  // ── Breakdown panel ──────────────────────────────────────────────────────────
133
158
  function BreakdownPanel({ stats }) {
134
159
  return (_jsxs(Box, { borderStyle: "round", borderColor: "blue", paddingX: 1, flexDirection: "column", flexGrow: 1, minWidth: 42, children: [_jsx(Text, { bold: true, color: "blue", children: "Token breakdown" }), !stats ? (_jsx(Text, { dimColor: true, children: "No data yet." })) : ((() => {
135
160
  const u = stats.totals;
136
161
  const max = Math.max(1, u.input_tokens, u.output_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens);
137
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(BarRow, { label: "Input ", value: u.input_tokens, max: max, color: "cyan" }), _jsx(BarRow, { label: "Output ", value: u.output_tokens, max: max, color: "green" }), _jsx(BarRow, { label: "C. write ", value: u.cache_creation_input_tokens, max: max, color: "yellow" }), _jsx(BarRow, { label: "C. read ", value: u.cache_read_input_tokens, max: max, color: "magenta" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "By model" }), Object.entries(stats.byModel).map(([m, mu]) => (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: shortModel(m) }), _jsx(Text, { dimColor: true, children: " \u03A3 " }), _jsx(Text, { children: fmtNumber(totalTokens(mu)) })] }, m)))] })] }));
162
+ const total = totalTokens(u);
163
+ const model = stats.lastModel ?? '';
164
+ const cacheDenom = u.input_tokens + u.cache_read_input_tokens;
165
+ const hitRatio = cacheDenom > 0 ? u.cache_read_input_tokens / cacheDenom : null;
166
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(BarRow, { label: "Input ", value: u.input_tokens, max: max, total: total, color: "cyan", cost: categoryCostUSD(model, 'input', u.input_tokens) }), _jsx(BarRow, { label: "Output ", value: u.output_tokens, max: max, total: total, color: "green", cost: categoryCostUSD(model, 'output', u.output_tokens) }), _jsx(BarRow, { label: "C. write ", value: u.cache_creation_input_tokens, max: max, total: total, color: "yellow", cost: categoryCostUSD(model, 'cacheWrite', u.cache_creation_input_tokens) }), _jsx(BarRow, { label: "C. read ", value: u.cache_read_input_tokens, max: max, total: total, color: "magenta", cost: categoryCostUSD(model, 'cacheRead', u.cache_read_input_tokens) }), hitRatio !== null && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Cache hit" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: hitRatio > 0.9 ? 'green' : hitRatio > 0.6 ? 'yellow' : 'red', children: [(hitRatio * 100).toFixed(1), "%"] }), _jsx(Text, { dimColor: true, children: " (cache read / (input + cache read))" })] }) })), Object.keys(stats.byModel).length > 1 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "By model" }), Object.entries(stats.byModel).map(([m, mu]) => {
167
+ const c = estimateCostUSD(m, mu);
168
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: shortModel(m) }), _jsx(Text, { dimColor: true, children: " \u03A3 " }), _jsx(Text, { children: fmtNumber(totalTokens(mu)) }), c !== null && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " ~" }), _jsx(Text, { children: fmtUSD(c) })] }))] }, m));
169
+ })] }))] }));
138
170
  })())] }));
139
171
  }
140
- function BarRow({ label, value, max, color, }) {
141
- return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: label }), _jsx(Text, { color: color, children: bar(value / max, BAR_WIDTH) }), _jsxs(Text, { children: [" ", fmtNumber(value)] })] }));
172
+ function BarRow({ label, value, max, total, color, cost, }) {
173
+ const pct = total > 0 ? (value / total) * 100 : 0;
174
+ return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: label }), _jsx(Text, { color: color, children: bar(value / max, BAR_WIDTH) }), _jsxs(Text, { children: [" ", fmtNumber(value).padStart(7, ' ')] }), _jsx(Text, { dimColor: true, children: ` ${pct.toFixed(1).padStart(5, ' ')}%` }), cost !== null && _jsx(Text, { dimColor: true, children: ` ~${fmtUSD(cost)}` })] }));
175
+ }
176
+ // ── History panel ────────────────────────────────────────────────────────────
177
+ function HistoryPanel({ history }) {
178
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "magenta", paddingX: 1, flexDirection: "column", width: "100%", children: [_jsx(Text, { bold: true, color: "magenta", children: "Usage history" }), !history ? (_jsx(Text, { dimColor: true, children: "Scanning ~/.claude/projects\u2026" })) : history.scannedFiles === 0 ? (_jsx(Text, { dimColor: true, children: "No transcripts found." })) : (_jsxs(_Fragment, { children: [_jsx(HistoryRow, { label: "", today: "Today", d7: "7d", d30: "30d", dim: true }), _jsx(HistoryRow, { label: "Tokens ", today: fmtNumber(bucketTokens(history.today)), d7: fmtNumber(bucketTokens(history.d7)), d30: fmtNumber(bucketTokens(history.d30)) }), _jsx(HistoryRow, { label: "Cost~ ", today: fmtCost(bucketCostUSD(history.today)), d7: fmtCost(bucketCostUSD(history.d7)), d30: fmtCost(bucketCostUSD(history.d30)) }), _jsx(HistoryRow, { label: "Sessions", today: String(history.today.sessions.size), d7: String(history.d7.sessions.size), d30: String(history.d30.sessions.size) }), _jsx(HistoryRow, { label: "Top model", today: fmtTopModel(history.today), d7: fmtTopModel(history.d7), d30: fmtTopModel(history.d30) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [`scanned ${history.scannedFiles} transcripts`, history.oldestMtimeMs !== null &&
179
+ ` · data since ${fmtDate(history.oldestMtimeMs)} (${daysAgo(history.oldestMtimeMs, history.generatedAt)} days)`] }) })] }))] }));
180
+ }
181
+ function fmtDate(ms) {
182
+ const d = new Date(ms);
183
+ const y = d.getFullYear();
184
+ const m = String(d.getMonth() + 1).padStart(2, '0');
185
+ const day = String(d.getDate()).padStart(2, '0');
186
+ return `${y}-${m}-${day}`;
187
+ }
188
+ function daysAgo(from, to) {
189
+ return Math.max(0, Math.floor((to - from) / 86_400_000));
190
+ }
191
+ function HistoryRow({ label, today, d7, d30, dim, }) {
192
+ const col = (s) => s.padEnd(14, ' ');
193
+ return (_jsxs(Text, { dimColor: dim, children: [_jsx(Text, { bold: true, children: label.padEnd(10, ' ') }), _jsx(Text, { children: col(today) }), _jsx(Text, { children: col(d7) }), _jsx(Text, { children: col(d30) })] }));
194
+ }
195
+ function fmtCost(c) {
196
+ return c === null ? '—' : fmtUSD(c);
197
+ }
198
+ function fmtTopModel(b) {
199
+ const m = bucketTopModel(b);
200
+ return m ? shortModel(m) : '—';
201
+ }
202
+ // ── Today's sessions panel ───────────────────────────────────────────────────
203
+ function TodaySessionsPanel({ sessions, now }) {
204
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", width: "100%", children: [_jsx(Text, { bold: true, color: "cyan", children: "Today's sessions" }), sessions.map((s) => {
205
+ const tokens = Object.values(s.byModel).reduce((acc, u) => acc + totalTokens(u), 0);
206
+ const cost = Object.entries(s.byModel).reduce((acc, [m, u]) => {
207
+ const c = estimateCostUSD(m, u);
208
+ if (c === null)
209
+ return acc;
210
+ return (acc ?? 0) + c;
211
+ }, null);
212
+ const topModel = Object.entries(s.byModel).reduce((best, [m, u]) => best === null || totalTokens(u) > totalTokens(s.byModel[best] ?? {})
213
+ ? m
214
+ : best, null);
215
+ const duration = s.startedAt !== null && s.endedAt !== null
216
+ ? fmtDuration(s.endedAt - s.startedAt)
217
+ : null;
218
+ return (_jsxs(Text, { children: [_jsx(Text, { color: s.isActive ? 'green' : 'gray', children: s.isActive ? '▶ ' : ' ' }), _jsx(Text, { bold: s.isActive, children: s.startedAt ? fmtTime(s.startedAt) : '??:??' }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { wrap: "truncate-middle", dimColor: !s.isActive, children: OPTS.reveal ? (s.cwd ?? '—') : displayCwd(s.cwd) }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "cyan", children: topModel ? shortModel(topModel) : '—' }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { children: fmtNumber(tokens) }), cost !== null && _jsx(Text, { dimColor: true, children: ` ~${fmtUSD(cost)}` }), duration && _jsx(Text, { dimColor: true, children: ` ${duration}` }), s.isActive && _jsx(Text, { color: "green", children: " active" })] }, s.sessionId));
219
+ })] }));
220
+ }
221
+ function fmtDuration(ms) {
222
+ const totalSec = Math.floor(ms / 1000);
223
+ const h = Math.floor(totalSec / 3600);
224
+ const m = Math.floor((totalSec % 3600) / 60);
225
+ if (h > 0)
226
+ return `${h}h ${m}m`;
227
+ if (m > 0)
228
+ return `${m}m`;
229
+ return `${totalSec}s`;
142
230
  }
143
231
  // ── Transcripts panel ────────────────────────────────────────────────────────
144
232
  function TranscriptsPanel({ transcripts, activePath, now, }) {
145
233
  return (_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width: "100%", children: [_jsx(Text, { bold: true, children: "Recent transcripts" }), transcripts.length === 0 ? (_jsx(Text, { dimColor: true, children: "None found under ~/.claude/projects" })) : (transcripts.map((t) => {
146
234
  const isActive = t.path === activePath;
147
- return (_jsxs(Text, { children: [_jsx(Text, { color: isActive ? 'green' : 'gray', children: isActive ? '▶ ' : ' ' }), _jsx(Text, { dimColor: !isActive, wrap: "truncate-middle", children: OPTS.reveal ? t.cwd : anonymizePath(t.cwd) }), _jsx(Text, { dimColor: true, children: ` ${timeAgo(now - t.mtimeMs)}` })] }, t.path));
235
+ return (_jsxs(Text, { children: [_jsx(Text, { color: isActive ? 'green' : 'gray', children: isActive ? '▶ ' : ' ' }), _jsx(Text, { dimColor: !isActive, wrap: "truncate-middle", children: OPTS.reveal ? t.cwd : displayCwd(t.cwd) }), _jsx(Text, { dimColor: true, children: ` ${timeAgo(now - t.mtimeMs)}` })] }, t.path));
148
236
  }))] }));
149
237
  }
150
238
  // ── helpers ──────────────────────────────────────────────────────────────────
151
239
  function KV({ k, v }) {
152
240
  return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: k }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), v] }));
153
241
  }
242
+ function fmtTime(ms) {
243
+ const d = new Date(ms);
244
+ const h = String(d.getHours()).padStart(2, '0');
245
+ const m = String(d.getMinutes()).padStart(2, '0');
246
+ return `${h}:${m}`;
247
+ }
248
+ function plural(n, one, many) {
249
+ return n === 1 ? one : many;
250
+ }
251
+ /**
252
+ * Like anonymizePath, but distinguishes the bare home directory from "no
253
+ * path" — a transcript whose cwd is $HOME would otherwise show as a lonely
254
+ * `~`, which looks like a bug.
255
+ */
256
+ function displayCwd(cwd) {
257
+ const out = anonymizePath(cwd);
258
+ if (out === '~')
259
+ return '~ (home)';
260
+ return out;
261
+ }
262
+ function intensityColor(ratio) {
263
+ if (ratio <= 0)
264
+ return 'gray';
265
+ if (ratio < 0.34)
266
+ return 'green';
267
+ if (ratio < 0.67)
268
+ return 'yellow';
269
+ return 'red';
270
+ }
271
+ function axisLabel(width) {
272
+ const left = `-${width}s`;
273
+ const right = 'now';
274
+ const gap = Math.max(1, width - left.length - right.length);
275
+ return `${left}${' '.repeat(gap)}${right}`;
276
+ }
154
277
  function shortModel(m) {
155
278
  return m.replace(/^claude-/, '').replace(/-20\d{6}/, '').replace(/-\d{8}$/, '');
156
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokens-metric",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Real-time token usage meter for Claude Code — statusline + Ink TUI.",
5
5
  "type": "module",
6
6
  "bin": {