tokens-metric 0.3.5 → 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 +37 -12
- package/dist/tui/index.js +95 -17
- package/package.json +1 -1
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
|
-
|
|
10
|
-
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
85
|
-
src/core/tailer.ts
|
|
86
|
-
src/core/detect.ts
|
|
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' ||
|
|
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 }),
|
|
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
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
|
162
|
-
function
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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: "
|
|
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, {
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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;
|