clauddy 1.12.0 → 1.13.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 +16 -5
- package/package.json +4 -1
- package/renderer/burn.js +98 -0
- package/renderer/index.html +19 -4
- package/renderer/pet.js +116 -80
- package/renderer/style.css +47 -5
- package/usage.js +105 -1
package/README.md
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
A cute pixel-art desktop pet for macOS that tracks your Claude Code usage — mirroring the official **Settings → Usage** panel (current session + weekly limits, in tokens & %), with animations.
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
|
|
7
|
+
https://github.com/user-attachments/assets/76d87b6f-2876-4000-b6db-13ba2207ae31
|
|
8
|
+
|
|
9
|
+
<em>A little terracotta creature that lives in the corner of your screen, eats your tokens, and naps when you're idle.</em>
|
|
8
10
|
</p>
|
|
9
11
|
|
|
10
12
|
## What it shows
|
|
@@ -13,9 +15,10 @@ A cute pixel-art desktop pet for macOS that tracks your Claude Code usage — mi
|
|
|
13
15
|
- **Weekly · all models** — real % used + tokens over the last 7 days
|
|
14
16
|
- **Status line** under the pet: `● working · 1.6M tok/min` (or today's tokens when idle)
|
|
15
17
|
- **By model · 7 days** — Opus / Sonnet / Haiku / Fable, in tokens
|
|
18
|
+
- **By project · 7 days** — which repo actually ate the week, ranked, with the tail folded into `other`
|
|
16
19
|
- **30-day map** — colored squares by daily tokens (green = light → red = heavy), with the monthly total
|
|
17
20
|
|
|
18
|
-
The **percentages are real**, pulled from your account (you log in once — see below). The token counts, by-model
|
|
21
|
+
The **percentages are real**, pulled from your account (you log in once — see below). The token counts, the by-model and by-project breakdowns, activity status, and 30-day map come from your local logs (`~/.claude/projects/**/*.jsonl`). Everything is token-based — no dollars.
|
|
19
22
|
|
|
20
23
|
## Account & live usage
|
|
21
24
|
|
|
@@ -132,7 +135,7 @@ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node
|
|
|
132
135
|
bunx clauddy # or: npx clauddy
|
|
133
136
|
```
|
|
134
137
|
|
|
135
|
-
Prefer a standalone app? Grab the **AppImage** or **tar.gz** (`Clauddy-<version>-linux-
|
|
138
|
+
Prefer a standalone app? Grab the **AppImage** or **tar.gz** (`Clauddy-<version>-linux-x86_64.AppImage` / `Clauddy-<version>-linux-x64.tar.gz`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), then:
|
|
136
139
|
|
|
137
140
|
```bash
|
|
138
141
|
chmod +x Clauddy-*.AppImage
|
|
@@ -202,7 +205,7 @@ live. (Installed globally? Drop the `bunx`: `clauddy poke`. Working on the repo?
|
|
|
202
205
|
## How it works
|
|
203
206
|
|
|
204
207
|
- **`main.js`** — Electron main process: frameless, transparent, always-on-top window; polls usage; fires macOS notifications; watches `config.json` and `debug.json`.
|
|
205
|
-
- **`usage.js`** — reads `~/.claude/projects/**/*.jsonl`, sums tokens per model/day, detects the rolling 5-hour session window, the working/sleeping status, and which activity (reading/editing/running/…) Claude is on from its latest tool use.
|
|
208
|
+
- **`usage.js`** — reads `~/.claude/projects/**/*.jsonl`, sums tokens per model/project/day, detects the rolling 5-hour session window, the working/sleeping status, and which activity (reading/editing/running/…) Claude is on from its latest tool use.
|
|
206
209
|
- **`auth.js`** — OAuth login (PKCE, same public client as Claude Code) that fetches the authoritative usage %. Token stored locally, never committed.
|
|
207
210
|
- **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles.
|
|
208
211
|
- **`make-icon.js`** — generates the app icon from the pixel sprite (`build/icon.icns`).
|
|
@@ -217,9 +220,17 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
|
|
|
217
220
|
|
|
218
221
|
Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints.
|
|
219
222
|
|
|
223
|
+
## Contributing
|
|
224
|
+
|
|
225
|
+
Bug reports and ideas are welcome — see **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
|
226
|
+
for setup and the few gotchas worth knowing before a first PR.
|
|
227
|
+
|
|
220
228
|
## Dev tooling
|
|
221
229
|
|
|
222
230
|
- **Bun** for install/scripts, **Node 24** pinned in `.nvmrc`
|
|
231
|
+
- **Tests**: `bun run test` (never bare `bun test` — the groups under `test/`
|
|
232
|
+
must each run in their own process). `bun run test:coverage` enforces the
|
|
233
|
+
floor; every PR runs both.
|
|
223
234
|
- **Biome** for format + lint (`bun run check`); a versioned **pre-commit hook** (`.githooks/pre-commit`) auto-formats staged files and blocks on errors. It's wired up automatically on `bun install` (via the `prepare` script).
|
|
224
235
|
|
|
225
236
|
### Releasing
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauddy",
|
|
3
3
|
"desktopName": "clauddy.desktop",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.13.0",
|
|
5
5
|
"description": "A cute desktop pet that tracks your Claude Code usage",
|
|
6
6
|
"main": "main.js",
|
|
7
7
|
"bin": {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"dist:linux": "node scripts/build-app.js --linux --publish never",
|
|
25
25
|
"icon": "bash build-icon.sh",
|
|
26
26
|
"gifs": "electron tools/capture/capture.js",
|
|
27
|
+
"test": "bun test test/unit/ && bun test test/main/ && bun test test/dom/",
|
|
28
|
+
"test:coverage": "node scripts/check-coverage.js",
|
|
27
29
|
"format": "biome format --write .",
|
|
28
30
|
"lint": "biome lint .",
|
|
29
31
|
"check": "biome check --write .",
|
|
@@ -104,6 +106,7 @@
|
|
|
104
106
|
},
|
|
105
107
|
"devDependencies": {
|
|
106
108
|
"@biomejs/biome": "^2.5.0",
|
|
109
|
+
"@happy-dom/global-registrator": "^20.11.2",
|
|
107
110
|
"electron-builder": "^26.15.3",
|
|
108
111
|
"semantic-release": "^25.0.5"
|
|
109
112
|
}
|
package/renderer/burn.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ---- burn-rate projection ----
|
|
2
|
+
// The panel knows where you are (82%) and when the window resets (1h 12m); it
|
|
3
|
+
// can also say where you're headed — fit a slope through recent usage and
|
|
4
|
+
// project the crossing of 100%. If the reset lands first there's nothing to
|
|
5
|
+
// worry about, which is worth saying out loud rather than leaving blank.
|
|
6
|
+
//
|
|
7
|
+
// The slope is fitted over session *tokens*, not the account %. The % is the
|
|
8
|
+
// number we ultimately care about, but it arrives as a whole number every ~5
|
|
9
|
+
// min: over a short window the whole signal is a single 16 → 17 step, which
|
|
10
|
+
// makes the fitted pace wrong by multiples. Local-log tokens step too — one
|
|
11
|
+
// jump per assistant turn, with plateaus in between — but in increments some
|
|
12
|
+
// 10-20x finer, so they carry a far steadier slope. The account % still anchors
|
|
13
|
+
// it: pct/tokens converts tokens/ms into %/ms and re-calibrates on every poll,
|
|
14
|
+
// so the projection stays tied to the real number.
|
|
15
|
+
//
|
|
16
|
+
// Loaded both as a plain <script> by the renderer (exposing `Burn`) and via
|
|
17
|
+
// require() by the tests — hence the dual export at the bottom.
|
|
18
|
+
;(() => {
|
|
19
|
+
const WINDOW_MS = 45 * 60 * 1000 // only fit recent samples — pace changes
|
|
20
|
+
const MIN_SAMPLES = 4
|
|
21
|
+
const MIN_SPAN_MS = 5 * 60 * 1000 // shorter than a % fit affords, and steadier
|
|
22
|
+
const SAMPLE_EVERY_MS = 30 * 1000 // usage polls every few seconds; thin it out
|
|
23
|
+
|
|
24
|
+
// `now` is injectable so tests can drive the trail without sleeping
|
|
25
|
+
function createBurnTracker() {
|
|
26
|
+
let trail = []
|
|
27
|
+
|
|
28
|
+
function note(tokens, active, now = Date.now()) {
|
|
29
|
+
if (!active || !(tokens > 0)) {
|
|
30
|
+
trail = [] // no session, no trend
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
// session tokens only climb within a window — a drop means it rolled
|
|
34
|
+
// over, and the fresh sample starts the new trail rather than being
|
|
35
|
+
// throttled away
|
|
36
|
+
let last = trail[trail.length - 1]
|
|
37
|
+
if (last && tokens < last.tokens) {
|
|
38
|
+
trail = []
|
|
39
|
+
last = undefined
|
|
40
|
+
}
|
|
41
|
+
if (last && now - last.t < SAMPLE_EVERY_MS) return
|
|
42
|
+
trail.push({ t: now, tokens })
|
|
43
|
+
trail = trail.filter((s) => s.t >= now - WINDOW_MS)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// least-squares slope in tokens per ms, or null when there isn't enough to say
|
|
47
|
+
function slope() {
|
|
48
|
+
if (trail.length < MIN_SAMPLES) return null
|
|
49
|
+
const span = trail[trail.length - 1].t - trail[0].t
|
|
50
|
+
if (span < MIN_SPAN_MS) return null
|
|
51
|
+
const n = trail.length
|
|
52
|
+
const t0 = trail[0].t
|
|
53
|
+
let sx = 0
|
|
54
|
+
let sy = 0
|
|
55
|
+
let sxy = 0
|
|
56
|
+
let sxx = 0
|
|
57
|
+
for (const s of trail) {
|
|
58
|
+
const x = s.t - t0
|
|
59
|
+
sx += x
|
|
60
|
+
sy += s.tokens
|
|
61
|
+
sxy += x * s.tokens
|
|
62
|
+
sxx += x * x
|
|
63
|
+
}
|
|
64
|
+
const denom = n * sxx - sx * sx
|
|
65
|
+
if (denom === 0) return null
|
|
66
|
+
return (n * sxy - sx * sy) / denom
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// → { kind: 'eta', ms } when you'd run out first, { kind: 'safe' } when the
|
|
70
|
+
// reset beats you there, or null when we can't tell yet.
|
|
71
|
+
function project(pct, resetMs, tokens) {
|
|
72
|
+
if (!(pct > 0) || !(tokens > 0)) return null // no anchor to convert tokens → %
|
|
73
|
+
const tokPerMs = slope()
|
|
74
|
+
if (tokPerMs == null || tokPerMs <= 0) return null // idle or flat
|
|
75
|
+
const pctPerMs = tokPerMs * (pct / tokens)
|
|
76
|
+
const eta = (100 - pct) / pctPerMs
|
|
77
|
+
if (!Number.isFinite(eta) || eta <= 0) return null
|
|
78
|
+
if (resetMs != null && eta >= resetMs) return { kind: 'safe' }
|
|
79
|
+
return { kind: 'eta', ms: eta }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
note,
|
|
84
|
+
slope,
|
|
85
|
+
project,
|
|
86
|
+
reset: () => {
|
|
87
|
+
trail = []
|
|
88
|
+
},
|
|
89
|
+
get trail() {
|
|
90
|
+
return trail
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const api = { createBurnTracker, WINDOW_MS, MIN_SAMPLES, MIN_SPAN_MS, SAMPLE_EVERY_MS }
|
|
96
|
+
if (typeof module === 'object' && module.exports) module.exports = api
|
|
97
|
+
else globalThis.Burn = api
|
|
98
|
+
})()
|
package/renderer/index.html
CHANGED
|
@@ -268,9 +268,23 @@
|
|
|
268
268
|
<div class="divider"></div>
|
|
269
269
|
|
|
270
270
|
<!-- by model (7 days) -->
|
|
271
|
-
<div id="bymodel">
|
|
272
|
-
<
|
|
273
|
-
|
|
271
|
+
<div id="bymodel" class="sec">
|
|
272
|
+
<button class="sec-head" data-sec="bymodel" aria-expanded="true">
|
|
273
|
+
<span class="sec-name">by model · 7 days</span>
|
|
274
|
+
<svg class="sec-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10l5 5 5-5" /></svg>
|
|
275
|
+
</button>
|
|
276
|
+
<div id="bymodel-list" class="sec-body"></div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div class="divider"></div>
|
|
280
|
+
|
|
281
|
+
<!-- by project (7 days) -->
|
|
282
|
+
<div id="byproject" class="sec">
|
|
283
|
+
<button class="sec-head" data-sec="byproject" aria-expanded="true">
|
|
284
|
+
<span class="sec-name">by project · 7 days</span>
|
|
285
|
+
<svg class="sec-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10l5 5 5-5" /></svg>
|
|
286
|
+
</button>
|
|
287
|
+
<div id="byproject-list" class="sec-body"></div>
|
|
274
288
|
</div>
|
|
275
289
|
|
|
276
290
|
<div class="divider"></div>
|
|
@@ -374,6 +388,7 @@
|
|
|
374
388
|
</div>
|
|
375
389
|
</div>
|
|
376
390
|
</div>
|
|
377
|
-
<script src="
|
|
391
|
+
<script src="burn.js"></script>
|
|
392
|
+
<script src="pet.js"></script>
|
|
378
393
|
</body>
|
|
379
394
|
</html>
|
package/renderer/pet.js
CHANGED
|
@@ -141,6 +141,14 @@ const SPRITE = [
|
|
|
141
141
|
})()
|
|
142
142
|
|
|
143
143
|
// helpers
|
|
144
|
+
// labels come from log fields and directory names — neither is ours to trust
|
|
145
|
+
function esc(s) {
|
|
146
|
+
return String(s).replace(
|
|
147
|
+
/[&<>"']/g,
|
|
148
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
144
152
|
function fmtTokens(t) {
|
|
145
153
|
t = t || 0
|
|
146
154
|
if (t >= 1e9) return `${(t / 1e9).toFixed(2)}B`
|
|
@@ -154,79 +162,8 @@ function fmtReset(ms) {
|
|
|
154
162
|
const m = Math.floor((ms % 3600000) / 60000)
|
|
155
163
|
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
156
164
|
}
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
// can also say where you're headed — fit a slope through recent usage and
|
|
160
|
-
// project the crossing of 100%. If the reset lands first there's nothing to
|
|
161
|
-
// worry about, which is worth saying out loud rather than leaving blank.
|
|
162
|
-
//
|
|
163
|
-
// The slope is fitted over session *tokens*, not the account %. The % is the
|
|
164
|
-
// number we ultimately care about, but it arrives as a whole number every ~5
|
|
165
|
-
// min: over a short window the whole signal is a single 16 → 17 step, which
|
|
166
|
-
// makes the fitted pace wrong by multiples. Local-log tokens step too — one
|
|
167
|
-
// jump per assistant turn, with plateaus in between — but in increments some
|
|
168
|
-
// 10-20x finer, so they carry a far steadier slope. The
|
|
169
|
-
// account % still anchors it: pct/tokens converts tokens/ms into %/ms and
|
|
170
|
-
// re-calibrates on every poll, so the projection stays tied to the real number.
|
|
171
|
-
const PROJ_WINDOW_MS = 45 * 60 * 1000 // only fit recent samples — pace changes
|
|
172
|
-
const PROJ_MIN_SAMPLES = 4
|
|
173
|
-
const PROJ_MIN_SPAN_MS = 5 * 60 * 1000 // shorter than a % fit affords, and steadier
|
|
174
|
-
const PROJ_SAMPLE_EVERY_MS = 30 * 1000 // usage polls every few seconds; thin it out
|
|
175
|
-
let tokTrail = []
|
|
176
|
-
|
|
177
|
-
function noteTokens(tokens, active) {
|
|
178
|
-
if (!active || !(tokens > 0)) {
|
|
179
|
-
tokTrail = [] // no session, no trend
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
// session tokens only climb within a window — a drop means it rolled over,
|
|
183
|
-
// and the fresh sample starts the new trail rather than being throttled away
|
|
184
|
-
let last = tokTrail[tokTrail.length - 1]
|
|
185
|
-
if (last && tokens < last.tokens) {
|
|
186
|
-
tokTrail = []
|
|
187
|
-
last = undefined
|
|
188
|
-
}
|
|
189
|
-
const now = Date.now()
|
|
190
|
-
if (last && now - last.t < PROJ_SAMPLE_EVERY_MS) return
|
|
191
|
-
tokTrail.push({ t: now, tokens })
|
|
192
|
-
tokTrail = tokTrail.filter((s) => s.t >= now - PROJ_WINDOW_MS)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// least-squares slope in tokens per ms, or null when there isn't enough to say
|
|
196
|
-
function burnSlope() {
|
|
197
|
-
if (tokTrail.length < PROJ_MIN_SAMPLES) return null
|
|
198
|
-
const span = tokTrail[tokTrail.length - 1].t - tokTrail[0].t
|
|
199
|
-
if (span < PROJ_MIN_SPAN_MS) return null
|
|
200
|
-
const n = tokTrail.length
|
|
201
|
-
const t0 = tokTrail[0].t
|
|
202
|
-
let sx = 0
|
|
203
|
-
let sy = 0
|
|
204
|
-
let sxy = 0
|
|
205
|
-
let sxx = 0
|
|
206
|
-
for (const s of tokTrail) {
|
|
207
|
-
const x = s.t - t0
|
|
208
|
-
sx += x
|
|
209
|
-
sy += s.tokens
|
|
210
|
-
sxy += x * s.tokens
|
|
211
|
-
sxx += x * x
|
|
212
|
-
}
|
|
213
|
-
const denom = n * sxx - sx * sx
|
|
214
|
-
if (denom === 0) return null
|
|
215
|
-
return (n * sxy - sx * sy) / denom
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// → { kind: 'eta', ms } when you'd run out first, { kind: 'safe' } when the
|
|
219
|
-
// reset beats you there, or null when we can't tell yet.
|
|
220
|
-
function project(pct, resetMs, tokens) {
|
|
221
|
-
if (!(pct > 0) || !(tokens > 0)) return null // no anchor to convert tokens → %
|
|
222
|
-
const tokPerMs = burnSlope()
|
|
223
|
-
if (tokPerMs == null || tokPerMs <= 0) return null // idle or flat
|
|
224
|
-
const pctPerMs = tokPerMs * (pct / tokens)
|
|
225
|
-
const eta = (100 - pct) / pctPerMs
|
|
226
|
-
if (!Number.isFinite(eta) || eta <= 0) return null
|
|
227
|
-
if (resetMs != null && eta >= resetMs) return { kind: 'safe' }
|
|
228
|
-
return { kind: 'eta', ms: eta }
|
|
229
|
-
}
|
|
165
|
+
// burn-rate projection lives in burn.js (shared with the tests)
|
|
166
|
+
const burn = Burn.createBurnTracker()
|
|
230
167
|
|
|
231
168
|
function setState(name) {
|
|
232
169
|
const b = document.body
|
|
@@ -419,24 +356,60 @@ function renderHeat(days) {
|
|
|
419
356
|
})
|
|
420
357
|
}
|
|
421
358
|
|
|
422
|
-
// by model
|
|
423
|
-
|
|
424
|
-
|
|
359
|
+
// ranked bar list — shared by the model and project panels
|
|
360
|
+
//
|
|
361
|
+
// The name column is one width for the whole list, never per row: the bars are
|
|
362
|
+
// only comparable if every track starts and ends at the same x. So it is sized
|
|
363
|
+
// to the widest label actually present, clamped so a long path cannot squeeze
|
|
364
|
+
// the bars into stubs, and anything past the clamp is clipped with an ellipsis.
|
|
365
|
+
const NAME_MIN = 64
|
|
366
|
+
const BAR_MIN = 96 // room left for the track and the token count
|
|
367
|
+
|
|
368
|
+
function renderBars(boxId, list, limit) {
|
|
369
|
+
const box = el(boxId)
|
|
425
370
|
box.innerHTML = ''
|
|
426
|
-
const top = list.slice(0,
|
|
371
|
+
const top = list.slice(0, limit)
|
|
427
372
|
const max = Math.max(1, ...top.map((m) => m.tokens))
|
|
428
373
|
for (const m of top) {
|
|
429
374
|
const row = document.createElement('div')
|
|
430
375
|
row.className = 'mrow'
|
|
431
376
|
row.innerHTML =
|
|
432
|
-
`<span class="mname">${m.label}</span>` +
|
|
377
|
+
`<span class="mname">${esc(m.label)}</span>` +
|
|
433
378
|
`<span class="mbar"><i style="width:${(m.tokens / max) * 100}%"></i></span>` +
|
|
434
379
|
`<span class="mval">${fmtTokens(m.tokens)}</span>`
|
|
380
|
+
// the column can clip, so keep the full label reachable
|
|
381
|
+
row.firstChild.title = m.label
|
|
435
382
|
box.appendChild(row)
|
|
436
383
|
}
|
|
437
384
|
if (!top.length) {
|
|
438
385
|
box.innerHTML = '<div class="mrow" style="opacity:.5">no activity</div>'
|
|
386
|
+
return
|
|
439
387
|
}
|
|
388
|
+
fitNames(box)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// measure the labels unconstrained, then lock the column to the widest one
|
|
392
|
+
function fitNames(box) {
|
|
393
|
+
const names = [...box.querySelectorAll('.mname')]
|
|
394
|
+
// a collapsed section measures zero — it re-fits when it opens
|
|
395
|
+
if (!names.length || !box.getBoundingClientRect().width) return
|
|
396
|
+
box.style.setProperty('--name-w', 'auto')
|
|
397
|
+
let widest = 0
|
|
398
|
+
for (const n of names) widest = Math.max(widest, n.getBoundingClientRect().width)
|
|
399
|
+
const room = box.getBoundingClientRect().width - BAR_MIN
|
|
400
|
+
const w = Math.max(NAME_MIN, Math.min(Math.ceil(widest) + 1, room))
|
|
401
|
+
box.style.setProperty('--name-w', `${w}px`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// by model (7 days)
|
|
405
|
+
function renderModels(list) {
|
|
406
|
+
renderBars('bymodel-list', list, 4)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// by project (7 days) — usage.js already folds everything past the top few
|
|
410
|
+
// into a single `other` row, so whatever arrives here is meant to be drawn
|
|
411
|
+
function renderProjects(list) {
|
|
412
|
+
renderBars('byproject-list', list, 6)
|
|
440
413
|
}
|
|
441
414
|
|
|
442
415
|
// one-shot reaction (adds a class, removes after ms)
|
|
@@ -575,8 +548,9 @@ function render(d) {
|
|
|
575
548
|
: 'no active session'
|
|
576
549
|
|
|
577
550
|
// where this pace is taking you — hidden until there's enough trail to tell
|
|
578
|
-
|
|
579
|
-
const proj =
|
|
551
|
+
burn.note(d.session.tokens, sessActive)
|
|
552
|
+
const proj =
|
|
553
|
+
sessActive && sessPct < 100 ? burn.project(sessPct, sessReset, d.session.tokens) : null
|
|
580
554
|
const pe = el('session-proj')
|
|
581
555
|
pe.hidden = !proj
|
|
582
556
|
pe.classList.toggle('tight', proj?.kind === 'eta')
|
|
@@ -595,6 +569,7 @@ function render(d) {
|
|
|
595
569
|
: `${fmtTokens(d.week.tokens)} tokens · last 7 days`
|
|
596
570
|
|
|
597
571
|
renderModels(d.byModel || [])
|
|
572
|
+
renderProjects(d.byProject || [])
|
|
598
573
|
renderHeat(d.days30 || [])
|
|
599
574
|
el('month-total').textContent = `${fmtTokens(d.monthTokens)} tokens`
|
|
600
575
|
|
|
@@ -705,6 +680,50 @@ window.api.onAuthResult((r) => {
|
|
|
705
680
|
fitSize()
|
|
706
681
|
})
|
|
707
682
|
|
|
683
|
+
// collapsible panel sections — the widget is a desktop pet, not a dashboard, so
|
|
684
|
+
// each breakdown can be folded away and the choice is remembered per machine
|
|
685
|
+
const SEC_KEY = 'clauddy.folded'
|
|
686
|
+
|
|
687
|
+
function readFolded() {
|
|
688
|
+
try {
|
|
689
|
+
return new Set(JSON.parse(localStorage.getItem(SEC_KEY) || '[]'))
|
|
690
|
+
} catch {
|
|
691
|
+
return new Set()
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function toggleSection(id, force) {
|
|
696
|
+
const sec = el(id)
|
|
697
|
+
if (!sec) return
|
|
698
|
+
// `folded`, not `collapsed` — the body already uses that word for the pet
|
|
699
|
+
const folded = force !== undefined ? force : !sec.classList.contains('folded')
|
|
700
|
+
sec.classList.toggle('folded', folded)
|
|
701
|
+
const head = sec.querySelector('.sec-head')
|
|
702
|
+
if (head) head.setAttribute('aria-expanded', String(!folded))
|
|
703
|
+
// the column width could not be measured while hidden
|
|
704
|
+
if (!folded) fitNames(sec.querySelector('.sec-body'))
|
|
705
|
+
// the card just changed height, and the next usage poll is seconds away —
|
|
706
|
+
// without this the window keeps its old size and clips the content
|
|
707
|
+
fitSize()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
for (const head of document.querySelectorAll('.sec-head')) {
|
|
711
|
+
head.addEventListener('click', () => {
|
|
712
|
+
const id = head.dataset.sec
|
|
713
|
+
toggleSection(id)
|
|
714
|
+
const open = readFolded()
|
|
715
|
+
if (el(id).classList.contains('folded')) open.add(id)
|
|
716
|
+
else open.delete(id)
|
|
717
|
+
try {
|
|
718
|
+
localStorage.setItem(SEC_KEY, JSON.stringify([...open]))
|
|
719
|
+
} catch {
|
|
720
|
+
// private mode or a wiped profile — the panel just forgets, which is fine
|
|
721
|
+
}
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
for (const id of readFolded()) toggleSection(id, true)
|
|
726
|
+
|
|
708
727
|
el('close').addEventListener('click', () => window.api.quit())
|
|
709
728
|
el('usage').addEventListener('click', () => window.api.openUsage())
|
|
710
729
|
|
|
@@ -893,3 +912,20 @@ window.addEventListener('mouseout', (e) => {
|
|
|
893
912
|
// welcome wave
|
|
894
913
|
document.body.classList.add('greet')
|
|
895
914
|
setTimeout(() => document.body.classList.remove('greet'), 1200)
|
|
915
|
+
|
|
916
|
+
// Loaded as a plain <script> by the widget, where `module` doesn't exist. The
|
|
917
|
+
// tests import it instead, against a happy-dom document and a stub bridge —
|
|
918
|
+
// same dual export as burn.js.
|
|
919
|
+
if (typeof module === 'object' && module.exports) {
|
|
920
|
+
module.exports = {
|
|
921
|
+
render,
|
|
922
|
+
fmtTokens,
|
|
923
|
+
fmtReset,
|
|
924
|
+
setState,
|
|
925
|
+
renderModels,
|
|
926
|
+
renderProjects,
|
|
927
|
+
renderHeat,
|
|
928
|
+
showProfile,
|
|
929
|
+
burn,
|
|
930
|
+
}
|
|
931
|
+
}
|
package/renderer/style.css
CHANGED
|
@@ -1685,7 +1685,7 @@ body.state-tired #status-text {
|
|
|
1685
1685
|
.meter-top {
|
|
1686
1686
|
display: flex;
|
|
1687
1687
|
justify-content: space-between;
|
|
1688
|
-
font-size:
|
|
1688
|
+
font-size: 10px;
|
|
1689
1689
|
color: var(--muted);
|
|
1690
1690
|
margin-bottom: 4px;
|
|
1691
1691
|
}
|
|
@@ -1722,12 +1722,44 @@ body.state-tired #status-text {
|
|
|
1722
1722
|
background: linear-gradient(90deg, #e98a68, #d8442f);
|
|
1723
1723
|
}
|
|
1724
1724
|
|
|
1725
|
-
/* by model */
|
|
1726
|
-
.
|
|
1725
|
+
/* by model / by project — collapsible sections */
|
|
1726
|
+
.sec-head {
|
|
1727
|
+
display: flex;
|
|
1728
|
+
align-items: center;
|
|
1729
|
+
justify-content: space-between;
|
|
1730
|
+
width: 100%;
|
|
1731
|
+
padding: 0;
|
|
1732
|
+
margin-bottom: 6px;
|
|
1733
|
+
background: none;
|
|
1734
|
+
border: 0;
|
|
1735
|
+
cursor: pointer;
|
|
1736
|
+
font: inherit;
|
|
1727
1737
|
font-size: 9.5px;
|
|
1728
1738
|
color: var(--muted);
|
|
1729
1739
|
letter-spacing: 0.3px;
|
|
1730
|
-
|
|
1740
|
+
-webkit-app-region: no-drag;
|
|
1741
|
+
}
|
|
1742
|
+
.sec-head:hover {
|
|
1743
|
+
color: var(--text);
|
|
1744
|
+
}
|
|
1745
|
+
.sec-chev {
|
|
1746
|
+
width: 12px;
|
|
1747
|
+
height: 12px;
|
|
1748
|
+
fill: none;
|
|
1749
|
+
stroke: currentColor;
|
|
1750
|
+
stroke-width: 2.2;
|
|
1751
|
+
stroke-linecap: round;
|
|
1752
|
+
stroke-linejoin: round;
|
|
1753
|
+
transition: transform 0.22s ease;
|
|
1754
|
+
}
|
|
1755
|
+
.sec.folded .sec-chev {
|
|
1756
|
+
transform: rotate(-90deg);
|
|
1757
|
+
}
|
|
1758
|
+
.sec.folded .sec-body {
|
|
1759
|
+
display: none;
|
|
1760
|
+
}
|
|
1761
|
+
.sec.folded .sec-head {
|
|
1762
|
+
margin-bottom: 0;
|
|
1731
1763
|
}
|
|
1732
1764
|
.mrow {
|
|
1733
1765
|
display: flex;
|
|
@@ -1738,8 +1770,18 @@ body.state-tired #status-text {
|
|
|
1738
1770
|
margin-bottom: 5px;
|
|
1739
1771
|
}
|
|
1740
1772
|
.mrow .mname {
|
|
1741
|
-
|
|
1773
|
+
/* one width for the whole list, measured at render time — the bars are only
|
|
1774
|
+
comparable if every track starts at the same x */
|
|
1775
|
+
flex: 0 0 var(--name-w, 64px);
|
|
1742
1776
|
font-weight: 500;
|
|
1777
|
+
overflow: hidden;
|
|
1778
|
+
text-overflow: ellipsis;
|
|
1779
|
+
white-space: nowrap;
|
|
1780
|
+
}
|
|
1781
|
+
/* project names are paths, not four-letter families — give them room and clip
|
|
1782
|
+
the rest, with the full name on the row's title attribute */
|
|
1783
|
+
#byproject .mrow {
|
|
1784
|
+
font-size: 10px;
|
|
1743
1785
|
}
|
|
1744
1786
|
.mrow .mbar {
|
|
1745
1787
|
flex: 1;
|
package/usage.js
CHANGED
|
@@ -28,6 +28,86 @@ function labelFor(model) {
|
|
|
28
28
|
return fam
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Claude Code names each project directory after the working directory with
|
|
32
|
+
// every `/` and `.` flattened to `-`, so `-Users-me-my-projects-clauddy` cannot
|
|
33
|
+
// be reversed by string surgery alone: the separator and the names use the same
|
|
34
|
+
// character. Instead of guessing, we walk the real filesystem and let it be the
|
|
35
|
+
// dictionary — at each level we ask which actual child directory, once encoded
|
|
36
|
+
// the same way, starts the remaining string. Longest match first, because `my`
|
|
37
|
+
// and `my-projects` can both be candidates and only the longer one is right.
|
|
38
|
+
const projectLabelCache = new Map()
|
|
39
|
+
|
|
40
|
+
function encodePathSegment(name) {
|
|
41
|
+
return name.replace(/[/.]/g, '-')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Walks as far as the filesystem allows and reports where it stopped. A project
|
|
45
|
+
// that has since been moved or deleted still resolves its surviving ancestors,
|
|
46
|
+
// which is what keeps the fallback label readable instead of a raw blob.
|
|
47
|
+
//
|
|
48
|
+
// `consumed` is the remainder as it stood *before* the last successful step.
|
|
49
|
+
// That step is the only one that can have eaten into the name of a directory
|
|
50
|
+
// that no longer exists — matching `qulture-identity` out of the encoded
|
|
51
|
+
// `qulture-identity-web` leaves a bare `web`, which reads like a real project
|
|
52
|
+
// and is not one. When the walk ends short, the caller rewinds to `consumed`
|
|
53
|
+
// and shows a longer label rather than a confidently wrong one.
|
|
54
|
+
function resolveProjectPath(dirName) {
|
|
55
|
+
let cur = path.sep
|
|
56
|
+
let rest = dirName
|
|
57
|
+
let consumed = null
|
|
58
|
+
while (rest) {
|
|
59
|
+
if (!rest.startsWith('-')) break
|
|
60
|
+
const tail = rest.slice(1)
|
|
61
|
+
let kids
|
|
62
|
+
try {
|
|
63
|
+
kids = fs.readdirSync(cur, { withFileTypes: true })
|
|
64
|
+
} catch {
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
let match = null
|
|
68
|
+
for (const k of kids) {
|
|
69
|
+
// symlinks count: /var is one on macOS, and people do symlink project trees
|
|
70
|
+
if (!k.isDirectory() && !k.isSymbolicLink()) continue
|
|
71
|
+
const enc = encodePathSegment(k.name)
|
|
72
|
+
// `my` and `my-projects` can both start the remainder — the longer one wins
|
|
73
|
+
if (tail !== enc && !tail.startsWith(`${enc}-`)) continue
|
|
74
|
+
if (!match || enc.length > encodePathSegment(match).length) match = k.name
|
|
75
|
+
}
|
|
76
|
+
if (!match) break
|
|
77
|
+
consumed = tail
|
|
78
|
+
cur = path.join(cur, match)
|
|
79
|
+
rest = tail.slice(encodePathSegment(match).length)
|
|
80
|
+
}
|
|
81
|
+
return { full: rest ? null : cur, consumed }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const LABEL_MAX = 26
|
|
85
|
+
|
|
86
|
+
// The readable name for a project directory: its basename when the path still
|
|
87
|
+
// exists, otherwise the raw tail. A moved or deleted project is shown as-is
|
|
88
|
+
// rather than guessed at — a wrong name is worse than an ugly one.
|
|
89
|
+
function projectLabel(dirName) {
|
|
90
|
+
const hit = projectLabelCache.get(dirName)
|
|
91
|
+
if (hit !== undefined) return hit
|
|
92
|
+
const { full, consumed } = resolveProjectPath(dirName)
|
|
93
|
+
let label
|
|
94
|
+
if (full) {
|
|
95
|
+
label = path.basename(full) || full
|
|
96
|
+
} else {
|
|
97
|
+
// whatever the filesystem could not account for, shown as-is
|
|
98
|
+
label = (consumed || dirName).replace(/^-+/, '')
|
|
99
|
+
if (label.length > LABEL_MAX) label = `…${label.slice(-(LABEL_MAX - 1))}`
|
|
100
|
+
}
|
|
101
|
+
projectLabelCache.set(dirName, label)
|
|
102
|
+
return label
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function projectDirOf(file) {
|
|
106
|
+
const rel = path.relative(PROJECTS_DIR, file)
|
|
107
|
+
const first = rel.split(path.sep)[0]
|
|
108
|
+
return first && first !== '..' ? first : null
|
|
109
|
+
}
|
|
110
|
+
|
|
31
111
|
function tokensOf(entry) {
|
|
32
112
|
const u = entry.usage || {}
|
|
33
113
|
return (
|
|
@@ -100,6 +180,8 @@ function parseFile(full, st) {
|
|
|
100
180
|
}
|
|
101
181
|
|
|
102
182
|
const DAYS = 30
|
|
183
|
+
// how many projects the panel ranks before folding the rest into `other`
|
|
184
|
+
const TOP_PROJECTS = 5
|
|
103
185
|
const SESSION_MS = 5 * 3600 * 1000
|
|
104
186
|
|
|
105
187
|
// map a Claude Code tool name to what the pet is "doing"
|
|
@@ -213,12 +295,15 @@ function getUsage(config) {
|
|
|
213
295
|
let weekTokens = 0
|
|
214
296
|
let monthTokens = 0
|
|
215
297
|
const byModel = new Map() // tokens per model, 7 days
|
|
298
|
+
const byProject = new Map() // tokens per project, 7 days
|
|
216
299
|
const days30 = new Array(DAYS).fill(0) // tokens per day
|
|
217
300
|
const recent = [] // last 12h, to detect the 5h session
|
|
218
301
|
let last5mTokens = 0
|
|
219
302
|
|
|
220
303
|
for (const f of files) {
|
|
221
304
|
const entries = parseFile(f.full, f.st)
|
|
305
|
+
const dir = projectDirOf(f.full)
|
|
306
|
+
const proj = dir ? projectLabel(dir) : null
|
|
222
307
|
for (const e of entries) {
|
|
223
308
|
if (e.ts < start30) continue
|
|
224
309
|
if (e.key && e.key !== ':' && seen.has(e.key)) continue
|
|
@@ -233,6 +318,7 @@ function getUsage(config) {
|
|
|
233
318
|
weekTokens += t
|
|
234
319
|
const lbl = labelFor(e.model)
|
|
235
320
|
byModel.set(lbl, (byModel.get(lbl) || 0) + t)
|
|
321
|
+
if (proj) byProject.set(proj, (byProject.get(proj) || 0) + t)
|
|
236
322
|
}
|
|
237
323
|
if (e.ts >= todayMs) todayTokens += t
|
|
238
324
|
if (e.ts >= recentCutoff) recent.push({ ts: e.ts, tokens: t })
|
|
@@ -277,11 +363,26 @@ function getUsage(config) {
|
|
|
277
363
|
.map(([label, tokens]) => ({ label, tokens }))
|
|
278
364
|
.sort((a, b) => b.tokens - a.tokens)
|
|
279
365
|
|
|
366
|
+
// ranked, then everything past the top N folded into one row — a machine with
|
|
367
|
+
// 40 project directories must not turn the panel into a 40-row list.
|
|
368
|
+
const ranked = [...byProject.entries()]
|
|
369
|
+
.map(([label, tokens]) => ({ label, tokens }))
|
|
370
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
371
|
+
const byProjectArr = ranked.slice(0, TOP_PROJECTS)
|
|
372
|
+
const rest = ranked.slice(TOP_PROJECTS)
|
|
373
|
+
if (rest.length) {
|
|
374
|
+
byProjectArr.push({
|
|
375
|
+
label: `other · ${rest.length}`,
|
|
376
|
+
tokens: rest.reduce((n, p) => n + p.tokens, 0),
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
280
380
|
return {
|
|
281
381
|
session,
|
|
282
382
|
week: { tokens: weekTokens, pct: weekPct, resetMs: weekResetMs },
|
|
283
383
|
today: { tokens: todayTokens },
|
|
284
384
|
byModel: byModelArr,
|
|
385
|
+
byProject: byProjectArr,
|
|
285
386
|
days30,
|
|
286
387
|
monthTokens,
|
|
287
388
|
tokensPerMin: Math.round(last5mTokens / 5),
|
|
@@ -293,4 +394,7 @@ function getUsage(config) {
|
|
|
293
394
|
}
|
|
294
395
|
}
|
|
295
396
|
|
|
296
|
-
|
|
397
|
+
// labelFor/tokensOf/detectActivity are exported for the tests — they're the
|
|
398
|
+
// parts that decode Claude Code's log format, which is the thing most likely
|
|
399
|
+
// to change out from under us.
|
|
400
|
+
module.exports = { getUsage, labelFor, projectLabel, tokensOf, detectActivity, PLAN_BUDGETS }
|