tokens-metric 0.2.4 → 0.3.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/dist/core/format.js +14 -0
- package/dist/core/history-store.js +102 -0
- package/dist/core/history.js +269 -0
- package/dist/tui/bars.js +16 -8
- package/dist/tui/index.js +160 -37
- package/package.json +1 -1
package/dist/core/format.js
CHANGED
|
@@ -39,6 +39,20 @@ export function estimateCostUSD(model, u) {
|
|
|
39
39
|
u.cache_creation_input_tokens * p.cacheWrite * perTok +
|
|
40
40
|
u.cache_read_input_tokens * p.cacheRead * perTok);
|
|
41
41
|
}
|
|
42
|
+
export function categoryCostUSD(model, category, tokens) {
|
|
43
|
+
const key = priceKey(model);
|
|
44
|
+
if (!key)
|
|
45
|
+
return null;
|
|
46
|
+
const p = PRICES_PER_MTOK[key];
|
|
47
|
+
const rate = category === 'input'
|
|
48
|
+
? p.in
|
|
49
|
+
: category === 'output'
|
|
50
|
+
? p.out
|
|
51
|
+
: category === 'cacheWrite'
|
|
52
|
+
? p.cacheWrite
|
|
53
|
+
: p.cacheRead;
|
|
54
|
+
return (tokens * rate) / 1_000_000;
|
|
55
|
+
}
|
|
42
56
|
export function fmtUSD(n) {
|
|
43
57
|
if (n < 0.01)
|
|
44
58
|
return `$${n.toFixed(4)}`;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { addUsage, EMPTY_USAGE } from './types.js';
|
|
5
|
+
const STORE_VERSION = 1;
|
|
6
|
+
const STORE_PATH = () => join(homedir(), '.tokens-metric', 'history.json');
|
|
7
|
+
/**
|
|
8
|
+
* Read the on-disk store. Returns an empty map if the file does not exist
|
|
9
|
+
* or is unreadable/malformed — the caller can keep working from live data.
|
|
10
|
+
*/
|
|
11
|
+
export function loadStore() {
|
|
12
|
+
const path = STORE_PATH();
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return new Map();
|
|
15
|
+
try {
|
|
16
|
+
const raw = readFileSync(path, 'utf8');
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (parsed?.version !== STORE_VERSION || !parsed.days)
|
|
19
|
+
return new Map();
|
|
20
|
+
const out = new Map();
|
|
21
|
+
for (const [date, day] of Object.entries(parsed.days)) {
|
|
22
|
+
const dayMs = parseDateKey(date);
|
|
23
|
+
if (dayMs === null)
|
|
24
|
+
continue;
|
|
25
|
+
const byModel = {};
|
|
26
|
+
for (const [model, usage] of Object.entries(day.byModel ?? {})) {
|
|
27
|
+
byModel[model] = sanitizeUsage(usage);
|
|
28
|
+
}
|
|
29
|
+
out.set(dayMs, {
|
|
30
|
+
byModel,
|
|
31
|
+
sessions: new Set(Array.isArray(day.sessions) ? day.sessions : []),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return new Map();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Persist a snapshot of per-day aggregates atomically. Failures are
|
|
42
|
+
* swallowed — losing one write is recoverable on next refresh.
|
|
43
|
+
*/
|
|
44
|
+
export function saveStore(days) {
|
|
45
|
+
const path = STORE_PATH();
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
const out = { version: STORE_VERSION, days: {} };
|
|
49
|
+
for (const [dayMs, agg] of days) {
|
|
50
|
+
out.days[formatDateKey(dayMs)] = {
|
|
51
|
+
byModel: agg.byModel,
|
|
52
|
+
sessions: Array.from(agg.sessions),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const tmp = `${path}.tmp`;
|
|
56
|
+
writeFileSync(tmp, JSON.stringify(out), 'utf8');
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// ignore — next refresh will retry
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function mergeDayInto(target, dayMs, byModel, sessions) {
|
|
64
|
+
const existing = target.get(dayMs) ?? { byModel: {}, sessions: new Set() };
|
|
65
|
+
for (const [model, usage] of Object.entries(byModel)) {
|
|
66
|
+
existing.byModel[model] = addUsage(existing.byModel[model] ?? EMPTY_USAGE(), usage);
|
|
67
|
+
}
|
|
68
|
+
for (const sid of sessions)
|
|
69
|
+
existing.sessions.add(sid);
|
|
70
|
+
target.set(dayMs, existing);
|
|
71
|
+
}
|
|
72
|
+
export function replaceDayIn(target, dayMs, byModel, sessions) {
|
|
73
|
+
const next = { byModel: {}, sessions: new Set(sessions) };
|
|
74
|
+
for (const [model, usage] of Object.entries(byModel)) {
|
|
75
|
+
next.byModel[model] = { ...usage };
|
|
76
|
+
}
|
|
77
|
+
target.set(dayMs, next);
|
|
78
|
+
}
|
|
79
|
+
function formatDateKey(ms) {
|
|
80
|
+
const d = new Date(ms);
|
|
81
|
+
const y = d.getFullYear();
|
|
82
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
83
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
84
|
+
return `${y}-${m}-${day}`;
|
|
85
|
+
}
|
|
86
|
+
function parseDateKey(s) {
|
|
87
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
88
|
+
if (!m)
|
|
89
|
+
return null;
|
|
90
|
+
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0, 0);
|
|
91
|
+
return Number.isFinite(d.getTime()) ? d.getTime() : null;
|
|
92
|
+
}
|
|
93
|
+
function sanitizeUsage(u) {
|
|
94
|
+
const x = (u ?? {});
|
|
95
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
96
|
+
return {
|
|
97
|
+
input_tokens: n(x.input_tokens),
|
|
98
|
+
output_tokens: n(x.output_tokens),
|
|
99
|
+
cache_creation_input_tokens: n(x.cache_creation_input_tokens),
|
|
100
|
+
cache_read_input_tokens: n(x.cache_read_input_tokens),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { readdirSync, statSync, createReadStream, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { claudeHome } from './detect.js';
|
|
5
|
+
import { addUsage, EMPTY_USAGE, totalTokens } from './types.js';
|
|
6
|
+
import { estimateCostUSD } from './format.js';
|
|
7
|
+
import { loadStore, replaceDayIn, saveStore, } from './history-store.js';
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
let storedDays = null;
|
|
10
|
+
export async function buildHistory(now = Date.now()) {
|
|
11
|
+
if (storedDays === null)
|
|
12
|
+
storedDays = loadStore();
|
|
13
|
+
const root = join(claudeHome(), 'projects');
|
|
14
|
+
if (!existsSync(root)) {
|
|
15
|
+
// No transcripts available; surface whatever the store has.
|
|
16
|
+
return aggregate(now);
|
|
17
|
+
}
|
|
18
|
+
const files = listAllJsonl(root);
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const cached = cache.get(f.path);
|
|
21
|
+
if (!cached || cached.mtimeMs < f.mtimeMs) {
|
|
22
|
+
const parsed = await parseFile(f.path);
|
|
23
|
+
cache.set(f.path, {
|
|
24
|
+
mtimeMs: f.mtimeMs,
|
|
25
|
+
byDay: parsed.byDay,
|
|
26
|
+
sessionId: parsed.sessionId,
|
|
27
|
+
earliestEventMs: parsed.earliestEventMs,
|
|
28
|
+
latestEventMs: parsed.latestEventMs,
|
|
29
|
+
cwd: parsed.cwd,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const known = new Set(files.map((f) => f.path));
|
|
34
|
+
for (const key of Array.from(cache.keys())) {
|
|
35
|
+
if (!known.has(key))
|
|
36
|
+
cache.delete(key);
|
|
37
|
+
}
|
|
38
|
+
return aggregate(now);
|
|
39
|
+
}
|
|
40
|
+
function aggregate(now) {
|
|
41
|
+
const startToday = startOfDay(now);
|
|
42
|
+
const start7 = startToday - 6 * 86_400_000;
|
|
43
|
+
const start30 = startToday - 29 * 86_400_000;
|
|
44
|
+
// Build the live per-day map from the in-memory cache. Each file knows its
|
|
45
|
+
// sessionId, so we attach it to every day that file contributed to.
|
|
46
|
+
const liveDays = new Map();
|
|
47
|
+
for (const [, file] of cache) {
|
|
48
|
+
for (const [dayStart, models] of file.byDay) {
|
|
49
|
+
const entry = liveDays.get(dayStart) ?? { byModel: {}, sessions: new Set() };
|
|
50
|
+
entry.sessions.add(file.sessionId);
|
|
51
|
+
for (const [model, usage] of Object.entries(models)) {
|
|
52
|
+
entry.byModel[model] = addUsage(entry.byModel[model] ?? EMPTY_USAGE(), usage);
|
|
53
|
+
}
|
|
54
|
+
liveDays.set(dayStart, entry);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Merge: start from the stored archive, then overwrite any day where live
|
|
58
|
+
// data exists (live transcripts are the authoritative source for days they
|
|
59
|
+
// cover; the store keeps days whose transcripts no longer exist on disk).
|
|
60
|
+
const merged = new Map();
|
|
61
|
+
if (storedDays) {
|
|
62
|
+
for (const [day, agg] of storedDays) {
|
|
63
|
+
merged.set(day, {
|
|
64
|
+
byModel: { ...agg.byModel },
|
|
65
|
+
sessions: new Set(agg.sessions),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [day, agg] of liveDays) {
|
|
70
|
+
replaceDayIn(merged, day, agg.byModel, agg.sessions);
|
|
71
|
+
}
|
|
72
|
+
// Persist the merged view so historical days survive transcript deletion or
|
|
73
|
+
// a future Claude Code log rotation.
|
|
74
|
+
storedDays = merged;
|
|
75
|
+
saveStore(merged);
|
|
76
|
+
// Earliest data point — prefer the stored archive's earliest day key (since
|
|
77
|
+
// events from deleted transcripts only live there now), but the live
|
|
78
|
+
// earliest event timestamp is more precise when it sits within the archive.
|
|
79
|
+
let oldest = null;
|
|
80
|
+
for (const day of merged.keys()) {
|
|
81
|
+
if (oldest === null || day < oldest)
|
|
82
|
+
oldest = day;
|
|
83
|
+
}
|
|
84
|
+
for (const [, file] of cache) {
|
|
85
|
+
const ts = file.earliestEventMs;
|
|
86
|
+
if (ts !== null && (oldest === null || ts < oldest))
|
|
87
|
+
oldest = ts;
|
|
88
|
+
}
|
|
89
|
+
const snap = {
|
|
90
|
+
today: { byModel: {}, sessions: new Set() },
|
|
91
|
+
d7: { byModel: {}, sessions: new Set() },
|
|
92
|
+
d30: { byModel: {}, sessions: new Set() },
|
|
93
|
+
scannedFiles: cache.size,
|
|
94
|
+
generatedAt: now,
|
|
95
|
+
oldestMtimeMs: oldest,
|
|
96
|
+
};
|
|
97
|
+
for (const [dayStart, agg] of merged) {
|
|
98
|
+
const buckets = [];
|
|
99
|
+
if (dayStart >= startToday)
|
|
100
|
+
buckets.push(snap.today);
|
|
101
|
+
if (dayStart >= start7)
|
|
102
|
+
buckets.push(snap.d7);
|
|
103
|
+
if (dayStart >= start30)
|
|
104
|
+
buckets.push(snap.d30);
|
|
105
|
+
if (buckets.length === 0)
|
|
106
|
+
continue;
|
|
107
|
+
for (const b of buckets) {
|
|
108
|
+
for (const sid of agg.sessions)
|
|
109
|
+
b.sessions.add(sid);
|
|
110
|
+
for (const [model, usage] of Object.entries(agg.byModel)) {
|
|
111
|
+
b.byModel[model] = addUsage(b.byModel[model] ?? EMPTY_USAGE(), usage);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return snap;
|
|
116
|
+
}
|
|
117
|
+
export function bucketTokens(b) {
|
|
118
|
+
let n = 0;
|
|
119
|
+
for (const u of Object.values(b.byModel))
|
|
120
|
+
n += totalTokens(u);
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
export function bucketCostUSD(b) {
|
|
124
|
+
let total = 0;
|
|
125
|
+
let any = false;
|
|
126
|
+
for (const [model, u] of Object.entries(b.byModel)) {
|
|
127
|
+
const c = estimateCostUSD(model, u);
|
|
128
|
+
if (c !== null) {
|
|
129
|
+
total += c;
|
|
130
|
+
any = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return any ? total : null;
|
|
134
|
+
}
|
|
135
|
+
export function bucketTopModel(b) {
|
|
136
|
+
let topModel = null;
|
|
137
|
+
let topTokens = -1;
|
|
138
|
+
for (const [model, u] of Object.entries(b.byModel)) {
|
|
139
|
+
const t = totalTokens(u);
|
|
140
|
+
if (t > topTokens) {
|
|
141
|
+
topTokens = t;
|
|
142
|
+
topModel = model;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return topModel;
|
|
146
|
+
}
|
|
147
|
+
function emptySnapshot(now, scannedFiles) {
|
|
148
|
+
return {
|
|
149
|
+
today: { byModel: {}, sessions: new Set() },
|
|
150
|
+
d7: { byModel: {}, sessions: new Set() },
|
|
151
|
+
d30: { byModel: {}, sessions: new Set() },
|
|
152
|
+
scannedFiles,
|
|
153
|
+
generatedAt: now,
|
|
154
|
+
oldestMtimeMs: null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function startOfDay(ms) {
|
|
158
|
+
const d = new Date(ms);
|
|
159
|
+
d.setHours(0, 0, 0, 0);
|
|
160
|
+
return d.getTime();
|
|
161
|
+
}
|
|
162
|
+
function listAllJsonl(root) {
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const projectDir of safeReaddir(root)) {
|
|
165
|
+
const full = join(root, projectDir);
|
|
166
|
+
let st;
|
|
167
|
+
try {
|
|
168
|
+
st = statSync(full);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!st.isDirectory())
|
|
174
|
+
continue;
|
|
175
|
+
for (const f of safeReaddir(full)) {
|
|
176
|
+
if (!f.endsWith('.jsonl'))
|
|
177
|
+
continue;
|
|
178
|
+
const p = join(full, f);
|
|
179
|
+
try {
|
|
180
|
+
const s = statSync(p);
|
|
181
|
+
out.push({ path: p, mtimeMs: s.mtimeMs });
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// ignore
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function safeReaddir(p) {
|
|
191
|
+
try {
|
|
192
|
+
return readdirSync(p);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function parseFile(path) {
|
|
199
|
+
const byDay = new Map();
|
|
200
|
+
const sessionId = (path.split('/').pop() ?? path).replace(/\.jsonl$/, '');
|
|
201
|
+
let earliestEventMs = null;
|
|
202
|
+
let latestEventMs = null;
|
|
203
|
+
let cwd;
|
|
204
|
+
const stream = createReadStream(path, { encoding: 'utf8' });
|
|
205
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
206
|
+
for await (const line of rl) {
|
|
207
|
+
if (!line.trim())
|
|
208
|
+
continue;
|
|
209
|
+
let evt;
|
|
210
|
+
try {
|
|
211
|
+
evt = JSON.parse(line);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (typeof evt?.cwd === 'string' && !cwd)
|
|
217
|
+
cwd = evt.cwd;
|
|
218
|
+
const ts = typeof evt?.timestamp === 'string' ? Date.parse(evt.timestamp) : NaN;
|
|
219
|
+
if (Number.isFinite(ts)) {
|
|
220
|
+
if (earliestEventMs === null || ts < earliestEventMs)
|
|
221
|
+
earliestEventMs = ts;
|
|
222
|
+
if (latestEventMs === null || ts > latestEventMs)
|
|
223
|
+
latestEventMs = ts;
|
|
224
|
+
}
|
|
225
|
+
const message = evt?.message;
|
|
226
|
+
const usage = message?.usage;
|
|
227
|
+
if (!usage)
|
|
228
|
+
continue;
|
|
229
|
+
if (!Number.isFinite(ts))
|
|
230
|
+
continue;
|
|
231
|
+
const day = startOfDay(ts);
|
|
232
|
+
const model = typeof message.model === 'string' ? message.model : 'unknown';
|
|
233
|
+
const u = {
|
|
234
|
+
input_tokens: numberOr0(usage.input_tokens),
|
|
235
|
+
output_tokens: numberOr0(usage.output_tokens),
|
|
236
|
+
cache_creation_input_tokens: numberOr0(usage.cache_creation_input_tokens),
|
|
237
|
+
cache_read_input_tokens: numberOr0(usage.cache_read_input_tokens),
|
|
238
|
+
};
|
|
239
|
+
const bucket = byDay.get(day) ?? {};
|
|
240
|
+
bucket[model] = addUsage(bucket[model] ?? EMPTY_USAGE(), u);
|
|
241
|
+
byDay.set(day, bucket);
|
|
242
|
+
}
|
|
243
|
+
return { byDay, sessionId, earliestEventMs, latestEventMs, cwd };
|
|
244
|
+
}
|
|
245
|
+
function numberOr0(v) {
|
|
246
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Returns all sessions (transcripts) that had activity today, sorted by
|
|
250
|
+
* start time descending (most recent first). Marks the active one.
|
|
251
|
+
*/
|
|
252
|
+
export function getTodaySessions(now, activePath) {
|
|
253
|
+
const startToday = startOfDay(now);
|
|
254
|
+
const out = [];
|
|
255
|
+
for (const [path, file] of cache) {
|
|
256
|
+
const todayUsage = file.byDay.get(startToday);
|
|
257
|
+
if (!todayUsage)
|
|
258
|
+
continue;
|
|
259
|
+
out.push({
|
|
260
|
+
sessionId: file.sessionId,
|
|
261
|
+
cwd: file.cwd,
|
|
262
|
+
startedAt: file.earliestEventMs,
|
|
263
|
+
endedAt: file.latestEventMs,
|
|
264
|
+
byModel: todayUsage,
|
|
265
|
+
isActive: path === activePath,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
|
|
269
|
+
}
|
package/dist/tui/bars.js
CHANGED
|
@@ -25,17 +25,25 @@ const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
|
25
25
|
* series max. Returns empty cells when there's no data yet.
|
|
26
26
|
*/
|
|
27
27
|
export function sparkline(values, width) {
|
|
28
|
+
return sparklineCells(values, width)
|
|
29
|
+
.map((c) => c.char)
|
|
30
|
+
.join('');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Per-cell version of `sparkline` so the caller can color each character
|
|
34
|
+
* individually based on intensity.
|
|
35
|
+
*/
|
|
36
|
+
export function sparklineCells(values, width) {
|
|
28
37
|
if (width <= 0)
|
|
29
|
-
return
|
|
38
|
+
return [];
|
|
30
39
|
const tail = values.slice(-width);
|
|
31
40
|
const padded = tail.length < width ? Array(width - tail.length).fill(0).concat(tail) : tail;
|
|
32
41
|
const max = Math.max(1, ...padded);
|
|
33
|
-
return padded
|
|
34
|
-
.map((v) => {
|
|
42
|
+
return padded.map((v) => {
|
|
35
43
|
if (v <= 0)
|
|
36
|
-
return ' ';
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
return { char: ' ', intensity: 0 };
|
|
45
|
+
const ratio = v / max;
|
|
46
|
+
const idx = Math.min(SPARK.length - 1, Math.floor(ratio * (SPARK.length - 1)));
|
|
47
|
+
return { char: SPARK[idx], intensity: ratio };
|
|
48
|
+
});
|
|
41
49
|
}
|
package/dist/tui/index.js
CHANGED
|
@@ -5,17 +5,20 @@ import { render, Box, Text, useApp, useInput } from 'ink';
|
|
|
5
5
|
import { findActiveTranscript, listTranscripts } from '../core/parser.js';
|
|
6
6
|
import { tailTranscript } from '../core/tailer.js';
|
|
7
7
|
import { detectAuth } from '../core/detect.js';
|
|
8
|
-
import { estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
|
|
8
|
+
import { categoryCostUSD, estimateCostUSD, fmtNumber, fmtUSD } from '../core/format.js';
|
|
9
|
+
import { buildHistory, bucketCostUSD, bucketTokens, bucketTopModel, getTodaySessions, } from '../core/history.js';
|
|
9
10
|
import { totalTokens } from '../core/types.js';
|
|
10
|
-
import { anonymizePath
|
|
11
|
+
import { anonymizePath } from '../core/privacy.js';
|
|
11
12
|
import { HELP_TEXT, parseArgs } from '../core/args.js';
|
|
12
|
-
import
|
|
13
|
+
import pkg from '../../package.json' assert { type: 'json' };
|
|
14
|
+
import { bar, sparklineCells } from './bars.js';
|
|
13
15
|
const OPTS = parseArgs(process.argv);
|
|
14
16
|
if (OPTS.help) {
|
|
15
17
|
process.stdout.write(HELP_TEXT);
|
|
16
18
|
process.exit(0);
|
|
17
19
|
}
|
|
18
20
|
const RESCAN_MS = 3_000;
|
|
21
|
+
const HISTORY_REFRESH_MS = 60_000;
|
|
19
22
|
const SPARK_WIDTH = 32;
|
|
20
23
|
const BAR_WIDTH = 20;
|
|
21
24
|
function App() {
|
|
@@ -23,11 +26,12 @@ function App() {
|
|
|
23
26
|
const [auth] = useState(() => detectAuth());
|
|
24
27
|
const [stats, setStats] = useState(null);
|
|
25
28
|
const [transcriptPath, setTranscriptPath] = useState(null);
|
|
26
|
-
const [rate, setRate] = useState(0);
|
|
27
29
|
const [series, setSeries] = useState(() => Array(SPARK_WIDTH).fill(0));
|
|
28
30
|
const [now, setNow] = useState(Date.now());
|
|
31
|
+
const [lastTailAt, setLastTailAt] = useState(null);
|
|
32
|
+
const [history, setHistory] = useState(null);
|
|
33
|
+
const startedAtRef = useRef(Date.now());
|
|
29
34
|
const lastTotalRef = useRef(0);
|
|
30
|
-
const lastSampleAtRef = useRef(Date.now());
|
|
31
35
|
// useInput requires raw mode (interactive TTY). Skip it when stdin is piped
|
|
32
36
|
// or otherwise non-interactive, so `node dist/tui/index.js | cat` still
|
|
33
37
|
// renders instead of crashing.
|
|
@@ -39,6 +43,27 @@ function App() {
|
|
|
39
43
|
exit();
|
|
40
44
|
});
|
|
41
45
|
}
|
|
46
|
+
// Historical aggregate over all transcripts. Refreshed on an interval —
|
|
47
|
+
// the live session is already shown in SessionPanel, so minute-grain
|
|
48
|
+
// accuracy on today's bucket is enough and keeps full parses out of the
|
|
49
|
+
// hot path.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
const refresh = () => {
|
|
53
|
+
buildHistory()
|
|
54
|
+
.then((h) => {
|
|
55
|
+
if (!cancelled)
|
|
56
|
+
setHistory(h);
|
|
57
|
+
})
|
|
58
|
+
.catch(() => undefined);
|
|
59
|
+
};
|
|
60
|
+
refresh();
|
|
61
|
+
const t = setInterval(refresh, HISTORY_REFRESH_MS);
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
clearInterval(t);
|
|
65
|
+
};
|
|
66
|
+
}, []);
|
|
42
67
|
// Wall-clock tick + sparkline slot rotation (1 Hz).
|
|
43
68
|
useEffect(() => {
|
|
44
69
|
const t = setInterval(() => {
|
|
@@ -62,14 +87,12 @@ function App() {
|
|
|
62
87
|
// before returning, but its first notify() fires before any listener
|
|
63
88
|
// is attached, so we'd otherwise wait for the next appended line.
|
|
64
89
|
lastTotalRef.current = totalTokens(handle.stats.totals);
|
|
65
|
-
|
|
90
|
+
setLastTailAt(Date.now());
|
|
66
91
|
setStats({ ...handle.stats });
|
|
67
92
|
handle.onUpdate((s) => {
|
|
93
|
+
setLastTailAt(Date.now());
|
|
68
94
|
const tot = totalTokens(s.totals);
|
|
69
95
|
const delta = Math.max(0, tot - lastTotalRef.current);
|
|
70
|
-
const dt = (Date.now() - lastSampleAtRef.current) / 60_000;
|
|
71
|
-
if (dt > 0 && delta > 0)
|
|
72
|
-
setRate(delta / dt);
|
|
73
96
|
if (delta > 0) {
|
|
74
97
|
setSeries((arr) => {
|
|
75
98
|
const next = arr.slice();
|
|
@@ -78,7 +101,6 @@ function App() {
|
|
|
78
101
|
});
|
|
79
102
|
}
|
|
80
103
|
lastTotalRef.current = tot;
|
|
81
|
-
lastSampleAtRef.current = Date.now();
|
|
82
104
|
setStats({ ...s });
|
|
83
105
|
});
|
|
84
106
|
}
|
|
@@ -96,61 +118,162 @@ function App() {
|
|
|
96
118
|
};
|
|
97
119
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
120
|
}, []);
|
|
99
|
-
const
|
|
100
|
-
|
|
121
|
+
const allTranscripts = listTranscripts();
|
|
122
|
+
const transcripts = allTranscripts.slice(0, 5);
|
|
123
|
+
const today = countToday(allTranscripts, now);
|
|
124
|
+
const ratePerSec = series.reduce((a, b) => a + b, 0) / SPARK_WIDTH;
|
|
125
|
+
const todaySessions = getTodaySessions(now, transcriptPath);
|
|
126
|
+
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 }), _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"] }) })] }));
|
|
101
127
|
}
|
|
102
128
|
// ── Header ───────────────────────────────────────────────────────────────────
|
|
103
|
-
function Header({ auth }) {
|
|
129
|
+
function Header({ auth, sessionsToday, projectsToday, lastTailAt, startedAt, now, }) {
|
|
104
130
|
const ok = auth.installed && auth.loggedIn;
|
|
105
131
|
const dot = ok ? 'green' : auth.installed ? 'yellow' : 'red';
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
default:
|
|
122
|
-
return { label: ' SUBSCRIPTION ', style: { color: 'cyan', bold: true } };
|
|
132
|
+
const tailLabel = lastTailAt ? `updated ${timeAgo(now - lastTailAt)} ago` : 'waiting…';
|
|
133
|
+
const tailColor = !lastTailAt ? 'gray' : now - lastTailAt < 10_000 ? 'green' : 'yellow';
|
|
134
|
+
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) })] })] }));
|
|
135
|
+
}
|
|
136
|
+
function countToday(transcripts, now) {
|
|
137
|
+
const startOfDay = new Date(now);
|
|
138
|
+
startOfDay.setHours(0, 0, 0, 0);
|
|
139
|
+
const cutoff = startOfDay.getTime();
|
|
140
|
+
const projects = new Set();
|
|
141
|
+
let sessions = 0;
|
|
142
|
+
for (const t of transcripts) {
|
|
143
|
+
if (t.mtimeMs < cutoff)
|
|
144
|
+
continue;
|
|
145
|
+
sessions++;
|
|
146
|
+
projects.add(t.cwd);
|
|
123
147
|
}
|
|
148
|
+
return { sessions, projects: projects.size };
|
|
124
149
|
}
|
|
125
150
|
// ── Session panel ────────────────────────────────────────────────────────────
|
|
126
|
-
function SessionPanel({ stats,
|
|
127
|
-
return (_jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", flexGrow: 1, minWidth: 42, children: [
|
|
151
|
+
function SessionPanel({ stats, ratePerSec, now, series, }) {
|
|
152
|
+
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" })] }), (() => {
|
|
128
153
|
const cost = estimateCostUSD(stats.lastModel ?? '', stats.totals);
|
|
129
154
|
return cost !== null ? (_jsxs(Text, { dimColor: true, children: ["~", fmtUSD(cost), " API-equivalent"] })) : null;
|
|
130
|
-
})(), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "~ " }), _jsx(Text, { color: "yellow", children: fmtNumber(Math.round(
|
|
155
|
+
})(), 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"] }) }))] }))] }));
|
|
131
156
|
}
|
|
132
157
|
// ── Breakdown panel ──────────────────────────────────────────────────────────
|
|
133
158
|
function BreakdownPanel({ stats }) {
|
|
134
159
|
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." })) : ((() => {
|
|
135
160
|
const u = stats.totals;
|
|
136
161
|
const max = Math.max(1, u.input_tokens, u.output_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens);
|
|
137
|
-
|
|
162
|
+
const total = totalTokens(u);
|
|
163
|
+
const model = stats.lastModel ?? '';
|
|
164
|
+
const cacheDenom = u.input_tokens + u.cache_read_input_tokens;
|
|
165
|
+
const hitRatio = cacheDenom > 0 ? u.cache_read_input_tokens / cacheDenom : null;
|
|
166
|
+
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]) => {
|
|
167
|
+
const c = estimateCostUSD(m, mu);
|
|
168
|
+
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));
|
|
169
|
+
})] }))] }));
|
|
138
170
|
})())] }));
|
|
139
171
|
}
|
|
140
|
-
function BarRow({ label, value, max, color, }) {
|
|
141
|
-
|
|
172
|
+
function BarRow({ label, value, max, total, color, cost, }) {
|
|
173
|
+
const pct = total > 0 ? (value / total) * 100 : 0;
|
|
174
|
+
return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: label }), _jsx(Text, { color: color, children: bar(value / max, BAR_WIDTH) }), _jsxs(Text, { children: [" ", fmtNumber(value).padStart(7, ' ')] }), _jsx(Text, { dimColor: true, children: ` ${pct.toFixed(1).padStart(5, ' ')}%` }), cost !== null && _jsx(Text, { dimColor: true, children: ` ~${fmtUSD(cost)}` })] }));
|
|
175
|
+
}
|
|
176
|
+
// ── History panel ────────────────────────────────────────────────────────────
|
|
177
|
+
function HistoryPanel({ history }) {
|
|
178
|
+
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 &&
|
|
179
|
+
` · data since ${fmtDate(history.oldestMtimeMs)} (${daysAgo(history.oldestMtimeMs, history.generatedAt)} days)`] }) })] }))] }));
|
|
180
|
+
}
|
|
181
|
+
function fmtDate(ms) {
|
|
182
|
+
const d = new Date(ms);
|
|
183
|
+
const y = d.getFullYear();
|
|
184
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
185
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
186
|
+
return `${y}-${m}-${day}`;
|
|
187
|
+
}
|
|
188
|
+
function daysAgo(from, to) {
|
|
189
|
+
return Math.max(0, Math.floor((to - from) / 86_400_000));
|
|
190
|
+
}
|
|
191
|
+
function HistoryRow({ label, today, d7, d30, dim, }) {
|
|
192
|
+
const col = (s) => s.padEnd(14, ' ');
|
|
193
|
+
return (_jsxs(Text, { dimColor: dim, children: [_jsx(Text, { bold: true, children: label.padEnd(10, ' ') }), _jsx(Text, { children: col(today) }), _jsx(Text, { children: col(d7) }), _jsx(Text, { children: col(d30) })] }));
|
|
194
|
+
}
|
|
195
|
+
function fmtCost(c) {
|
|
196
|
+
return c === null ? '—' : fmtUSD(c);
|
|
197
|
+
}
|
|
198
|
+
function fmtTopModel(b) {
|
|
199
|
+
const m = bucketTopModel(b);
|
|
200
|
+
return m ? shortModel(m) : '—';
|
|
201
|
+
}
|
|
202
|
+
// ── Today's sessions panel ───────────────────────────────────────────────────
|
|
203
|
+
function TodaySessionsPanel({ sessions, now }) {
|
|
204
|
+
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) => {
|
|
205
|
+
const tokens = Object.values(s.byModel).reduce((acc, u) => acc + totalTokens(u), 0);
|
|
206
|
+
const cost = Object.entries(s.byModel).reduce((acc, [m, u]) => {
|
|
207
|
+
const c = estimateCostUSD(m, u);
|
|
208
|
+
if (c === null)
|
|
209
|
+
return acc;
|
|
210
|
+
return (acc ?? 0) + c;
|
|
211
|
+
}, null);
|
|
212
|
+
const topModel = Object.entries(s.byModel).reduce((best, [m, u]) => best === null || totalTokens(u) > totalTokens(s.byModel[best] ?? {})
|
|
213
|
+
? m
|
|
214
|
+
: best, null);
|
|
215
|
+
const duration = s.startedAt !== null && s.endedAt !== null
|
|
216
|
+
? fmtDuration(s.endedAt - s.startedAt)
|
|
217
|
+
: null;
|
|
218
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: s.isActive ? 'green' : 'gray', children: s.isActive ? '▶ ' : ' ' }), _jsx(Text, { bold: s.isActive, children: s.startedAt ? fmtTime(s.startedAt) : '??:??' }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { wrap: "truncate-middle", dimColor: !s.isActive, children: OPTS.reveal ? (s.cwd ?? '—') : displayCwd(s.cwd) }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: "cyan", children: topModel ? shortModel(topModel) : '—' }), _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { children: fmtNumber(tokens) }), cost !== null && _jsx(Text, { dimColor: true, children: ` ~${fmtUSD(cost)}` }), duration && _jsx(Text, { dimColor: true, children: ` ${duration}` }), s.isActive && _jsx(Text, { color: "green", children: " active" })] }, s.sessionId));
|
|
219
|
+
})] }));
|
|
220
|
+
}
|
|
221
|
+
function fmtDuration(ms) {
|
|
222
|
+
const totalSec = Math.floor(ms / 1000);
|
|
223
|
+
const h = Math.floor(totalSec / 3600);
|
|
224
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
225
|
+
if (h > 0)
|
|
226
|
+
return `${h}h ${m}m`;
|
|
227
|
+
if (m > 0)
|
|
228
|
+
return `${m}m`;
|
|
229
|
+
return `${totalSec}s`;
|
|
142
230
|
}
|
|
143
231
|
// ── Transcripts panel ────────────────────────────────────────────────────────
|
|
144
232
|
function TranscriptsPanel({ transcripts, activePath, now, }) {
|
|
145
233
|
return (_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width: "100%", children: [_jsx(Text, { bold: true, children: "Recent transcripts" }), transcripts.length === 0 ? (_jsx(Text, { dimColor: true, children: "None found under ~/.claude/projects" })) : (transcripts.map((t) => {
|
|
146
234
|
const isActive = t.path === activePath;
|
|
147
|
-
return (_jsxs(Text, { children: [_jsx(Text, { color: isActive ? 'green' : 'gray', children: isActive ? '▶ ' : ' ' }), _jsx(Text, { dimColor: !isActive, wrap: "truncate-middle", children: OPTS.reveal ? t.cwd :
|
|
235
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: isActive ? 'green' : 'gray', children: isActive ? '▶ ' : ' ' }), _jsx(Text, { dimColor: !isActive, wrap: "truncate-middle", children: OPTS.reveal ? t.cwd : displayCwd(t.cwd) }), _jsx(Text, { dimColor: true, children: ` ${timeAgo(now - t.mtimeMs)}` })] }, t.path));
|
|
148
236
|
}))] }));
|
|
149
237
|
}
|
|
150
238
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
151
239
|
function KV({ k, v }) {
|
|
152
240
|
return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: k }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), v] }));
|
|
153
241
|
}
|
|
242
|
+
function fmtTime(ms) {
|
|
243
|
+
const d = new Date(ms);
|
|
244
|
+
const h = String(d.getHours()).padStart(2, '0');
|
|
245
|
+
const m = String(d.getMinutes()).padStart(2, '0');
|
|
246
|
+
return `${h}:${m}`;
|
|
247
|
+
}
|
|
248
|
+
function plural(n, one, many) {
|
|
249
|
+
return n === 1 ? one : many;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Like anonymizePath, but distinguishes the bare home directory from "no
|
|
253
|
+
* path" — a transcript whose cwd is $HOME would otherwise show as a lonely
|
|
254
|
+
* `~`, which looks like a bug.
|
|
255
|
+
*/
|
|
256
|
+
function displayCwd(cwd) {
|
|
257
|
+
const out = anonymizePath(cwd);
|
|
258
|
+
if (out === '~')
|
|
259
|
+
return '~ (home)';
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
function intensityColor(ratio) {
|
|
263
|
+
if (ratio <= 0)
|
|
264
|
+
return 'gray';
|
|
265
|
+
if (ratio < 0.34)
|
|
266
|
+
return 'green';
|
|
267
|
+
if (ratio < 0.67)
|
|
268
|
+
return 'yellow';
|
|
269
|
+
return 'red';
|
|
270
|
+
}
|
|
271
|
+
function axisLabel(width) {
|
|
272
|
+
const left = `-${width}s`;
|
|
273
|
+
const right = 'now';
|
|
274
|
+
const gap = Math.max(1, width - left.length - right.length);
|
|
275
|
+
return `${left}${' '.repeat(gap)}${right}`;
|
|
276
|
+
}
|
|
154
277
|
function shortModel(m) {
|
|
155
278
|
return m.replace(/^claude-/, '').replace(/-20\d{6}/, '').replace(/-\d{8}$/, '');
|
|
156
279
|
}
|