tokens-metric 0.4.1 → 0.4.3

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.
@@ -8,6 +8,12 @@ export function claudeHome() {
8
8
  export function claudeConfigPath() {
9
9
  return join(homedir(), '.claude.json');
10
10
  }
11
+ export function codexHome() {
12
+ return join(homedir(), '.codex');
13
+ }
14
+ export function codexSessionsDir() {
15
+ return join(codexHome(), 'sessions');
16
+ }
11
17
  /**
12
18
  * Detect whether Claude Code is installed, the user is logged in, and best-
13
19
  * effort what plan they're on. All signals are LOCAL and BEST-EFFORT — we
@@ -17,15 +17,37 @@ const PRICES_PER_MTOK = {
17
17
  'claude-opus-4': { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 },
18
18
  // Haiku 4.x family
19
19
  'claude-haiku-4': { in: 0.8, out: 4, cacheWrite: 1, cacheRead: 0.08 },
20
+ // OpenAI o4-mini — default Codex CLI model; also used for "codex" key
21
+ 'openai-o4-mini': { in: 1.1, out: 4.4, cacheWrite: 0, cacheRead: 0.275 },
22
+ // OpenAI o3
23
+ 'openai-o3': { in: 10, out: 40, cacheWrite: 0, cacheRead: 2.5 },
24
+ // OpenAI o3-mini
25
+ 'openai-o3-mini': { in: 1.1, out: 4.4, cacheWrite: 0, cacheRead: 0.275 },
26
+ // OpenAI GPT-4o
27
+ 'openai-gpt-4o': { in: 2.5, out: 10, cacheWrite: 0, cacheRead: 1.25 },
28
+ // OpenAI GPT-4o mini
29
+ 'openai-gpt-4o-mini': { in: 0.15, out: 0.6, cacheWrite: 0, cacheRead: 0.075 },
20
30
  };
21
31
  function priceKey(model) {
22
32
  const m = model.toLowerCase();
33
+ // Claude
23
34
  if (m.includes('opus'))
24
35
  return 'claude-opus-4';
25
36
  if (m.includes('haiku'))
26
37
  return 'claude-haiku-4';
27
38
  if (m.includes('sonnet'))
28
39
  return 'claude-sonnet-4';
40
+ // OpenAI / Codex — "codex" key maps to o4-mini (Codex CLI default)
41
+ if (m === 'codex' || m.includes('o4-mini'))
42
+ return 'openai-o4-mini';
43
+ if (m.includes('o3-mini'))
44
+ return 'openai-o3-mini';
45
+ if (m === 'o3' || m.endsWith('-o3'))
46
+ return 'openai-o3';
47
+ if (m.includes('gpt-4o-mini'))
48
+ return 'openai-gpt-4o-mini';
49
+ if (m.includes('gpt-4o'))
50
+ return 'openai-gpt-4o';
29
51
  return null;
30
52
  }
31
53
  export function estimateCostUSD(model, u) {
@@ -53,6 +75,14 @@ export function categoryCostUSD(model, category, tokens) {
53
75
  : p.cacheRead;
54
76
  return (tokens * rate) / 1_000_000;
55
77
  }
78
+ /**
79
+ * Returns the context window size in tokens for a given model.
80
+ * All current Claude models share a 200k window. Falls back to 200k for
81
+ * unknown models so the bar is always renderable.
82
+ */
83
+ export function contextWindowSize(_model) {
84
+ return 200_000;
85
+ }
56
86
  export function fmtUSD(n) {
57
87
  if (n < 0.01)
58
88
  return `$${n.toFixed(4)}`;
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, statSync, createReadStream, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
- import { claudeHome } from './detect.js';
4
+ import { claudeHome, codexSessionsDir } from './detect.js';
5
5
  import { addUsage, EMPTY_USAGE, totalTokens } from './types.js';
6
6
  import { estimateCostUSD } from './format.js';
7
7
  import { loadStore, replaceDayIn, saveStore, } from './history-store.js';
@@ -10,16 +10,21 @@ let storedDays = null;
10
10
  export async function buildHistory(now = Date.now()) {
11
11
  if (storedDays === null)
12
12
  storedDays = loadStore();
13
- const root = join(claudeHome(), 'projects');
14
- if (!existsSync(root)) {
15
- // No transcripts available; surface whatever the store has.
13
+ const claudeRoot = join(claudeHome(), 'projects');
14
+ const codexRoot = codexSessionsDir();
15
+ const allFiles = [];
16
+ if (existsSync(claudeRoot))
17
+ allFiles.push(...listAllJsonl(claudeRoot));
18
+ if (existsSync(codexRoot))
19
+ allFiles.push(...listCodexJsonl(codexRoot));
20
+ if (allFiles.length === 0)
16
21
  return aggregate(now);
17
- }
18
- const files = listAllJsonl(root);
19
- for (const f of files) {
22
+ for (const f of allFiles) {
20
23
  const cached = cache.get(f.path);
21
24
  if (!cached || cached.mtimeMs < f.mtimeMs) {
22
- const parsed = await parseFile(f.path);
25
+ const parsed = f.path.includes('/.codex/sessions/')
26
+ ? await parseCodexFile(f.path)
27
+ : await parseFile(f.path);
23
28
  cache.set(f.path, {
24
29
  mtimeMs: f.mtimeMs,
25
30
  byDay: parsed.byDay,
@@ -30,7 +35,7 @@ export async function buildHistory(now = Date.now()) {
30
35
  });
31
36
  }
32
37
  }
33
- const known = new Set(files.map((f) => f.path));
38
+ const known = new Set(allFiles.map((f) => f.path));
34
39
  for (const key of Array.from(cache.keys())) {
35
40
  if (!known.has(key))
36
41
  cache.delete(key);
@@ -245,6 +250,83 @@ async function parseFile(path) {
245
250
  function numberOr0(v) {
246
251
  return typeof v === 'number' && Number.isFinite(v) ? v : 0;
247
252
  }
253
+ function listCodexJsonl(root) {
254
+ const out = [];
255
+ for (const year of safeReaddir(root)) {
256
+ for (const month of safeReaddir(join(root, year))) {
257
+ for (const day of safeReaddir(join(root, year, month))) {
258
+ const dayDir = join(root, year, month, day);
259
+ for (const f of safeReaddir(dayDir)) {
260
+ if (!f.endsWith('.jsonl'))
261
+ continue;
262
+ const p = join(dayDir, f);
263
+ try {
264
+ const s = statSync(p);
265
+ out.push({ path: p, mtimeMs: s.mtimeMs });
266
+ }
267
+ catch {
268
+ // ignore
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
274
+ return out;
275
+ }
276
+ async function parseCodexFile(path) {
277
+ const byDay = new Map();
278
+ const sessionId = (path.split('/').pop() ?? path).replace(/\.jsonl$/, '');
279
+ let earliestEventMs = null;
280
+ let latestEventMs = null;
281
+ let cwd;
282
+ const stream = createReadStream(path, { encoding: 'utf8' });
283
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
284
+ for await (const line of rl) {
285
+ if (!line.trim())
286
+ continue;
287
+ let evt;
288
+ try {
289
+ evt = JSON.parse(line);
290
+ }
291
+ catch {
292
+ continue;
293
+ }
294
+ if (evt?.type === 'session_meta' && typeof evt?.payload?.cwd === 'string') {
295
+ if (!cwd)
296
+ cwd = evt.payload.cwd;
297
+ }
298
+ const ts = typeof evt?.timestamp === 'string' ? Date.parse(evt.timestamp) : NaN;
299
+ if (Number.isFinite(ts)) {
300
+ if (earliestEventMs === null || ts < earliestEventMs)
301
+ earliestEventMs = ts;
302
+ if (latestEventMs === null || ts > latestEventMs)
303
+ latestEventMs = ts;
304
+ }
305
+ if (evt?.type !== 'event_msg')
306
+ continue;
307
+ if (evt?.payload?.type !== 'token_count')
308
+ continue;
309
+ const info = evt?.payload?.info;
310
+ if (!info)
311
+ continue;
312
+ const last = info.last_token_usage;
313
+ if (!last)
314
+ continue;
315
+ if (!Number.isFinite(ts))
316
+ continue;
317
+ const day = startOfDay(ts);
318
+ const u = {
319
+ input_tokens: numberOr0(last.input_tokens),
320
+ output_tokens: numberOr0(last.output_tokens) + numberOr0(last.reasoning_output_tokens),
321
+ cache_read_input_tokens: numberOr0(last.cached_input_tokens),
322
+ cache_creation_input_tokens: 0,
323
+ };
324
+ const bucket = byDay.get(day) ?? {};
325
+ bucket['codex'] = addUsage(bucket['codex'] ?? EMPTY_USAGE(), u);
326
+ byDay.set(day, bucket);
327
+ }
328
+ return { byDay, sessionId, earliestEventMs, latestEventMs, cwd };
329
+ }
248
330
  /**
249
331
  * Returns all sessions (transcripts) that had activity today, sorted by
250
332
  * start time descending (most recent first). Marks the active one.
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, statSync, createReadStream, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
- import { claudeHome } from './detect.js';
4
+ import { claudeHome, codexSessionsDir } from './detect.js';
5
5
  import { addUsage, EMPTY_USAGE, } from './types.js';
6
6
  const PROJECTS_DIR = () => join(claudeHome(), 'projects');
7
7
  /**
@@ -79,6 +79,7 @@ export async function aggregateTranscript(path) {
79
79
  totals: EMPTY_USAGE(),
80
80
  byModel: {},
81
81
  messageCount: 0,
82
+ lastMsgUsage: null,
82
83
  };
83
84
  const stream = createReadStream(path, { encoding: 'utf8' });
84
85
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
@@ -129,6 +130,8 @@ export function applyLine(stats, line) {
129
130
  stats.totals = addUsage(stats.totals, u);
130
131
  stats.byModel[model] = addUsage(stats.byModel[model] ?? EMPTY_USAGE(), u);
131
132
  stats.messageCount += 1;
133
+ // Overwrite — we only care about the most recent turn's context footprint.
134
+ stats.lastMsgUsage = { ...EMPTY_USAGE(), ...u };
132
135
  }
133
136
  function numberOr0(v) {
134
137
  return typeof v === 'number' && Number.isFinite(v) ? v : 0;
@@ -139,3 +142,133 @@ function parseTimestamp(v) {
139
142
  const n = Date.parse(v);
140
143
  return Number.isFinite(n) ? n : undefined;
141
144
  }
145
+ /**
146
+ * Aggregate a transcript using the appropriate parser (auto-detected by path).
147
+ */
148
+ export async function aggregateAnyTranscript(path) {
149
+ if (path.includes('/.codex/sessions/')) {
150
+ const stats = {
151
+ sessionId: deriveSessionId(path),
152
+ transcriptPath: path,
153
+ totals: EMPTY_USAGE(),
154
+ byModel: {},
155
+ messageCount: 0,
156
+ lastMsgUsage: null,
157
+ };
158
+ const stream = (await import('node:fs')).createReadStream(path, { encoding: 'utf8' });
159
+ const rl = (await import('node:readline')).createInterface({ input: stream, crlfDelay: Infinity });
160
+ for await (const line of rl) {
161
+ if (line.trim())
162
+ applyCodexLine(stats, line);
163
+ }
164
+ return stats;
165
+ }
166
+ return aggregateTranscript(path);
167
+ }
168
+ /**
169
+ * Returns the most recently active transcript across Claude Code and Codex CLI,
170
+ * or null if neither has a session within the window.
171
+ */
172
+ export function findMostRecentActiveTranscript(withinMs = 5 * 60_000) {
173
+ const claude = findActiveTranscript(withinMs);
174
+ const codex = findActiveCodexTranscript(withinMs);
175
+ if (!claude && !codex)
176
+ return null;
177
+ if (!claude)
178
+ return codex;
179
+ if (!codex)
180
+ return claude;
181
+ // Both exist — pick the one with the most recent file
182
+ const claudeMtime = listTranscripts()[0]?.mtimeMs ?? 0;
183
+ const codexMtime = listCodexTranscripts()[0]?.mtimeMs ?? 0;
184
+ return codexMtime > claudeMtime ? codex : claude;
185
+ }
186
+ // ─── Codex CLI support ────────────────────────────────────────────────────────
187
+ const CODEX_SESSIONS_DIR = () => codexSessionsDir();
188
+ /**
189
+ * Walk ~/.codex/sessions/YYYY/MM/DD/*.jsonl and return transcript files
190
+ * sorted by mtime descending.
191
+ */
192
+ export function listCodexTranscripts() {
193
+ const root = CODEX_SESSIONS_DIR();
194
+ if (!existsSync(root))
195
+ return [];
196
+ const out = [];
197
+ for (const year of safeReaddir(root)) {
198
+ for (const month of safeReaddir(join(root, year))) {
199
+ for (const day of safeReaddir(join(root, year, month))) {
200
+ const dayDir = join(root, year, month, day);
201
+ for (const f of safeReaddir(dayDir)) {
202
+ if (!f.endsWith('.jsonl'))
203
+ continue;
204
+ const p = join(dayDir, f);
205
+ try {
206
+ const s = statSync(p);
207
+ out.push({ path: p, mtimeMs: s.mtimeMs, cwd: '' });
208
+ }
209
+ catch {
210
+ // ignore
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
217
+ }
218
+ /**
219
+ * Pick the most recently active Codex transcript within `withinMs`.
220
+ */
221
+ export function findActiveCodexTranscript(withinMs = 5 * 60_000) {
222
+ const list = listCodexTranscripts();
223
+ if (list.length === 0)
224
+ return null;
225
+ const top = list[0];
226
+ if (Date.now() - top.mtimeMs > withinMs) {
227
+ return { path: top.path, cwd: top.cwd };
228
+ }
229
+ return { path: top.path, cwd: top.cwd };
230
+ }
231
+ /**
232
+ * Apply a single Codex JSONL line to a running stats object.
233
+ * Codex emits `event_msg / token_count` events with `last_token_usage`
234
+ * (per-turn delta). Summing deltas across a session equals the cumulative
235
+ * total, so we use the same additive approach as the Claude parser.
236
+ */
237
+ export function applyCodexLine(stats, line) {
238
+ let evt;
239
+ try {
240
+ evt = JSON.parse(line);
241
+ }
242
+ catch {
243
+ return;
244
+ }
245
+ const ts = parseTimestamp(evt?.timestamp);
246
+ if (ts) {
247
+ stats.startedAt = stats.startedAt ? Math.min(stats.startedAt, ts) : ts;
248
+ stats.lastEventAt = stats.lastEventAt ? Math.max(stats.lastEventAt, ts) : ts;
249
+ }
250
+ if (evt?.type === 'session_meta' && typeof evt?.payload?.cwd === 'string') {
251
+ if (!stats.cwd)
252
+ stats.cwd = evt.payload.cwd;
253
+ }
254
+ if (evt?.type !== 'event_msg')
255
+ return;
256
+ if (evt?.payload?.type !== 'token_count')
257
+ return;
258
+ const info = evt?.payload?.info;
259
+ if (!info)
260
+ return;
261
+ const last = info.last_token_usage;
262
+ if (!last)
263
+ return;
264
+ const u = {
265
+ input_tokens: numberOr0(last.input_tokens),
266
+ output_tokens: numberOr0(last.output_tokens) + numberOr0(last.reasoning_output_tokens),
267
+ cache_read_input_tokens: numberOr0(last.cached_input_tokens),
268
+ cache_creation_input_tokens: 0,
269
+ };
270
+ stats.totals = addUsage(stats.totals, u);
271
+ stats.byModel['codex'] = addUsage(stats.byModel['codex'] ?? EMPTY_USAGE(), u);
272
+ stats.lastModel = 'codex';
273
+ stats.messageCount += 1;
274
+ }
@@ -1,19 +1,22 @@
1
1
  import { open, watch } from 'node:fs/promises';
2
2
  import { statSync } from 'node:fs';
3
- import { applyLine } from './parser.js';
3
+ import { applyLine, applyCodexLine } from './parser.js';
4
4
  import { EMPTY_USAGE } from './types.js';
5
5
  import { deriveSessionId } from './parser.js';
6
6
  /**
7
7
  * Open a JSONL transcript, read everything currently in it, then watch for
8
8
  * appended lines. Calls onUpdate whenever the stats change.
9
9
  */
10
- export async function tailTranscript(path) {
10
+ export async function tailTranscript(path, format) {
11
+ const isCodex = format === 'codex' || path.includes('/.codex/sessions/');
12
+ const lineApplier = isCodex ? applyCodexLine : applyLine;
11
13
  const stats = {
12
14
  sessionId: deriveSessionId(path),
13
15
  transcriptPath: path,
14
16
  totals: EMPTY_USAGE(),
15
17
  byModel: {},
16
18
  messageCount: 0,
19
+ lastMsgUsage: null,
17
20
  };
18
21
  const listeners = [];
19
22
  const notify = () => listeners.forEach((l) => l(stats));
@@ -43,7 +46,7 @@ export async function tailTranscript(path) {
43
46
  const line = buf.slice(0, nl);
44
47
  buf = buf.slice(nl + 1);
45
48
  if (line.trim()) {
46
- applyLine(stats, line);
49
+ lineApplier(stats, line);
47
50
  changed = true;
48
51
  }
49
52
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { aggregateTranscript, findActiveTranscript } from '../core/parser.js';
2
+ import { aggregateAnyTranscript, findMostRecentActiveTranscript } from '../core/parser.js';
3
3
  import { detectAuth } from '../core/detect.js';
4
4
  import { estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
5
5
  import { totalTokens } from '../core/types.js';
@@ -16,13 +16,13 @@ if (OPTS.help) {
16
16
  */
17
17
  async function main() {
18
18
  try {
19
- const active = findActiveTranscript();
19
+ const active = findMostRecentActiveTranscript();
20
20
  const auth = detectAuth();
21
21
  if (!active) {
22
22
  process.stdout.write(authBadge(auth) + ' · no active session');
23
23
  return;
24
24
  }
25
- const s = await aggregateTranscript(active.path);
25
+ const s = await aggregateAnyTranscript(active.path);
26
26
  const model = s.lastModel ?? 'unknown';
27
27
  const tot = totalTokens(s.totals);
28
28
  const cost = estimateCostUSD(model, s.totals);
package/dist/tui/index.js CHANGED
@@ -4,10 +4,10 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { render, Box, Text, useApp, useInput } from 'ink';
5
5
  import { createInterface } from 'node:readline';
6
6
  import { spawnSync } from 'node:child_process';
7
- import { findActiveTranscript, listTranscripts } from '../core/parser.js';
7
+ import { findMostRecentActiveTranscript, listTranscripts } from '../core/parser.js';
8
8
  import { tailTranscript } from '../core/tailer.js';
9
9
  import { detectAuth } from '../core/detect.js';
10
- import { categoryCostUSD, estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
10
+ import { categoryCostUSD, contextWindowSize, estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
11
11
  import { buildHistory, bucketCostUSD, bucketTokens, bucketTopModel, getTodaySessions, } from '../core/history.js';
12
12
  import { totalTokens } from '../core/types.js';
13
13
  import { anonymizePath } from '../core/privacy.js';
@@ -157,7 +157,7 @@ function App() {
157
157
  });
158
158
  }
159
159
  async function rescan() {
160
- const active = findActiveTranscript();
160
+ const active = findMostRecentActiveTranscript();
161
161
  if (active && active.path !== transcriptPath)
162
162
  await attach(active.path);
163
163
  }
@@ -245,7 +245,14 @@ function BreakdownPanel({ stats, series, ratePerSec, }) {
245
245
  const model = stats.lastModel ?? '';
246
246
  const cacheDenom = u.input_tokens + u.cache_read_input_tokens;
247
247
  const hitRatio = cacheDenom > 0 ? u.cache_read_input_tokens / cacheDenom : null;
248
- 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, { color: hitRatio > 0.9 ? 'green' : hitRatio > 0.6 ? 'yellow' : 'red', children: hitRatio > 0.9 ? ' ✓ excellent' : hitRatio > 0.6 ? ' ⚠ degraded' : ' ✗ poor' })] }) })), 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]) => {
248
+ // Context window fill based on last message's input footprint
249
+ const ctxLimit = contextWindowSize(model);
250
+ const lastMsg = stats.lastMsgUsage;
251
+ const ctxUsed = lastMsg
252
+ ? lastMsg.input_tokens + lastMsg.cache_read_input_tokens + lastMsg.cache_creation_input_tokens
253
+ : null;
254
+ const ctxRatio = ctxUsed !== null ? ctxUsed / ctxLimit : null;
255
+ return (_jsxs(Box, { flexDirection: "column", children: [ctxRatio !== null && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Context " }), _jsx(Text, { color: ctxRatio > 0.9 ? 'red' : ctxRatio > 0.7 ? 'yellow' : 'green', children: ctxWindowBar(ctxRatio, BAR_WIDTH) }), _jsx(Text, { children: ' ' }), _jsx(Text, { bold: true, color: ctxRatio > 0.9 ? 'red' : ctxRatio > 0.7 ? 'yellow' : 'green', children: fmtNumber(ctxUsed) }), _jsxs(Text, { dimColor: true, children: [" / ", fmtNumber(ctxLimit), " "] }), _jsxs(Text, { color: ctxRatio > 0.9 ? 'red' : ctxRatio > 0.7 ? 'yellow' : 'green', children: [(ctxRatio * 100).toFixed(1), "%"] }), ctxRatio > 0.9 && _jsx(Text, { color: "red", children: " \u26A0 near limit" })] }), _jsxs(Text, { dimColor: true, children: [" last turn \u00B7 ", fmtNumber(ctxLimit - ctxUsed), " tokens remaining"] })] })), _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, { color: hitRatio > 0.9 ? 'green' : hitRatio > 0.6 ? 'yellow' : 'red', children: hitRatio > 0.9 ? ' ✓ excellent' : hitRatio > 0.6 ? ' ⚠ degraded' : ' ✗ poor' })] }) })), 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]) => {
249
256
  const c = estimateCostUSD(m, mu);
250
257
  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));
251
258
  })] }))] }));
@@ -350,6 +357,10 @@ function displayCwd(cwd) {
350
357
  return '~ (home)';
351
358
  return out;
352
359
  }
360
+ function ctxWindowBar(ratio, width) {
361
+ const filled = Math.round(Math.min(1, ratio) * width);
362
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
363
+ }
353
364
  function intensityColor(ratio) {
354
365
  if (ratio <= 0)
355
366
  return 'gray';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tokens-metric",
3
- "version": "0.4.1",
4
- "description": "Real-time token usage meter for Claude Code statusline + Ink TUI.",
3
+ "version": "0.4.3",
4
+ "description": "Real-time token usage meter for Claude Code \u2014 statusline + Ink TUI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tokens-metric": "dist/tui/index.js",