clauddy 1.13.1 → 1.15.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
@@ -4,7 +4,7 @@ A cute pixel-art desktop pet for macOS that tracks your Claude Code usage — mi
4
4
 
5
5
  <p align="center">
6
6
 
7
- https://github.com/user-attachments/assets/76d87b6f-2876-4000-b6db-13ba2207ae31
7
+ https://github.com/user-attachments/assets/dbe00d9a-b49c-48ea-941c-76c517dec358
8
8
 
9
9
  <em>A little terracotta creature that lives in the corner of your screen, eats your tokens, and naps when you're idle.</em>
10
10
  </p>
@@ -142,6 +142,18 @@ chmod +x Clauddy-*.AppImage
142
142
  ./Clauddy-*.AppImage
143
143
  ```
144
144
 
145
+ Want the pet icon to show up in your app menu / taskbar too, instead of a generic icon? Running the AppImage directly doesn't register it anywhere — GNOME (and most Wayland desktops) only pick up an app's icon from an installed `.desktop` entry. One-time setup, right next to the AppImage:
146
+
147
+ ```bash
148
+ ./Clauddy-*.AppImage --appimage-extract clauddy.desktop >/dev/null
149
+ ./Clauddy-*.AppImage --appimage-extract usr/share/icons/hicolor/512x512/apps/clauddy.png >/dev/null
150
+ mkdir -p ~/.local/share/applications ~/.local/share/icons/hicolor/512x512/apps
151
+ cp squashfs-root/usr/share/icons/hicolor/512x512/apps/clauddy.png ~/.local/share/icons/hicolor/512x512/apps/clauddy.png
152
+ sed "s|^Exec=.*|Exec=$(readlink -f Clauddy-*.AppImage) --no-sandbox %U|" squashfs-root/clauddy.desktop > ~/.local/share/applications/clauddy.desktop
153
+ rm -rf squashfs-root
154
+ update-desktop-database ~/.local/share/applications 2>/dev/null
155
+ ```
156
+
145
157
  > 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/`.
146
158
 
147
159
  > The app keeps its data in `~/.claude-usage-monitor`, regardless of platform or how you run it.
package/auth.js CHANGED
@@ -152,6 +152,32 @@ function win(o) {
152
152
  : null
153
153
  }
154
154
 
155
+ // Weekly limits scoped to one model (e.g. "Fable" on Max) live in the `limits`
156
+ // array. The older seven_day_<model> windows are kept as a fallback for plans
157
+ // that still report them that way.
158
+ function scopedWeeks(j) {
159
+ const out = []
160
+ for (const l of Array.isArray(j.limits) ? j.limits : []) {
161
+ if (l?.kind !== 'weekly_scoped' || typeof l.percent !== 'number') continue
162
+ const label = l.scope?.model?.display_name
163
+ if (!label) continue
164
+ out.push({
165
+ label,
166
+ pct: l.percent,
167
+ resetMs: l.resets_at ? Date.parse(l.resets_at) - Date.now() : null,
168
+ })
169
+ }
170
+ if (out.length) return out
171
+ for (const [key, label] of [
172
+ ['seven_day_opus', 'Opus'],
173
+ ['seven_day_sonnet', 'Sonnet'],
174
+ ]) {
175
+ const w = win(j[key])
176
+ if (w) out.push({ label, ...w })
177
+ }
178
+ return out
179
+ }
180
+
155
181
  // Step 3: fetch the authoritative usage
156
182
  async function fetchUsage() {
157
183
  const token = await validToken()
@@ -173,8 +199,7 @@ async function fetchUsage() {
173
199
  return {
174
200
  session: win(j.five_hour) || { pct: 0, resetMs: null },
175
201
  week: win(j.seven_day) || { pct: 0, resetMs: null },
176
- sonnet: win(j.seven_day_sonnet),
177
- opus: win(j.seven_day_opus),
202
+ scoped: scopedWeeks(j),
178
203
  }
179
204
  }
180
205
 
package/config.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "alerts": true,
5
5
  "alertThresholds": [80, 95],
6
6
  "fireThreshold": 90,
7
+ "zoom": 100,
7
8
  "pollIntervalMs": 4000,
8
9
  "activeThresholdMs": 20000,
9
10
  "sleepThresholdMs": 300000
package/main.js CHANGED
@@ -65,6 +65,7 @@ function publicConfig(c) {
65
65
  alerts: c.alerts,
66
66
  alertThresholds: c.alertThresholds,
67
67
  fireThreshold: c.fireThreshold,
68
+ zoom: c.zoom,
68
69
  }
69
70
  }
70
71
 
@@ -78,6 +79,7 @@ function loadConfig() {
78
79
  alerts: true,
79
80
  alertThresholds: [80, 95],
80
81
  fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
82
+ zoom: 100, // widget scale %, 100-200
81
83
  pollIntervalMs: 4000,
82
84
  activeThresholdMs: 20000,
83
85
  sleepThresholdMs: 300000,
@@ -93,12 +95,22 @@ function loadConfig() {
93
95
  // native notification when usage crosses a threshold
94
96
  const armed = new Set()
95
97
  function checkAlerts(config, d) {
96
- if (!config.alerts || !Notification.isSupported()) return
97
- const ths = config.alertThresholds || [80, 95]
98
- const scopes = [
98
+ alertScopes(config, [
99
99
  ['session', d.session.pct],
100
100
  ['weekly usage', d.week.pct],
101
- ]
101
+ ])
102
+ }
103
+ // per-model weekly limits only exist on the account side, so they're checked
104
+ // off the OAuth poll rather than the local tick
105
+ function checkScopedAlerts(config, u) {
106
+ alertScopes(
107
+ config,
108
+ (u?.scoped || []).map((s) => [`${s.label} weekly usage`, s.pct]),
109
+ )
110
+ }
111
+ function alertScopes(config, scopes) {
112
+ if (!config.alerts || !Notification.isSupported()) return
113
+ const ths = config.alertThresholds || [80, 95]
102
114
  for (const [name, pct] of scopes) {
103
115
  for (const t of ths) {
104
116
  const key = `${name}:${t}`
@@ -137,6 +149,7 @@ function createWindow() {
137
149
  skipTaskbar: true,
138
150
  hasShadow: false,
139
151
  fullscreenable: false,
152
+ icon: path.join(__dirname, 'build', 'icon.png'),
140
153
  webPreferences: {
141
154
  preload: path.join(__dirname, 'preload.js'),
142
155
  contextIsolation: true,
@@ -358,6 +371,7 @@ function updateTray() {
358
371
  function pushRealUsage(u) {
359
372
  sessionPct = u?.session ? u.session.pct : null
360
373
  updateTray()
374
+ checkScopedAlerts(config, u)
361
375
  if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
362
376
  }
363
377
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.13.1",
4
+ "version": "1.15.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
@@ -253,6 +253,8 @@
253
253
  <div class="track"><div class="fill week" id="week-fill"></div></div>
254
254
  <div class="sub" id="week-sub">—</div>
255
255
  </div>
256
+ <!-- per-model weekly limits (e.g. Fable), one meter each -->
257
+ <div id="scoped-meters"></div>
256
258
  </div>
257
259
  <button id="limits-connect">
258
260
  <svg class="lc-ico" viewBox="0 0 24 24" aria-hidden="true">
@@ -328,6 +330,22 @@
328
330
  </span>
329
331
  </div>
330
332
 
333
+ <div class="set-section">
334
+ <div class="set-sec-title">Zoom</div>
335
+ <div class="set-thresholds">
336
+ <span class="thr">
337
+ <span class="thr-cap">size</span>
338
+ <span class="num">
339
+ <input id="set-zoom" type="number" min="100" max="200" step="25" />
340
+ <span class="num-spin">
341
+ <button type="button" class="num-btn" data-for="set-zoom" data-step="25">▲</button>
342
+ <button type="button" class="num-btn" data-for="set-zoom" data-step="-25">▼</button>
343
+ </span>
344
+ </span>
345
+ </span>
346
+ </div>
347
+ </div>
348
+
331
349
  <div class="set-section">
332
350
  <div class="set-sec-title">
333
351
  Alerts
package/renderer/pet.js CHANGED
@@ -404,6 +404,27 @@ function fitNames(box) {
404
404
  box.style.setProperty('--name-w', `${w}px`)
405
405
  }
406
406
 
407
+ // per-model weekly limits (e.g. "Fable" on Max) — one meter each, in the same
408
+ // shape as the all-models one. The token count pairs the limit with the local
409
+ // log totals for that model family (a "Fable" limit covers every Fable row).
410
+ function renderScoped(list, byModel) {
411
+ const box = el('scoped-meters')
412
+ box.innerHTML = ''
413
+ for (const s of list) {
414
+ const key = s.label.toLowerCase()
415
+ const tokens = byModel
416
+ .filter((m) => m.label.toLowerCase().startsWith(key))
417
+ .reduce((n, m) => n + m.tokens, 0)
418
+ const m = document.createElement('div')
419
+ m.className = 'meter'
420
+ m.innerHTML =
421
+ `<div class="meter-top"><span>weekly · ${esc(key)}</span><span>${Math.round(s.pct)}%</span></div>` +
422
+ `<div class="track"><div class="fill week${s.pct >= 80 ? ' high' : ''}" style="width:${s.pct}%"></div></div>` +
423
+ `<div class="sub">${s.resetMs != null ? `resets in ${fmtReset(s.resetMs)} · ` : ''}${fmtTokens(tokens)} tokens</div>`
424
+ box.appendChild(m)
425
+ }
426
+ }
427
+
407
428
  // by model (7 days)
408
429
  function renderModels(list) {
409
430
  renderBars('bymodel-list', list, 4)
@@ -571,6 +592,7 @@ function render(d) {
571
592
  ? `resets in ${fmtReset(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
572
593
  : `${fmtTokens(d.week.tokens)} tokens · last 7 days`
573
594
 
595
+ renderScoped(liveOn ? realUsage.scoped || [] : [], d.byModel || [])
574
596
  renderModels(d.byModel || [])
575
597
  renderProjects(d.byProject || [])
576
598
  renderHeat(d.days30 || [])
@@ -596,8 +618,12 @@ let lastW = 0
596
618
  function fitSize() {
597
619
  requestAnimationFrame(() => {
598
620
  const collapsed = document.body.classList.contains('collapsed')
599
- const w = collapsed ? 140 : 276
600
- const h = el('card').offsetHeight + 24 // 12px margin top + bottom
621
+ const zoom = Number.parseFloat(document.body.style.zoom) || 1
622
+ const w = (collapsed ? 140 : 276) * zoom
623
+ // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
624
+ // empirically against this Electron build) — so measure the unzoomed content
625
+ // height, then scale the whole thing (content + margin) ourselves
626
+ const h = (el('card').offsetHeight + 24) * zoom // 12px margin top + bottom
601
627
  if (Math.abs(h - lastH) > 2 || w !== lastW) {
602
628
  lastH = h
603
629
  lastW = w
@@ -606,6 +632,12 @@ function fitSize() {
606
632
  })
607
633
  }
608
634
 
635
+ // widget scale, applied as a CSS zoom (not transform) so offsetWidth/Height
636
+ // keep reflecting it — see fitNames()'s comment on why transform can't be used here
637
+ function applyZoom(z) {
638
+ document.body.style.zoom = (z || 100) / 100
639
+ }
640
+
609
641
  let currentConfig = {}
610
642
  let realUsage = null
611
643
  let debugState = null
@@ -624,6 +656,8 @@ window.api.onError((msg) => {
624
656
  window.api.onConfig((cfg) => {
625
657
  currentConfig = cfg || {}
626
658
  document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
659
+ applyZoom(currentConfig.zoom)
660
+ fitSize()
627
661
  })
628
662
  window.api.onRealUsage((u) => {
629
663
  realUsage = u || null
@@ -809,6 +843,7 @@ function snapshotSettings() {
809
843
  t1: el('set-t1').value,
810
844
  t2: el('set-t2').value,
811
845
  fire: el('set-fire').value,
846
+ zoom: el('set-zoom').value,
812
847
  })
813
848
  }
814
849
  function refreshSaveDirty() {
@@ -827,6 +862,7 @@ function populateSettings() {
827
862
  el('set-t1').value = th[0] != null ? th[0] : 80
828
863
  el('set-t2').value = th[1] != null ? th[1] : 95
829
864
  el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
865
+ el('set-zoom').value = c.zoom != null ? c.zoom : 100
830
866
  clearSaveDirty() // fields now match the saved config
831
867
  }
832
868
  function openSettings() {
@@ -850,7 +886,7 @@ for (const b of document.querySelectorAll('.num-btn')) {
850
886
  })
851
887
  }
852
888
  // light up Save whenever an editable field changes
853
- for (const id of ['set-alerts', 'set-t1', 'set-t2', 'set-fire']) {
889
+ for (const id of ['set-alerts', 'set-t1', 'set-t2', 'set-fire', 'set-zoom']) {
854
890
  el(id).addEventListener('input', refreshSaveDirty)
855
891
  el(id).addEventListener('change', refreshSaveDirty)
856
892
  }
@@ -869,6 +905,9 @@ el('set-cancel').addEventListener('click', () => {
869
905
  el('set-save').addEventListener('click', () => {
870
906
  const num = (id) => parseFloat(el(id).value)
871
907
  const fire = num('set-fire')
908
+ const zoomRaw = num('set-zoom')
909
+ const zoom = zoomRaw >= 100 && zoomRaw <= 200 ? Math.round(zoomRaw) : 100
910
+ applyZoom(zoom) // apply immediately so the fitSize() below measures the new size
872
911
  window.api.saveConfig({
873
912
  mode: selectedMode,
874
913
  alerts: el('set-alerts').checked,
@@ -876,6 +915,7 @@ el('set-save').addEventListener('click', () => {
876
915
  .filter((n) => n >= 1 && n <= 100)
877
916
  .sort((a, b) => a - b),
878
917
  fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
918
+ zoom,
879
919
  })
880
920
  clearSaveDirty()
881
921
  document.body.classList.remove('settings-open')
@@ -1679,7 +1679,8 @@ body.state-tired #status-text {
1679
1679
  }
1680
1680
 
1681
1681
  /* meters */
1682
- .meter + .meter {
1682
+ .meter + .meter,
1683
+ #scoped-meters .meter {
1683
1684
  margin-top: 9px;
1684
1685
  }
1685
1686
  .meter-top {