tokens-metric 0.4.2 → 0.4.4
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/dist/core/detect.js +6 -0
- package/dist/core/format.js +22 -0
- package/dist/core/history.js +91 -9
- package/dist/core/parser.js +131 -1
- package/dist/core/tailer.js +5 -3
- package/dist/statusline/index.js +3 -3
- package/dist/tui/index.js +85 -23
- package/package.json +1 -1
package/dist/core/detect.js
CHANGED
|
@@ -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
|
package/dist/core/format.js
CHANGED
|
@@ -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) {
|
package/dist/core/history.js
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
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 =
|
|
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(
|
|
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.
|
package/dist/core/parser.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -142,3 +142,133 @@ function parseTimestamp(v) {
|
|
|
142
142
|
const n = Date.parse(v);
|
|
143
143
|
return Number.isFinite(n) ? n : undefined;
|
|
144
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
|
+
}
|
package/dist/core/tailer.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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,
|
|
@@ -44,7 +46,7 @@ export async function tailTranscript(path) {
|
|
|
44
46
|
const line = buf.slice(0, nl);
|
|
45
47
|
buf = buf.slice(nl + 1);
|
|
46
48
|
if (line.trim()) {
|
|
47
|
-
|
|
49
|
+
lineApplier(stats, line);
|
|
48
50
|
changed = true;
|
|
49
51
|
}
|
|
50
52
|
}
|
package/dist/statusline/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
|
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,7 +4,7 @@ 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 { findActiveTranscript, findActiveCodexTranscript, listTranscripts } from '../core/parser.js';
|
|
8
8
|
import { tailTranscript } from '../core/tailer.js';
|
|
9
9
|
import { detectAuth } from '../core/detect.js';
|
|
10
10
|
import { categoryCostUSD, contextWindowSize, estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
|
|
@@ -29,11 +29,19 @@ const BAR_WIDTH = 20;
|
|
|
29
29
|
function App() {
|
|
30
30
|
const { exit } = useApp();
|
|
31
31
|
const [auth] = useState(() => detectAuth());
|
|
32
|
-
|
|
33
|
-
const [
|
|
34
|
-
const [
|
|
32
|
+
// Claude source
|
|
33
|
+
const [claudeStats, setClaudeStats] = useState(null);
|
|
34
|
+
const [claudePath, setClaudePath] = useState(null);
|
|
35
|
+
const [claudeSeries, setClaudeSeries] = useState(() => Array(SPARK_WIDTH).fill(0));
|
|
36
|
+
const [claudeLastTailAt, setClaudeLastTailAt] = useState(null);
|
|
37
|
+
const claudeLastTotalRef = useRef(0);
|
|
38
|
+
// Codex source
|
|
39
|
+
const [codexStats, setCodexStats] = useState(null);
|
|
40
|
+
const [codexPath, setCodexPath] = useState(null);
|
|
41
|
+
const [codexSeries, setCodexSeries] = useState(() => Array(SPARK_WIDTH).fill(0));
|
|
42
|
+
const [codexLastTailAt, setCodexLastTailAt] = useState(null);
|
|
43
|
+
const codexLastTotalRef = useRef(0);
|
|
35
44
|
const [now, setNow] = useState(Date.now());
|
|
36
|
-
const [lastTailAt, setLastTailAt] = useState(null);
|
|
37
45
|
const [history, setHistory] = useState(null);
|
|
38
46
|
const [updateAvailable, setUpdateAvailable] = useState(null);
|
|
39
47
|
// focusedTab: where the cursor sits (arrow navigation)
|
|
@@ -41,7 +49,6 @@ function App() {
|
|
|
41
49
|
const [focusedTab, setFocusedTab] = useState(1);
|
|
42
50
|
const [openTab, setOpenTab] = useState(null);
|
|
43
51
|
const startedAtRef = useRef(Date.now());
|
|
44
|
-
const lastTotalRef = useRef(0);
|
|
45
52
|
// useInput requires raw mode (interactive TTY). Skip it when stdin is piped
|
|
46
53
|
// or otherwise non-interactive, so `node dist/tui/index.js | cat` still
|
|
47
54
|
// renders instead of crashing.
|
|
@@ -120,45 +127,88 @@ function App() {
|
|
|
120
127
|
useEffect(() => {
|
|
121
128
|
const t = setInterval(() => {
|
|
122
129
|
setNow(Date.now());
|
|
123
|
-
|
|
130
|
+
setClaudeSeries((s) => [...s.slice(1), 0]);
|
|
131
|
+
setCodexSeries((s) => [...s.slice(1), 0]);
|
|
124
132
|
}, 1000);
|
|
125
133
|
return () => clearInterval(t);
|
|
126
134
|
}, []);
|
|
135
|
+
// Claude tailing
|
|
127
136
|
useEffect(() => {
|
|
128
137
|
let handle = null;
|
|
129
138
|
let cancelled = false;
|
|
130
139
|
async function attach(path) {
|
|
131
140
|
handle?.stop().catch(() => undefined);
|
|
132
|
-
|
|
141
|
+
setClaudePath(path);
|
|
133
142
|
handle = await tailTranscript(path);
|
|
134
143
|
if (cancelled) {
|
|
135
144
|
handle.stop();
|
|
136
145
|
return;
|
|
137
146
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
lastTotalRef.current = totalTokens(handle.stats.totals);
|
|
142
|
-
setLastTailAt(Date.now());
|
|
143
|
-
setStats({ ...handle.stats });
|
|
147
|
+
claudeLastTotalRef.current = totalTokens(handle.stats.totals);
|
|
148
|
+
setClaudeLastTailAt(Date.now());
|
|
149
|
+
setClaudeStats({ ...handle.stats });
|
|
144
150
|
handle.onUpdate((s) => {
|
|
145
|
-
|
|
151
|
+
setClaudeLastTailAt(Date.now());
|
|
146
152
|
const tot = totalTokens(s.totals);
|
|
147
|
-
const delta = Math.max(0, tot -
|
|
153
|
+
const delta = Math.max(0, tot - claudeLastTotalRef.current);
|
|
148
154
|
if (delta > 0) {
|
|
149
|
-
|
|
155
|
+
setClaudeSeries((arr) => {
|
|
150
156
|
const next = arr.slice();
|
|
151
157
|
next[next.length - 1] = (next[next.length - 1] ?? 0) + delta;
|
|
152
158
|
return next;
|
|
153
159
|
});
|
|
154
160
|
}
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
claudeLastTotalRef.current = tot;
|
|
162
|
+
setClaudeStats({ ...s });
|
|
157
163
|
});
|
|
158
164
|
}
|
|
159
165
|
async function rescan() {
|
|
160
166
|
const active = findActiveTranscript();
|
|
161
|
-
if (active && active.path !==
|
|
167
|
+
if (active && active.path !== claudePath)
|
|
168
|
+
await attach(active.path);
|
|
169
|
+
}
|
|
170
|
+
rescan();
|
|
171
|
+
const interval = setInterval(rescan, RESCAN_MS);
|
|
172
|
+
return () => {
|
|
173
|
+
cancelled = true;
|
|
174
|
+
clearInterval(interval);
|
|
175
|
+
handle?.stop().catch(() => undefined);
|
|
176
|
+
};
|
|
177
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
178
|
+
}, []);
|
|
179
|
+
// Codex tailing
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
let handle = null;
|
|
182
|
+
let cancelled = false;
|
|
183
|
+
async function attach(path) {
|
|
184
|
+
handle?.stop().catch(() => undefined);
|
|
185
|
+
setCodexPath(path);
|
|
186
|
+
handle = await tailTranscript(path);
|
|
187
|
+
if (cancelled) {
|
|
188
|
+
handle.stop();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
codexLastTotalRef.current = totalTokens(handle.stats.totals);
|
|
192
|
+
setCodexLastTailAt(Date.now());
|
|
193
|
+
setCodexStats({ ...handle.stats });
|
|
194
|
+
handle.onUpdate((s) => {
|
|
195
|
+
setCodexLastTailAt(Date.now());
|
|
196
|
+
const tot = totalTokens(s.totals);
|
|
197
|
+
const delta = Math.max(0, tot - codexLastTotalRef.current);
|
|
198
|
+
if (delta > 0) {
|
|
199
|
+
setCodexSeries((arr) => {
|
|
200
|
+
const next = arr.slice();
|
|
201
|
+
next[next.length - 1] = (next[next.length - 1] ?? 0) + delta;
|
|
202
|
+
return next;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
codexLastTotalRef.current = tot;
|
|
206
|
+
setCodexStats({ ...s });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async function rescan() {
|
|
210
|
+
const active = findActiveCodexTranscript();
|
|
211
|
+
if (active && active.path !== codexPath)
|
|
162
212
|
await attach(active.path);
|
|
163
213
|
}
|
|
164
214
|
rescan();
|
|
@@ -173,9 +223,21 @@ function App() {
|
|
|
173
223
|
const allTranscripts = listTranscripts();
|
|
174
224
|
const transcripts = allTranscripts.slice(0, 5);
|
|
175
225
|
const today = countToday(allTranscripts, now);
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
226
|
+
const claudeRate = claudeSeries.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
|
|
227
|
+
const codexRate = codexSeries.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
|
|
228
|
+
const todaySessions = getTodaySessions(now, claudePath);
|
|
229
|
+
// BreakdownPanel follows the most recently active source
|
|
230
|
+
const claudeAt = claudeStats?.lastEventAt ?? 0;
|
|
231
|
+
const codexAt = codexStats?.lastEventAt ?? 0;
|
|
232
|
+
const primaryStats = codexAt > claudeAt ? codexStats : claudeStats;
|
|
233
|
+
const primarySeries = codexAt > claudeAt ? codexSeries : claudeSeries;
|
|
234
|
+
const primaryRate = codexAt > claudeAt ? codexRate : claudeRate;
|
|
235
|
+
const lastTailAt = claudeLastTailAt && codexLastTailAt
|
|
236
|
+
? Math.max(claudeLastTailAt, codexLastTailAt)
|
|
237
|
+
: claudeLastTailAt ?? codexLastTailAt;
|
|
238
|
+
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: "column", children: [claudeStats
|
|
239
|
+
? _jsx(SessionStatusBar, { stats: claudeStats, ratePerSec: claudeRate, now: now, series: claudeSeries })
|
|
240
|
+
: !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
241
|
}
|
|
180
242
|
// ── Header ───────────────────────────────────────────────────────────────────
|
|
181
243
|
function Header({ auth, sessionsToday, projectsToday, lastTailAt, startedAt, now, updateAvailable, }) {
|