tokens-metric 0.3.4 → 0.4.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.
package/README.md CHANGED
@@ -6,10 +6,18 @@ It tails the transcripts Claude Code writes under `~/.claude/projects/**/*.jsonl
6
6
 
7
7
  ## What it shows
8
8
 
9
- - **Active session**: model, message count, time since last event, total tokens, equivalent API cost, tokens-per-minute rate, and an activity sparkline.
10
- - **Breakdown**: input / output / cache-write / cache-read tokens with proportional bars, plus per-model totals.
11
- - **Plan detection** (best-effort, local-only): API key vs. OAuth subscription, and a hint between Free, Pro/Max, or Team/Enterprise, inferred from flags in `~/.claude.json`.
12
- - **Recent transcripts**: last five sessions across all your projects.
9
+ ### TUI (tab navigation)
10
+
11
+ - **Session status bar** always visible: model, total tokens, estimated cost, message count, time since session start, mini activity sparkline, live/idle indicator.
12
+ - **[1] Breakdown** input / output / cache-write / cache-read token bars with percentages and per-category cost estimates, cache hit ratio, per-model totals, and a 32-second activity sparkline with peak/avg rate.
13
+ - **[2] History** — today / 7-day / 30-day aggregate: tokens, estimated cost, session count, top model. Data is persisted locally in `~/.tokens-metric/history.json` so historical totals survive transcript rotation.
14
+ - **[3] Sessions** — today's sessions sorted by start time: project path, model, tokens, cost, duration, active indicator.
15
+ - **[4] Transcripts** — last five transcript files with recency timestamps.
16
+ - **Update notifier** — checks npm once every 24 h (cached) and shows a banner when a newer version is available.
17
+
18
+ ### Statusline
19
+
20
+ A compact one-line output for embedding in Claude Code's status bar.
13
21
 
14
22
  ## Install
15
23
 
@@ -23,7 +31,7 @@ Or run without installing:
23
31
  npx tokens-metric
24
32
  ```
25
33
 
26
- Requires Node 18+ and an existing Claude Code installation.
34
+ Requires **Node 18+** and an existing Claude Code installation.
27
35
 
28
36
  ## Usage
29
37
 
@@ -33,16 +41,22 @@ Requires Node 18+ and an existing Claude Code installation.
33
41
  tokens-metric
34
42
  ```
35
43
 
36
- Press `q` (or `Esc`, or `Ctrl-C`) to quit.
44
+ | Key | Action |
45
+ |-----|--------|
46
+ | `←` / `→` | Move cursor between tabs |
47
+ | `Enter` | Open / collapse the focused tab |
48
+ | `1` – `4` | Jump directly to a tab and open it |
49
+ | `Esc` | Collapse the open panel |
50
+ | `q` / `Ctrl-C` | Quit |
37
51
 
38
52
  ### Privacy defaults
39
53
 
40
- Starting in v0.2.0, the TUI masks identifying information by default so screenshots can be shared safely:
54
+ Starting in v0.2.0 the TUI masks identifying information by default so screenshots can be shared safely:
41
55
 
42
56
  - `cwd` paths are shown as `~/…` instead of `/Users/<you>/…`.
43
57
  - The user ID is masked to `●●●●●●●●`.
44
58
 
45
- Pass `--reveal` to show everything unmasked on your own machine:
59
+ Pass `--reveal` to show everything unmasked:
46
60
 
47
61
  ```bash
48
62
  tokens-metric --reveal
@@ -69,7 +83,7 @@ Output looks like:
69
83
 
70
84
  ## Honest limitations
71
85
 
72
- 1. **Plan tier is heuristic.** We read flags Claude Code writes locally (e.g. `opusProMigrationComplete`, `cachedExtraUsageDisabledReason`). Anthropic can rename these at any release; if they change, the detector falls back to `unknown` instead of breaking.
86
+ 1. **Plan tier is heuristic.** We read flags Claude Code writes locally (e.g. `opusProMigrationComplete`). Anthropic can rename these at any release; the detector falls back to `unknown` instead of breaking.
73
87
  2. **Pro vs. Max are not distinguishable locally.** Both look identical from the config file.
74
88
  3. **The USD figure is API-equivalent pricing, not what you actually pay.** On Pro/Max/Team you pay a flat subscription — the dollar number is purely a reference for what the same tokens would cost on the API.
75
89
  4. **Prices are hardcoded.** See `src/core/format.ts`. If Anthropic updates pricing, the numbers drift until you bump the package.
@@ -81,9 +95,12 @@ Output looks like:
81
95
  ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
82
96
 
83
97
 
84
- src/core/parser.ts reads + aggregates usage per message
85
- src/core/tailer.ts watches the active file with fs.watch
86
- src/core/detect.ts reads ~/.claude.json for auth + plan hints
98
+ src/core/parser.ts reads + aggregates usage per message
99
+ src/core/tailer.ts watches the active file with fs.watch
100
+ src/core/detect.ts reads ~/.claude.json for auth + plan hints
101
+ src/core/history.ts per-day aggregation with mtime-based cache
102
+ src/core/history-store.ts persists daily aggregates to ~/.tokens-metric/
103
+ src/core/updater.ts npm version check, 24h cache
87
104
 
88
105
  ┌────────┴────────┐
89
106
  ▼ ▼
@@ -101,6 +118,14 @@ npm run dev:statusline # prints one line
101
118
  npm run build # builds dist/
102
119
  ```
103
120
 
121
+ ## Roadmap
122
+
123
+ - **Windows support** — Claude Code on Windows stores transcripts in a different location (`%APPDATA%\Claude\`). This requires abstracting `claudeHome()` in `detect.ts` to resolve the correct path per OS, and handling Windows path separators in project folder names.
124
+
125
+ - **Multi-provider support (Codex, Gemini, etc.)** — The pricing table in `format.ts` currently covers Claude models only. Adding other providers means extending the table with `gpt-*`, `gemini-*`, `o1-*` prefixes, investigating whether those agents produce compatible `.jsonl` transcripts, and likely allowing the user to point to additional transcript folders.
126
+
127
+ - **Context window usage** — Show how much of the active session's context window is consumed. Claude Code may log this in the transcript; if not, it can be inferred per model (e.g. claude-3.5-sonnet = 200k tokens). Would appear as a progress bar in the session status bar or breakdown panel.
128
+
104
129
  ## License
105
130
 
106
131
  MIT — see [LICENSE](./LICENSE).
package/dist/tui/index.js CHANGED
@@ -34,6 +34,10 @@ function App() {
34
34
  const [lastTailAt, setLastTailAt] = useState(null);
35
35
  const [history, setHistory] = useState(null);
36
36
  const [updateAvailable, setUpdateAvailable] = useState(null);
37
+ // focusedTab: where the cursor sits (arrow navigation)
38
+ // openTab: which panel is expanded (null = all collapsed)
39
+ const [focusedTab, setFocusedTab] = useState(1);
40
+ const [openTab, setOpenTab] = useState(null);
37
41
  const startedAtRef = useRef(Date.now());
38
42
  const lastTotalRef = useRef(0);
39
43
  // useInput requires raw mode (interactive TTY). Skip it when stdin is piped
@@ -43,8 +47,43 @@ function App() {
43
47
  if (interactive) {
44
48
  // eslint-disable-next-line react-hooks/rules-of-hooks
45
49
  useInput((input, key) => {
46
- if (input === 'q' || key.escape || (key.ctrl && input === 'c'))
50
+ if (input === 'q' || (key.ctrl && input === 'c'))
47
51
  exit();
52
+ if (key.escape) {
53
+ setOpenTab(null);
54
+ return;
55
+ }
56
+ // Arrow keys move the cursor
57
+ if (key.leftArrow) {
58
+ setFocusedTab((t) => (t > 1 ? (t - 1) : t));
59
+ return;
60
+ }
61
+ if (key.rightArrow) {
62
+ setFocusedTab((t) => (t < 4 ? (t + 1) : t));
63
+ return;
64
+ }
65
+ // Enter opens/collapses the focused tab
66
+ if (key.return) {
67
+ setOpenTab((o) => (o === focusedTab ? null : focusedTab));
68
+ return;
69
+ }
70
+ // Number shortcuts: jump directly and open
71
+ if (input === '1') {
72
+ setFocusedTab(1);
73
+ setOpenTab(1);
74
+ }
75
+ if (input === '2') {
76
+ setFocusedTab(2);
77
+ setOpenTab(2);
78
+ }
79
+ if (input === '3') {
80
+ setFocusedTab(3);
81
+ setOpenTab(3);
82
+ }
83
+ if (input === '4') {
84
+ setFocusedTab(4);
85
+ setOpenTab(4);
86
+ }
48
87
  });
49
88
  }
50
89
  // Check for newer version on npm — non-blocking, result cached 24h.
@@ -134,15 +173,17 @@ function App() {
134
173
  const today = countToday(allTranscripts, now);
135
174
  const ratePerSec = series.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
136
175
  const todaySessions = getTodaySessions(now, transcriptPath);
137
- 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, updateAvailable: updateAvailable }), _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"] }) })] }));
176
+ 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, updateAvailable: updateAvailable }), _jsx(Box, { marginTop: 1, children: _jsx(SessionStatusBar, { stats: stats, ratePerSec: ratePerSec, now: now, series: series }) }), _jsx(Box, { marginTop: 1, children: _jsx(TabBar, { focusedTab: focusedTab, openTab: openTab }) }), openTab !== null && (_jsxs(Box, { marginTop: 1, children: [openTab === 1 && (_jsx(BreakdownPanel, { stats: stats, series: series, ratePerSec: ratePerSec })), openTab === 2 && _jsx(HistoryPanel, { history: history }), openTab === 3 && _jsx(TodaySessionsPanel, { sessions: todaySessions, now: now }), openTab === 4 && (_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: "\u2190\u2192" }), " move \u00B7", ' ', _jsx(Text, { color: "magenta", children: "enter" }), " open/close \u00B7", ' ', _jsx(Text, { color: "magenta", children: "1\u20134" }), " jump \u00B7 pricing is", ' ', _jsx(Text, { italic: true, children: "API-equivalent" }), ", not your real bill on a subscription"] }) })] }));
138
177
  }
139
178
  // ── Header ───────────────────────────────────────────────────────────────────
140
179
  function Header({ auth, sessionsToday, projectsToday, lastTailAt, startedAt, now, updateAvailable, }) {
141
180
  const ok = auth.installed && auth.loggedIn;
142
181
  const dot = ok ? 'green' : auth.installed ? 'yellow' : 'red';
143
- const tailLabel = lastTailAt ? `updated ${timeAgo(now - lastTailAt)} ago` : 'waiting…';
144
- const tailColor = !lastTailAt ? 'gray' : now - lastTailAt < 10_000 ? 'green' : 'yellow';
145
- 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) })] }), updateAvailable && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "\u26A1 Update available: " }), _jsxs(Text, { dimColor: true, children: ["v", pkg.version] }), _jsx(Text, { color: "yellow", children: " \u2192 " }), _jsxs(Text, { color: "yellow", bold: true, children: ["v", updateAvailable] }), _jsx(Text, { dimColor: true, children: " npm install -g tokens-metric" })] }))] }));
182
+ const tailAgo = lastTailAt ? `updated ${timeAgo(now - lastTailAt)} ago` : 'waiting…';
183
+ const tailIsLive = !!lastTailAt && now - lastTailAt < 10_000;
184
+ const tailColor = !lastTailAt ? 'gray' : tailIsLive ? 'green' : 'yellow';
185
+ const tailStatusText = !lastTailAt ? '' : tailIsLive ? '● live' : '⚠ stale';
186
+ 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 " }), tailStatusText ? _jsxs(Text, { color: tailColor, children: [tailStatusText, " "] }) : null, _jsx(Text, { dimColor: true, children: tailAgo }), _jsx(Text, { dimColor: true, children: ' · uptime ' }), _jsx(Text, { children: timeAgo(now - startedAt) })] }), updateAvailable && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "\u26A1 Update available: " }), _jsxs(Text, { dimColor: true, children: ["v", pkg.version] }), _jsx(Text, { color: "yellow", children: " \u2192 " }), _jsxs(Text, { color: "yellow", bold: true, children: ["v", updateAvailable] }), _jsx(Text, { dimColor: true, children: " npm install -g tokens-metric" })] }))] }));
146
187
  }
147
188
  function countToday(transcripts, now) {
148
189
  const startOfDay = new Date(now);
@@ -158,23 +199,51 @@ function countToday(transcripts, now) {
158
199
  }
159
200
  return { sessions, projects: projects.size };
160
201
  }
161
- // ── Session panel ────────────────────────────────────────────────────────────
162
- function SessionPanel({ stats, ratePerSec, now, series, }) {
163
- 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" })] }), (() => {
164
- const cost = estimateCostUSD(stats.lastModel ?? '', stats.totals);
165
- return cost !== null ? (_jsxs(Text, { dimColor: true, children: ["~", fmtUSD(cost), " API-equivalent"] })) : null;
166
- })(), 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"] }) }))] }))] }));
202
+ // ── Session status bar (always-visible compact one-liner) ────────────────────
203
+ function SessionStatusBar({ stats, ratePerSec, now, series, }) {
204
+ if (!stats) {
205
+ return (_jsx(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "\u25CB No active session \u2014 waiting for Claude Code\u2026" }) }));
206
+ }
207
+ const isIdle = !stats.lastEventAt || now - stats.lastEventAt > 60_000;
208
+ const cost = estimateCostUSD(stats.lastModel ?? '', stats.totals);
209
+ const tokens = totalTokens(stats.totals);
210
+ const hasActivity = series.some((n) => n > 0);
211
+ const MINI_SPARK = 16;
212
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "row", flexWrap: "wrap", children: [_jsx(Text, { color: "green", bold: true, children: "\u25CF " }), _jsx(Text, { bold: true, color: "cyan", children: shortModel(stats.lastModel ?? '—') }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { bold: true, children: fmtNumber(tokens) }), _jsx(Text, { dimColor: true, children: " tok" }), cost !== null && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u00B7 ~" }), _jsx(Text, { children: fmtUSD(cost) })] })), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { children: stats.messageCount }), _jsx(Text, { dimColor: true, children: " msgs" }), stats.startedAt !== undefined && stats.startedAt !== null && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u00B7 since " }), _jsx(Text, { children: fmtTime(stats.startedAt) })] })), hasActivity && (_jsxs(_Fragment, { children: [_jsx(Text, { children: ' ' }), sparklineCells(series, MINI_SPARK).map((cell, i) => {
213
+ const isCurrentSlot = i === MINI_SPARK - 1 && cell.intensity > 0;
214
+ return (_jsx(Text, { color: isCurrentSlot ? 'white' : intensityColor(cell.intensity), bold: isCurrentSlot, children: cell.char }, i));
215
+ })] })), _jsx(Text, { children: ' ' }), _jsx(Text, { color: isIdle ? 'gray' : 'green', children: isIdle ? '○ idle' : '● live' }), stats.lastEventAt && (_jsx(Text, { dimColor: true, children: ` ${timeAgo(now - stats.lastEventAt)} ago` }))] }));
216
+ }
217
+ // ── Tab bar ──────────────────────────────────────────────────────────────────
218
+ function TabBar({ focusedTab, openTab, }) {
219
+ const tabs = [
220
+ { id: 1, label: 'Breakdown' },
221
+ { id: 2, label: 'History' },
222
+ { id: 3, label: 'Sessions' },
223
+ { id: 4, label: 'Transcripts' },
224
+ ];
225
+ return (_jsx(Box, { paddingX: 1, children: tabs.map((tab) => {
226
+ const isFocused = focusedTab === tab.id;
227
+ const isOpen = openTab === tab.id;
228
+ // open+focused → cyan bold; focused only → white bold (cursor); open only → cyan; rest → dim
229
+ const color = isOpen ? 'cyan' : isFocused ? 'white' : undefined;
230
+ const dim = !isFocused && !isOpen;
231
+ return (_jsx(Box, { marginRight: 3, children: _jsxs(Text, { color: color, bold: isFocused, dimColor: dim, children: [isFocused ? '›' : ' ', "[", tab.id, "] ", tab.label, isOpen ? ' ▾' : ''] }) }, tab.id));
232
+ }) }));
167
233
  }
168
234
  // ── Breakdown panel ──────────────────────────────────────────────────────────
169
- function BreakdownPanel({ stats }) {
170
- 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." })) : ((() => {
235
+ function BreakdownPanel({ stats, series, ratePerSec, }) {
236
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", flexGrow: 1, minWidth: 42, children: [_jsxs(Text, { bold: true, color: "green", children: ['Token breakdown', _jsx(Text, { bold: false, color: "green", children: " \u00B7 live data" })] }), series.some((n) => n > 0) ? (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["activity (last ", SPARK_WIDTH, "s) peak "] }), _jsx(Text, { bold: true, color: "yellow", children: fmtNumber(Math.max(...series)) }), _jsx(Text, { dimColor: true, children: "/s" }), Math.round(ratePerSec) > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " avg " }), _jsx(Text, { color: "yellow", children: fmtNumber(Math.round(ratePerSec)) }), _jsx(Text, { dimColor: true, children: "/s" })] }))] }), _jsx(Text, { children: sparklineCells(series, SPARK_WIDTH).map((cell, i) => {
237
+ const isCurrentSlot = i === SPARK_WIDTH - 1 && cell.intensity > 0;
238
+ return (_jsx(Text, { color: isCurrentSlot ? 'white' : intensityColor(cell.intensity), bold: isCurrentSlot, children: cell.char }, i));
239
+ }) }), _jsx(Text, { dimColor: true, children: axisLabel(SPARK_WIDTH) })] })) : (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["\u25CB idle \u2014 no activity in the last ", SPARK_WIDTH, "s"] }) })), !stats ? (_jsx(Text, { dimColor: true, children: "No session data yet." })) : ((() => {
171
240
  const u = stats.totals;
172
241
  const max = Math.max(1, u.input_tokens, u.output_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens);
173
242
  const total = totalTokens(u);
174
243
  const model = stats.lastModel ?? '';
175
244
  const cacheDenom = u.input_tokens + u.cache_read_input_tokens;
176
245
  const hitRatio = cacheDenom > 0 ? u.cache_read_input_tokens / cacheDenom : null;
177
- 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]) => {
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]) => {
178
247
  const c = estimateCostUSD(m, mu);
179
248
  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));
180
249
  })] }))] }));
@@ -186,7 +255,7 @@ function BarRow({ label, value, max, total, color, cost, }) {
186
255
  }
187
256
  // ── History panel ────────────────────────────────────────────────────────────
188
257
  function HistoryPanel({ history }) {
189
- 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 &&
258
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "blue", paddingX: 1, flexDirection: "column", width: "100%", children: [_jsxs(Text, { bold: true, color: "blue", children: ['Usage history', _jsx(Text, { bold: false, color: "blue", children: " \u00B7 refreshes every 60s" })] }), !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 &&
190
259
  ` · data since ${fmtDate(history.oldestMtimeMs)} (${daysAgo(history.oldestMtimeMs, history.generatedAt)} days)`] }) })] }))] }));
191
260
  }
192
261
  function fmtDate(ms) {
@@ -212,7 +281,7 @@ function fmtTopModel(b) {
212
281
  }
213
282
  // ── Today's sessions panel ───────────────────────────────────────────────────
214
283
  function TodaySessionsPanel({ sessions, now }) {
215
- 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) => {
284
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "blue", paddingX: 1, flexDirection: "column", width: "100%", children: [_jsxs(Text, { bold: true, color: "blue", children: ["Today's sessions", _jsx(Text, { bold: false, color: "blue", children: ` · ${sessions.length} ${plural(sessions.length, 'session', 'sessions')} today` })] }), sessions.map((s) => {
216
285
  const tokens = Object.values(s.byModel).reduce((acc, u) => acc + totalTokens(u), 0);
217
286
  const cost = Object.entries(s.byModel).reduce((acc, [m, u]) => {
218
287
  const c = estimateCostUSD(m, u);
@@ -252,9 +321,18 @@ function KV({ k, v }) {
252
321
  }
253
322
  function fmtTime(ms) {
254
323
  const d = new Date(ms);
324
+ const now = new Date();
325
+ const isToday = d.getFullYear() === now.getFullYear() &&
326
+ d.getMonth() === now.getMonth() &&
327
+ d.getDate() === now.getDate();
255
328
  const h = String(d.getHours()).padStart(2, '0');
256
329
  const m = String(d.getMinutes()).padStart(2, '0');
257
- return `${h}:${m}`;
330
+ const time = `${h}:${m}`;
331
+ if (isToday)
332
+ return time;
333
+ const month = String(d.getMonth() + 1).padStart(2, '0');
334
+ const day = String(d.getDate()).padStart(2, '0');
335
+ return `${d.getFullYear()}-${month}-${day} ${time}`;
258
336
  }
259
337
  function plural(n, one, many) {
260
338
  return n === 1 ? one : many;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokens-metric",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Real-time token usage meter for Claude Code — statusline + Ink TUI.",
5
5
  "type": "module",
6
6
  "bin": {