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 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
- <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
@@ -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 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
 
@@ -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-x64.AppImage` / `Clauddy-<version>-linux-x64.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:
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.12.0",
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
  }
@@ -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
+ })()
@@ -268,9 +268,23 @@
268
268
  <div class="divider"></div>
269
269
 
270
270
  <!-- by model (7 days) -->
271
- <div id="bymodel">
272
- <div class="bymodel-title">by model · 7 days</div>
273
- <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>
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="pet.js"></script>
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) => ({ '&': '&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,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
- // ---- burn-rate projection ----
158
- // The panel knows where you are (82%) and when the window resets (1h 12m); it
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 (7 days)
423
- function renderModels(list) {
424
- 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)
425
370
  box.innerHTML = ''
426
- const top = list.slice(0, 4)
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
- noteTokens(d.session.tokens, sessActive)
579
- const proj = sessActive && sessPct < 100 ? project(sessPct, sessReset, d.session.tokens) : null
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
+ }
@@ -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: 10.5px;
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
- .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;
1727
1737
  font-size: 9.5px;
1728
1738
  color: var(--muted);
1729
1739
  letter-spacing: 0.3px;
1730
- 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;
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
- 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);
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
- 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 }