tokens-metric 0.4.0 → 0.4.2

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.
@@ -53,6 +53,14 @@ export function categoryCostUSD(model, category, tokens) {
53
53
  : p.cacheRead;
54
54
  return (tokens * rate) / 1_000_000;
55
55
  }
56
+ /**
57
+ * Returns the context window size in tokens for a given model.
58
+ * All current Claude models share a 200k window. Falls back to 200k for
59
+ * unknown models so the bar is always renderable.
60
+ */
61
+ export function contextWindowSize(_model) {
62
+ return 200_000;
63
+ }
56
64
  export function fmtUSD(n) {
57
65
  if (n < 0.01)
58
66
  return `$${n.toFixed(4)}`;
@@ -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;
@@ -14,6 +14,7 @@ export async function tailTranscript(path) {
14
14
  totals: EMPTY_USAGE(),
15
15
  byModel: {},
16
16
  messageCount: 0,
17
+ lastMsgUsage: null,
17
18
  };
18
19
  const listeners = [];
19
20
  const notify = () => listeners.forEach((l) => l(stats));
package/dist/tui/index.js CHANGED
@@ -2,10 +2,12 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
4
  import { render, Box, Text, useApp, useInput } from 'ink';
5
+ import { createInterface } from 'node:readline';
6
+ import { spawnSync } from 'node:child_process';
5
7
  import { findActiveTranscript, listTranscripts } from '../core/parser.js';
6
8
  import { tailTranscript } from '../core/tailer.js';
7
9
  import { detectAuth } from '../core/detect.js';
8
- import { categoryCostUSD, estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
10
+ import { categoryCostUSD, contextWindowSize, estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
9
11
  import { buildHistory, bucketCostUSD, bucketTokens, bucketTopModel, getTodaySessions, } from '../core/history.js';
10
12
  import { totalTokens } from '../core/types.js';
11
13
  import { anonymizePath } from '../core/privacy.js';
@@ -243,7 +245,14 @@ function BreakdownPanel({ stats, series, ratePerSec, }) {
243
245
  const model = stats.lastModel ?? '';
244
246
  const cacheDenom = u.input_tokens + u.cache_read_input_tokens;
245
247
  const hitRatio = cacheDenom > 0 ? u.cache_read_input_tokens / cacheDenom : null;
246
- 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]) => {
247
256
  const c = estimateCostUSD(m, mu);
248
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));
249
258
  })] }))] }));
@@ -348,6 +357,10 @@ function displayCwd(cwd) {
348
357
  return '~ (home)';
349
358
  return out;
350
359
  }
360
+ function ctxWindowBar(ratio, width) {
361
+ const filled = Math.round(Math.min(1, ratio) * width);
362
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
363
+ }
351
364
  function intensityColor(ratio) {
352
365
  if (ratio <= 0)
353
366
  return 'gray';
@@ -380,4 +393,32 @@ function timeAgo(ms) {
380
393
  return `${h}h`;
381
394
  return `${Math.floor(h / 24)}d`;
382
395
  }
396
+ // ── Pre-start update prompt ───────────────────────────────────────────────────
397
+ async function promptForUpdate() {
398
+ const latest = await checkForUpdate(pkg.version);
399
+ if (!latest || !process.stdin.isTTY)
400
+ return;
401
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
402
+ const answer = await new Promise((resolve) => {
403
+ rl.question(`\n⚡ tokens-metric v${latest} available (you have v${pkg.version})\n` +
404
+ ` Install now? [Y/n] `, (ans) => { rl.close(); resolve(ans); });
405
+ });
406
+ process.stdout.write('\n');
407
+ if (answer.trim().toLowerCase() !== 'n') {
408
+ process.stdout.write('Installing tokens-metric@latest…\n');
409
+ const result = spawnSync('npm', ['install', '-g', 'tokens-metric'], {
410
+ stdio: 'inherit',
411
+ shell: true,
412
+ });
413
+ if (result.status === 0) {
414
+ process.stdout.write(`\n✓ Updated to v${latest} — restarting…\n\n`);
415
+ spawnSync(process.execPath, process.argv.slice(1), { stdio: 'inherit', shell: false });
416
+ process.exit(0);
417
+ }
418
+ else {
419
+ process.stdout.write('\n✗ Install failed — starting current version anyway.\n\n');
420
+ }
421
+ }
422
+ }
423
+ await promptForUpdate();
383
424
  render(_jsx(App, {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokens-metric",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Real-time token usage meter for Claude Code — statusline + Ink TUI.",
5
5
  "type": "module",
6
6
  "bin": {