clauddy 1.11.1 → 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 +40 -14
- package/package.json +9 -4
- package/renderer/burn.js +98 -0
- package/renderer/index.html +20 -4
- package/renderer/pet.js +126 -5
- package/renderer/style.css +59 -5
- package/usage.js +105 -1
package/README.md
CHANGED
|
@@ -3,19 +3,22 @@
|
|
|
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
|
|
11
13
|
|
|
12
|
-
- **Current session** — real % used + **"resets in Xh Ym"** + session tokens
|
|
14
|
+
- **Current session** — real % used + **"resets in Xh Ym"** + session tokens, and a projection of where that pace is taking you (see [Burn rate](#burn-rate))
|
|
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
|
|
|
@@ -26,6 +29,19 @@ The session/weekly **%** comes straight from your Anthropic account, so it match
|
|
|
26
29
|
|
|
27
30
|
The token is saved locally (see [Data & privacy](#data--privacy)) and refreshed automatically. **Until you connect**, the limits area shows a _"Connect your account"_ prompt instead of percentages.
|
|
28
31
|
|
|
32
|
+
## Burn rate
|
|
33
|
+
|
|
34
|
+
Knowing you're at **82%** with **1h 12m** left on the window still leaves you doing arithmetic in your head. So Clauddy does it for you: it fits the slope of your recent usage and projects when you'd hit 100% — showing one extra line under the session bar:
|
|
35
|
+
|
|
36
|
+
- **`~35m left at this pace`** (in coral) — you'd run out before the window resets. Ease off, or wrap up.
|
|
37
|
+
- **`resets before you run out`** — the reset gets there first. Carry on.
|
|
38
|
+
|
|
39
|
+
The slope is fitted over your **session tokens** rather than the account %. The % is the number you care about, but it arrives as a whole number every ~5 minutes — over a short window the whole signal is a single `16 → 17` step, which throws the fitted pace off by multiples. Local-log tokens step too — one jump per assistant turn — but in increments some 10–20× finer, so the slope is far steadier; the account % then anchors it, converting tokens into % and re-calibrating on every poll.
|
|
40
|
+
|
|
41
|
+
It reads your **recent** pace, not the session average: go quiet for a few minutes and the projection eases off, which is the point.
|
|
42
|
+
|
|
43
|
+
It only appears once there's enough to say honestly — roughly 5 minutes into a session — and stays hidden while you're idle, when the pace is flat, or right after a reset. A projection is a projection: change your pace and it changes with you.
|
|
44
|
+
|
|
29
45
|
## The pet's states
|
|
30
46
|
|
|
31
47
|
<table>
|
|
@@ -109,9 +125,7 @@ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node
|
|
|
109
125
|
bunx clauddy # or: npx clauddy
|
|
110
126
|
```
|
|
111
127
|
|
|
112
|
-
Prefer a standalone app with no Node/Bun? Grab the **portable zip** (`Clauddy-<version>-win.zip`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), unzip it anywhere, and run `Clauddy.exe`. Because the app is unsigned, Windows **SmartScreen** shows a "Windows protected your PC" prompt the first time — click **More info → Run anyway**. From then on it starts with Windows.
|
|
113
|
-
|
|
114
|
-
> Windows builds are produced by the **Build** workflow (Actions ▸ Build) — attaching them to every release automatically is on the roadmap.
|
|
128
|
+
Prefer a standalone app with no Node/Bun? Grab the **portable zip** (`Clauddy-<version>-win-x64.zip`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), unzip it anywhere, and run `Clauddy.exe`. Because the app is unsigned, Windows **SmartScreen** shows a "Windows protected your PC" prompt the first time — click **More info → Run anyway**. From then on it starts with Windows.
|
|
115
129
|
|
|
116
130
|
### Linux (x64)
|
|
117
131
|
|
|
@@ -121,15 +135,13 @@ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node
|
|
|
121
135
|
bunx clauddy # or: npx clauddy
|
|
122
136
|
```
|
|
123
137
|
|
|
124
|
-
Prefer a standalone app? Grab the **AppImage** or **tar.gz** (`Clauddy-<version
|
|
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:
|
|
125
139
|
|
|
126
140
|
```bash
|
|
127
141
|
chmod +x Clauddy-*.AppImage
|
|
128
142
|
./Clauddy-*.AppImage
|
|
129
143
|
```
|
|
130
144
|
|
|
131
|
-
> Linux builds are produced by the **Build** workflow (Actions ▸ Build) — attaching them to every release automatically is on the roadmap.
|
|
132
|
-
|
|
133
145
|
> The system tray icon needs an indicator extension on vanilla GNOME (e.g. "AppIndicator and KStatusNotifier Item Support") — it works out of the box on Cinnamon, KDE, and XFCE. Autostart-at-login is wired up via an XDG `.desktop` entry in `~/.config/autostart/`.
|
|
134
146
|
|
|
135
147
|
> The app keeps its data in `~/.claude-usage-monitor`, regardless of platform or how you run it.
|
|
@@ -193,7 +205,7 @@ live. (Installed globally? Drop the `bunx`: `clauddy poke`. Working on the repo?
|
|
|
193
205
|
## How it works
|
|
194
206
|
|
|
195
207
|
- **`main.js`** — Electron main process: frameless, transparent, always-on-top window; polls usage; fires macOS notifications; watches `config.json` and `debug.json`.
|
|
196
|
-
- **`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.
|
|
197
209
|
- **`auth.js`** — OAuth login (PKCE, same public client as Claude Code) that fetches the authoritative usage %. Token stored locally, never committed.
|
|
198
210
|
- **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles.
|
|
199
211
|
- **`make-icon.js`** — generates the app icon from the pixel sprite (`build/icon.icns`).
|
|
@@ -208,9 +220,17 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
|
|
|
208
220
|
|
|
209
221
|
Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints.
|
|
210
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
|
+
|
|
211
228
|
## Dev tooling
|
|
212
229
|
|
|
213
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.
|
|
214
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).
|
|
215
235
|
|
|
216
236
|
### Releasing
|
|
@@ -218,9 +238,15 @@ Nothing leaves your machine except the OAuth calls to Anthropic's own login and
|
|
|
218
238
|
Releases are **fully automated**. Every push to `main` runs
|
|
219
239
|
[semantic-release](https://semantic-release.gitbook.io) (`.github/workflows/release.yml`):
|
|
220
240
|
it reads the **Conventional Commits** and, when there's something to ship,
|
|
221
|
-
computes the version, builds the macOS
|
|
222
|
-
|
|
223
|
-
your PRs.
|
|
241
|
+
computes the version, builds the app for **macOS, Windows and Linux** on their
|
|
242
|
+
own runners, publishes `clauddy` to npm, and cuts a GitHub Release with every
|
|
243
|
+
artifact attached. Nothing to do by hand — just merge your PRs.
|
|
244
|
+
|
|
245
|
+
The pipeline runs in three stages, because electron-builder can't cross-build
|
|
246
|
+
Windows/Linux from macOS: `version` (a semantic-release dry-run that computes
|
|
247
|
+
the next version) → `build` (a matrix that stamps that version into
|
|
248
|
+
`package.json` so the filenames are right) → `publish` (downloads every
|
|
249
|
+
artifact and runs semantic-release for real).
|
|
224
250
|
|
|
225
251
|
- `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → major.
|
|
226
252
|
- `docs:`/`chore:`/`ci:` etc. don't trigger a release.
|
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 .",
|
|
@@ -80,17 +82,20 @@
|
|
|
80
82
|
"category": "public.app-category.developer-tools",
|
|
81
83
|
"target": "dir",
|
|
82
84
|
"identity": null,
|
|
83
|
-
"icon": "build/icon.icns"
|
|
85
|
+
"icon": "build/icon.icns",
|
|
86
|
+
"artifactName": "${productName}-${version}-mac-${arch}.${ext}"
|
|
84
87
|
},
|
|
85
88
|
"win": {
|
|
86
89
|
"target": "zip",
|
|
87
|
-
"icon": "build/icon.ico"
|
|
90
|
+
"icon": "build/icon.ico",
|
|
91
|
+
"artifactName": "${productName}-${version}-win-${arch}.${ext}"
|
|
88
92
|
},
|
|
89
93
|
"linux": {
|
|
90
94
|
"target": [
|
|
91
95
|
"tar.gz",
|
|
92
96
|
"AppImage"
|
|
93
97
|
],
|
|
98
|
+
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",
|
|
94
99
|
"category": "Utility",
|
|
95
100
|
"icon": "build/icon.png",
|
|
96
101
|
"syncDesktopName": true
|
|
@@ -101,7 +106,7 @@
|
|
|
101
106
|
},
|
|
102
107
|
"devDependencies": {
|
|
103
108
|
"@biomejs/biome": "^2.5.0",
|
|
104
|
-
"@
|
|
109
|
+
"@happy-dom/global-registrator": "^20.11.2",
|
|
105
110
|
"electron-builder": "^26.15.3",
|
|
106
111
|
"semantic-release": "^25.0.5"
|
|
107
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
|
@@ -246,6 +246,7 @@
|
|
|
246
246
|
<div class="meter-top"><span>current session</span><span id="session-pct">0%</span></div>
|
|
247
247
|
<div class="track"><div class="fill" id="session-fill"></div></div>
|
|
248
248
|
<div class="sub" id="session-sub">no active session</div>
|
|
249
|
+
<div class="sub proj" id="session-proj" hidden></div>
|
|
249
250
|
</div>
|
|
250
251
|
<div class="meter">
|
|
251
252
|
<div class="meter-top"><span>weekly · all models</span><span id="week-pct">0%</span></div>
|
|
@@ -267,9 +268,23 @@
|
|
|
267
268
|
<div class="divider"></div>
|
|
268
269
|
|
|
269
270
|
<!-- by model (7 days) -->
|
|
270
|
-
<div id="bymodel">
|
|
271
|
-
<
|
|
272
|
-
|
|
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>
|
|
273
288
|
</div>
|
|
274
289
|
|
|
275
290
|
<div class="divider"></div>
|
|
@@ -373,6 +388,7 @@
|
|
|
373
388
|
</div>
|
|
374
389
|
</div>
|
|
375
390
|
</div>
|
|
376
|
-
<script src="
|
|
391
|
+
<script src="burn.js"></script>
|
|
392
|
+
<script src="pet.js"></script>
|
|
377
393
|
</body>
|
|
378
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,6 +162,9 @@ 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
|
}
|
|
165
|
+
// burn-rate projection lives in burn.js (shared with the tests)
|
|
166
|
+
const burn = Burn.createBurnTracker()
|
|
167
|
+
|
|
157
168
|
function setState(name) {
|
|
158
169
|
const b = document.body
|
|
159
170
|
;[...b.classList].forEach((c) => {
|
|
@@ -345,24 +356,60 @@ function renderHeat(days) {
|
|
|
345
356
|
})
|
|
346
357
|
}
|
|
347
358
|
|
|
348
|
-
// by model
|
|
349
|
-
|
|
350
|
-
|
|
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)
|
|
351
370
|
box.innerHTML = ''
|
|
352
|
-
const top = list.slice(0,
|
|
371
|
+
const top = list.slice(0, limit)
|
|
353
372
|
const max = Math.max(1, ...top.map((m) => m.tokens))
|
|
354
373
|
for (const m of top) {
|
|
355
374
|
const row = document.createElement('div')
|
|
356
375
|
row.className = 'mrow'
|
|
357
376
|
row.innerHTML =
|
|
358
|
-
`<span class="mname">${m.label}</span>` +
|
|
377
|
+
`<span class="mname">${esc(m.label)}</span>` +
|
|
359
378
|
`<span class="mbar"><i style="width:${(m.tokens / max) * 100}%"></i></span>` +
|
|
360
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
|
|
361
382
|
box.appendChild(row)
|
|
362
383
|
}
|
|
363
384
|
if (!top.length) {
|
|
364
385
|
box.innerHTML = '<div class="mrow" style="opacity:.5">no activity</div>'
|
|
386
|
+
return
|
|
365
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)
|
|
366
413
|
}
|
|
367
414
|
|
|
368
415
|
// one-shot reaction (adds a class, removes after ms)
|
|
@@ -500,6 +547,18 @@ function render(d) {
|
|
|
500
547
|
? `resets in ${fmtReset(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
|
|
501
548
|
: 'no active session'
|
|
502
549
|
|
|
550
|
+
// where this pace is taking you — hidden until there's enough trail to tell
|
|
551
|
+
burn.note(d.session.tokens, sessActive)
|
|
552
|
+
const proj =
|
|
553
|
+
sessActive && sessPct < 100 ? burn.project(sessPct, sessReset, d.session.tokens) : null
|
|
554
|
+
const pe = el('session-proj')
|
|
555
|
+
pe.hidden = !proj
|
|
556
|
+
pe.classList.toggle('tight', proj?.kind === 'eta')
|
|
557
|
+
if (proj) {
|
|
558
|
+
pe.textContent =
|
|
559
|
+
proj.kind === 'eta' ? `~${fmtReset(proj.ms)} left at this pace` : 'resets before you run out'
|
|
560
|
+
}
|
|
561
|
+
|
|
503
562
|
el('week-pct').textContent = `${Math.round(wkPct)}%`
|
|
504
563
|
const wf = el('week-fill')
|
|
505
564
|
wf.style.width = `${wkPct}%`
|
|
@@ -510,6 +569,7 @@ function render(d) {
|
|
|
510
569
|
: `${fmtTokens(d.week.tokens)} tokens · last 7 days`
|
|
511
570
|
|
|
512
571
|
renderModels(d.byModel || [])
|
|
572
|
+
renderProjects(d.byProject || [])
|
|
513
573
|
renderHeat(d.days30 || [])
|
|
514
574
|
el('month-total').textContent = `${fmtTokens(d.monthTokens)} tokens`
|
|
515
575
|
|
|
@@ -620,6 +680,50 @@ window.api.onAuthResult((r) => {
|
|
|
620
680
|
fitSize()
|
|
621
681
|
})
|
|
622
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
|
+
|
|
623
727
|
el('close').addEventListener('click', () => window.api.quit())
|
|
624
728
|
el('usage').addEventListener('click', () => window.api.openUsage())
|
|
625
729
|
|
|
@@ -808,3 +912,20 @@ window.addEventListener('mouseout', (e) => {
|
|
|
808
912
|
// welcome wave
|
|
809
913
|
document.body.classList.add('greet')
|
|
810
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
|
@@ -1618,6 +1618,18 @@ body.state-tired #status-text {
|
|
|
1618
1618
|
letter-spacing: 0.2px;
|
|
1619
1619
|
}
|
|
1620
1620
|
|
|
1621
|
+
/* burn-rate projection — calm by default, warm when you'd run out first */
|
|
1622
|
+
.sub.proj {
|
|
1623
|
+
margin-top: 2px;
|
|
1624
|
+
font-style: italic;
|
|
1625
|
+
opacity: 0.85;
|
|
1626
|
+
}
|
|
1627
|
+
.sub.proj.tight {
|
|
1628
|
+
color: var(--coral);
|
|
1629
|
+
font-style: normal;
|
|
1630
|
+
opacity: 1;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1621
1633
|
/* 30-day map */
|
|
1622
1634
|
#heat {
|
|
1623
1635
|
margin-top: 12px;
|
|
@@ -1673,7 +1685,7 @@ body.state-tired #status-text {
|
|
|
1673
1685
|
.meter-top {
|
|
1674
1686
|
display: flex;
|
|
1675
1687
|
justify-content: space-between;
|
|
1676
|
-
font-size:
|
|
1688
|
+
font-size: 10px;
|
|
1677
1689
|
color: var(--muted);
|
|
1678
1690
|
margin-bottom: 4px;
|
|
1679
1691
|
}
|
|
@@ -1710,12 +1722,44 @@ body.state-tired #status-text {
|
|
|
1710
1722
|
background: linear-gradient(90deg, #e98a68, #d8442f);
|
|
1711
1723
|
}
|
|
1712
1724
|
|
|
1713
|
-
/* by model */
|
|
1714
|
-
.
|
|
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;
|
|
1715
1737
|
font-size: 9.5px;
|
|
1716
1738
|
color: var(--muted);
|
|
1717
1739
|
letter-spacing: 0.3px;
|
|
1718
|
-
|
|
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;
|
|
1719
1763
|
}
|
|
1720
1764
|
.mrow {
|
|
1721
1765
|
display: flex;
|
|
@@ -1726,8 +1770,18 @@ body.state-tired #status-text {
|
|
|
1726
1770
|
margin-bottom: 5px;
|
|
1727
1771
|
}
|
|
1728
1772
|
.mrow .mname {
|
|
1729
|
-
|
|
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);
|
|
1730
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;
|
|
1731
1785
|
}
|
|
1732
1786
|
.mrow .mbar {
|
|
1733
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 }
|