tokens-metric 0.4.3 → 0.4.5

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.
@@ -14,6 +14,9 @@ export function codexHome() {
14
14
  export function codexSessionsDir() {
15
15
  return join(codexHome(), 'sessions');
16
16
  }
17
+ export function isCodexInstalled() {
18
+ return existsSync(codexHome());
19
+ }
17
20
  /**
18
21
  * Detect whether Claude Code is installed, the user is logged in, and best-
19
22
  * effort what plan they're on. All signals are LOCAL and BEST-EFFORT — we
package/dist/tui/index.js CHANGED
@@ -4,9 +4,9 @@ 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 { findMostRecentActiveTranscript, listTranscripts } from '../core/parser.js';
7
+ import { findActiveTranscript, findActiveCodexTranscript, listTranscripts } from '../core/parser.js';
8
8
  import { tailTranscript } from '../core/tailer.js';
9
- import { detectAuth } from '../core/detect.js';
9
+ import { detectAuth, isCodexInstalled } from '../core/detect.js';
10
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';
@@ -29,11 +29,20 @@ const BAR_WIDTH = 20;
29
29
  function App() {
30
30
  const { exit } = useApp();
31
31
  const [auth] = useState(() => detectAuth());
32
- const [stats, setStats] = useState(null);
33
- const [transcriptPath, setTranscriptPath] = useState(null);
34
- const [series, setSeries] = useState(() => Array(SPARK_WIDTH).fill(0));
32
+ const [codexDetected] = useState(() => isCodexInstalled());
33
+ // Claude source
34
+ const [claudeStats, setClaudeStats] = useState(null);
35
+ const [claudePath, setClaudePath] = useState(null);
36
+ const [claudeSeries, setClaudeSeries] = useState(() => Array(SPARK_WIDTH).fill(0));
37
+ const [claudeLastTailAt, setClaudeLastTailAt] = useState(null);
38
+ const claudeLastTotalRef = useRef(0);
39
+ // Codex source
40
+ const [codexStats, setCodexStats] = useState(null);
41
+ const [codexPath, setCodexPath] = useState(null);
42
+ const [codexSeries, setCodexSeries] = useState(() => Array(SPARK_WIDTH).fill(0));
43
+ const [codexLastTailAt, setCodexLastTailAt] = useState(null);
44
+ const codexLastTotalRef = useRef(0);
35
45
  const [now, setNow] = useState(Date.now());
36
- const [lastTailAt, setLastTailAt] = useState(null);
37
46
  const [history, setHistory] = useState(null);
38
47
  const [updateAvailable, setUpdateAvailable] = useState(null);
39
48
  // focusedTab: where the cursor sits (arrow navigation)
@@ -41,7 +50,6 @@ function App() {
41
50
  const [focusedTab, setFocusedTab] = useState(1);
42
51
  const [openTab, setOpenTab] = useState(null);
43
52
  const startedAtRef = useRef(Date.now());
44
- const lastTotalRef = useRef(0);
45
53
  // useInput requires raw mode (interactive TTY). Skip it when stdin is piped
46
54
  // or otherwise non-interactive, so `node dist/tui/index.js | cat` still
47
55
  // renders instead of crashing.
@@ -120,45 +128,88 @@ function App() {
120
128
  useEffect(() => {
121
129
  const t = setInterval(() => {
122
130
  setNow(Date.now());
123
- setSeries((s) => [...s.slice(1), 0]);
131
+ setClaudeSeries((s) => [...s.slice(1), 0]);
132
+ setCodexSeries((s) => [...s.slice(1), 0]);
124
133
  }, 1000);
125
134
  return () => clearInterval(t);
126
135
  }, []);
136
+ // Claude tailing
127
137
  useEffect(() => {
128
138
  let handle = null;
129
139
  let cancelled = false;
130
140
  async function attach(path) {
131
141
  handle?.stop().catch(() => undefined);
132
- setTranscriptPath(path);
142
+ setClaudePath(path);
133
143
  handle = await tailTranscript(path);
134
144
  if (cancelled) {
135
145
  handle.stop();
136
146
  return;
137
147
  }
138
- // Seed initial state — tailTranscript already drained the file once
139
- // before returning, but its first notify() fires before any listener
140
- // is attached, so we'd otherwise wait for the next appended line.
141
- lastTotalRef.current = totalTokens(handle.stats.totals);
142
- setLastTailAt(Date.now());
143
- setStats({ ...handle.stats });
148
+ claudeLastTotalRef.current = totalTokens(handle.stats.totals);
149
+ setClaudeLastTailAt(Date.now());
150
+ setClaudeStats({ ...handle.stats });
144
151
  handle.onUpdate((s) => {
145
- setLastTailAt(Date.now());
152
+ setClaudeLastTailAt(Date.now());
146
153
  const tot = totalTokens(s.totals);
147
- const delta = Math.max(0, tot - lastTotalRef.current);
154
+ const delta = Math.max(0, tot - claudeLastTotalRef.current);
148
155
  if (delta > 0) {
149
- setSeries((arr) => {
156
+ setClaudeSeries((arr) => {
150
157
  const next = arr.slice();
151
158
  next[next.length - 1] = (next[next.length - 1] ?? 0) + delta;
152
159
  return next;
153
160
  });
154
161
  }
155
- lastTotalRef.current = tot;
156
- setStats({ ...s });
162
+ claudeLastTotalRef.current = tot;
163
+ setClaudeStats({ ...s });
157
164
  });
158
165
  }
159
166
  async function rescan() {
160
- const active = findMostRecentActiveTranscript();
161
- if (active && active.path !== transcriptPath)
167
+ const active = findActiveTranscript();
168
+ if (active && active.path !== claudePath)
169
+ await attach(active.path);
170
+ }
171
+ rescan();
172
+ const interval = setInterval(rescan, RESCAN_MS);
173
+ return () => {
174
+ cancelled = true;
175
+ clearInterval(interval);
176
+ handle?.stop().catch(() => undefined);
177
+ };
178
+ // eslint-disable-next-line react-hooks/exhaustive-deps
179
+ }, []);
180
+ // Codex tailing
181
+ useEffect(() => {
182
+ let handle = null;
183
+ let cancelled = false;
184
+ async function attach(path) {
185
+ handle?.stop().catch(() => undefined);
186
+ setCodexPath(path);
187
+ handle = await tailTranscript(path);
188
+ if (cancelled) {
189
+ handle.stop();
190
+ return;
191
+ }
192
+ codexLastTotalRef.current = totalTokens(handle.stats.totals);
193
+ setCodexLastTailAt(Date.now());
194
+ setCodexStats({ ...handle.stats });
195
+ handle.onUpdate((s) => {
196
+ setCodexLastTailAt(Date.now());
197
+ const tot = totalTokens(s.totals);
198
+ const delta = Math.max(0, tot - codexLastTotalRef.current);
199
+ if (delta > 0) {
200
+ setCodexSeries((arr) => {
201
+ const next = arr.slice();
202
+ next[next.length - 1] = (next[next.length - 1] ?? 0) + delta;
203
+ return next;
204
+ });
205
+ }
206
+ codexLastTotalRef.current = tot;
207
+ setCodexStats({ ...s });
208
+ });
209
+ }
210
+ async function rescan() {
211
+ const active = findActiveCodexTranscript();
212
+ if (active && active.path !== codexPath)
162
213
  await attach(active.path);
163
214
  }
164
215
  rescan();
@@ -173,19 +224,31 @@ function App() {
173
224
  const allTranscripts = listTranscripts();
174
225
  const transcripts = allTranscripts.slice(0, 5);
175
226
  const today = countToday(allTranscripts, now);
176
- const ratePerSec = series.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
177
- const todaySessions = getTodaySessions(now, transcriptPath);
178
- 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"] }) })] }));
227
+ const claudeRate = claudeSeries.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
228
+ const codexRate = codexSeries.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
229
+ const todaySessions = getTodaySessions(now, claudePath);
230
+ // BreakdownPanel follows the most recently active source
231
+ const claudeAt = claudeStats?.lastEventAt ?? 0;
232
+ const codexAt = codexStats?.lastEventAt ?? 0;
233
+ const primaryStats = codexAt > claudeAt ? codexStats : claudeStats;
234
+ const primarySeries = codexAt > claudeAt ? codexSeries : claudeSeries;
235
+ const primaryRate = codexAt > claudeAt ? codexRate : claudeRate;
236
+ const lastTailAt = claudeLastTailAt && codexLastTailAt
237
+ ? Math.max(claudeLastTailAt, codexLastTailAt)
238
+ : claudeLastTailAt ?? codexLastTailAt;
239
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Header, { auth: auth, codexDetected: codexDetected, sessionsToday: today.sessions, projectsToday: today.projects, lastTailAt: lastTailAt, startedAt: startedAtRef.current, now: now, updateAvailable: updateAvailable }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [claudeStats
240
+ ? _jsx(SessionStatusBar, { stats: claudeStats, ratePerSec: claudeRate, now: now, series: claudeSeries })
241
+ : !codexStats && _jsx(SessionStatusBar, { stats: null, ratePerSec: 0, now: now, series: claudeSeries }), codexStats && (_jsx(Box, { marginTop: claudeStats ? 1 : 0, children: _jsx(SessionStatusBar, { stats: codexStats, ratePerSec: codexRate, now: now, series: codexSeries }) }))] }), _jsx(Box, { marginTop: 1, children: _jsx(TabBar, { focusedTab: focusedTab, openTab: openTab }) }), openTab !== null && (_jsxs(Box, { marginTop: 1, children: [openTab === 1 && (_jsx(BreakdownPanel, { stats: primaryStats, series: primarySeries, ratePerSec: primaryRate })), openTab === 2 && _jsx(HistoryPanel, { history: history }), openTab === 3 && _jsx(TodaySessionsPanel, { sessions: todaySessions, now: now }), openTab === 4 && (_jsx(TranscriptsPanel, { transcripts: transcripts, activePath: claudePath, 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"] }) })] }));
179
242
  }
180
243
  // ── Header ───────────────────────────────────────────────────────────────────
181
- function Header({ auth, sessionsToday, projectsToday, lastTailAt, startedAt, now, updateAvailable, }) {
244
+ function Header({ auth, codexDetected, sessionsToday, projectsToday, lastTailAt, startedAt, now, updateAvailable, }) {
182
245
  const ok = auth.installed && auth.loggedIn;
183
246
  const dot = ok ? 'green' : auth.installed ? 'yellow' : 'red';
184
247
  const tailAgo = lastTailAt ? `updated ${timeAgo(now - lastTailAt)} ago` : 'waiting…';
185
248
  const tailIsLive = !!lastTailAt && now - lastTailAt < 10_000;
186
249
  const tailColor = !lastTailAt ? 'gray' : tailIsLive ? 'green' : 'yellow';
187
250
  const tailStatusText = !lastTailAt ? '' : tailIsLive ? '● live' : '⚠ stale';
188
- 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" })] }))] }));
251
+ 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, { color: codexDetected ? 'green' : 'red', children: "\u25CF" }), ' ', codexDetected ? 'Codex detected' : 'Codex 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" })] }))] }));
189
252
  }
190
253
  function countToday(transcripts, now) {
191
254
  const startOfDay = new Date(now);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tokens-metric",
3
- "version": "0.4.3",
4
- "description": "Real-time token usage meter for Claude Code \u2014 statusline + Ink TUI.",
3
+ "version": "0.4.5",
4
+ "description": "Real-time token usage meter for Claude Code statusline + Ink TUI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "tokens-metric": "dist/tui/index.js",