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 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
- <img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/overview.gif" width="300" alt="Clauddy — the full widget showing session, weekly, by-model and 30-day usage" /><br />
7
- <em>A little terracotta creature that lives in the corner of your screen, eats your tokens, and naps when you're idle.</em>
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 breakdown, activity status, and 30-day map come from your local logs (`~/.claude/projects/**/*.jsonl`). Everything is token-based — no dollars.
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>.AppImage` / `clauddy-<version>.tar.gz`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), then:
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 app, publishes `clauddy` to npm, and
222
- cuts a GitHub Release with the `.app` zip. Nothing to do by hand just merge
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.11.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
- "@semantic-release/exec": "^7.1.0",
109
+ "@happy-dom/global-registrator": "^20.11.2",
105
110
  "electron-builder": "^26.15.3",
106
111
  "semantic-release": "^25.0.5"
107
112
  }
@@ -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
+ })()
@@ -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
- <div class="bymodel-title">by model · 7 days</div>
272
- <div id="bymodel-list"></div>
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="pet.js"></script>
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 (7 days)
349
- function renderModels(list) {
350
- const box = el('bymodel-list')
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, 4)
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
+ }
@@ -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: 10.5px;
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
- .bymodel-title {
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
- margin-bottom: 6px;
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
- flex: 0 0 64px;
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
- module.exports = { getUsage }
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 }