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 +13 -1
- package/auth.js +27 -2
- package/config.json +1 -0
- package/main.js +18 -4
- package/package.json +1 -1
- package/renderer/index.html +18 -0
- package/renderer/pet.js +43 -3
- package/renderer/style.css +2 -1
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/
|
|
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
|
-
|
|
177
|
-
opus: win(j.seven_day_opus),
|
|
202
|
+
scoped: scopedWeeks(j),
|
|
178
203
|
}
|
|
179
204
|
}
|
|
180
205
|
|
package/config.json
CHANGED
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
|
-
|
|
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
package/renderer/index.html
CHANGED
|
@@ -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
|
|
600
|
-
const
|
|
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')
|