clauddy 1.8.0 → 1.9.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 +11 -1
- package/config.json +1 -0
- package/main.js +149 -7
- package/package.json +4 -2
- package/renderer/index.html +8 -0
- package/renderer/pet.js +18 -0
- package/renderer/style.css +32 -0
package/README.md
CHANGED
|
@@ -121,10 +121,19 @@ Coming soon — the build is already wired for `tar.gz` and AppImage. In the mea
|
|
|
121
121
|
|
|
122
122
|
- **Drag** the widget anywhere on screen
|
|
123
123
|
- **–** minimizes to just the pet's face (showing the live session %); the **⤢** button or a double-click on the pet expands it back
|
|
124
|
-
- **⚙** opens settings (log in, toggle alerts, set thresholds)
|
|
124
|
+
- **⚙** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
|
|
125
125
|
- **↗** opens the official Usage page
|
|
126
126
|
- **×** quits
|
|
127
127
|
|
|
128
|
+
### Floating or menu bar
|
|
129
|
+
|
|
130
|
+
Under **⚙ Settings → Display** you can pick where Clauddy lives:
|
|
131
|
+
|
|
132
|
+
- **Floating pet** — the always-on widget in the corner (default).
|
|
133
|
+
- **Menu bar** — a small pet icon in the macOS menu bar showing your live session **%** (it turns 🔥 near your limit). Click it to pop open the full pet + usage panel; click away to dismiss. Right-click for a quick menu.
|
|
134
|
+
|
|
135
|
+
Switching is instant — no restart. (On Windows/Linux the icon lives in the system tray; the live % shows in its tooltip.)
|
|
136
|
+
|
|
128
137
|
## Alerts
|
|
129
138
|
|
|
130
139
|
Optional **macOS notifications** when your session or weekly usage crosses the thresholds you set (default **80%** and **95%**) — e.g. _"Your session is over 80% — now at 82%"_. They re-arm automatically once usage drops back below a threshold (after a reset). Toggle them and edit the thresholds in **⚙ Settings**.
|
|
@@ -135,6 +144,7 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
|
|
|
135
144
|
|
|
136
145
|
```jsonc
|
|
137
146
|
{
|
|
147
|
+
"mode": "floating", // "floating" pet in the corner, or "menubar" popover
|
|
138
148
|
"alerts": true, // macOS notifications on/off
|
|
139
149
|
"alertThresholds": [80, 95], // notify when session/week cross these % (two levels)
|
|
140
150
|
"fireThreshold": 90, // session % at which the pet catches fire (maxed out stays 100)
|
package/config.json
CHANGED
package/main.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
app,
|
|
3
|
+
BrowserWindow,
|
|
4
|
+
ipcMain,
|
|
5
|
+
screen,
|
|
6
|
+
Notification,
|
|
7
|
+
shell,
|
|
8
|
+
Tray,
|
|
9
|
+
Menu,
|
|
10
|
+
nativeImage,
|
|
11
|
+
} = require('electron')
|
|
2
12
|
const path = require('node:path')
|
|
3
13
|
const fs = require('node:fs')
|
|
4
14
|
const os = require('node:os')
|
|
@@ -30,12 +40,20 @@ let config
|
|
|
30
40
|
let doTick = null
|
|
31
41
|
const W = 276
|
|
32
42
|
|
|
43
|
+
// menu-bar (tray) mode state
|
|
44
|
+
let tray = null
|
|
45
|
+
let currentMode = 'floating' // 'floating' widget | 'menubar' popover
|
|
46
|
+
let trayBounds = null // last known tray icon rect, to anchor the popover
|
|
47
|
+
let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
|
|
48
|
+
let sessionPct = null // authoritative session % shown in the tray title
|
|
49
|
+
|
|
33
50
|
function publicConfig(c) {
|
|
34
51
|
return {
|
|
35
52
|
plan: c.plan,
|
|
36
53
|
sessionTokenBudget: c.sessionTokenBudget,
|
|
37
54
|
weeklyTokenBudget: c.weeklyTokenBudget,
|
|
38
55
|
weeklyAnchorIso: c.weeklyAnchorIso,
|
|
56
|
+
mode: c.mode,
|
|
39
57
|
alerts: c.alerts,
|
|
40
58
|
alertThresholds: c.alertThresholds,
|
|
41
59
|
fireThreshold: c.fireThreshold,
|
|
@@ -48,6 +66,7 @@ function loadConfig() {
|
|
|
48
66
|
sessionTokenBudget: 630000000,
|
|
49
67
|
weeklyTokenBudget: 3450000000,
|
|
50
68
|
weeklyAnchorIso: null,
|
|
69
|
+
mode: 'floating', // 'floating' widget or 'menubar' popover
|
|
51
70
|
alerts: true,
|
|
52
71
|
alertThresholds: [80, 95],
|
|
53
72
|
fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
|
|
@@ -105,6 +124,7 @@ function createWindow() {
|
|
|
105
124
|
transparent: true,
|
|
106
125
|
backgroundColor: '#00000000', // fully transparent — Windows needs this or the window paints black
|
|
107
126
|
resizable: false,
|
|
127
|
+
show: false, // applyMode() reveals it (floating) or keeps it a hidden popover (menubar)
|
|
108
128
|
alwaysOnTop: true,
|
|
109
129
|
skipTaskbar: true,
|
|
110
130
|
hasShadow: false,
|
|
@@ -120,6 +140,14 @@ function createWindow() {
|
|
|
120
140
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
|
121
141
|
win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
|
|
122
142
|
|
|
143
|
+
// in menu-bar mode the popover dismisses when it loses focus (click elsewhere)
|
|
144
|
+
win.on('blur', () => {
|
|
145
|
+
if (currentMode === 'menubar' && win.isVisible()) {
|
|
146
|
+
win.hide()
|
|
147
|
+
lastBlurHide = Date.now()
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
123
151
|
const tick = () => {
|
|
124
152
|
if (!win || win.isDestroyed()) return
|
|
125
153
|
try {
|
|
@@ -140,7 +168,115 @@ function createWindow() {
|
|
|
140
168
|
startUsagePoll()
|
|
141
169
|
sendProfile()
|
|
142
170
|
watchDebug()
|
|
171
|
+
applyMode(config.mode) // reveal the widget, or set up the tray + popover
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- menu-bar (tray) mode ----------------------------------------------------
|
|
176
|
+
// Floating mode: the widget lives bottom-right, always visible. Menu-bar mode:
|
|
177
|
+
// the same window becomes a popover shown under a tray icon on click. Switching
|
|
178
|
+
// is live — no relaunch — so the Settings toggle applies immediately.
|
|
179
|
+
function applyMode(mode) {
|
|
180
|
+
const next = mode === 'menubar' ? 'menubar' : 'floating'
|
|
181
|
+
const changed = next !== currentMode
|
|
182
|
+
currentMode = next
|
|
183
|
+
if (currentMode === 'menubar') {
|
|
184
|
+
ensureTray()
|
|
185
|
+
win.setSkipTaskbar(true)
|
|
186
|
+
if (changed && win.isVisible()) win.hide() // hide only when switching in
|
|
187
|
+
} else {
|
|
188
|
+
destroyTray()
|
|
189
|
+
if (changed || !win.isVisible()) {
|
|
190
|
+
positionFloating()
|
|
191
|
+
win.show()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
updateTray()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function ensureTray() {
|
|
198
|
+
if (tray) return
|
|
199
|
+
const img = nativeImage.createFromPath(path.join(__dirname, 'build', 'trayTemplate.png'))
|
|
200
|
+
img.setTemplateImage(true) // let macOS recolor it for the light/dark menu bar
|
|
201
|
+
tray = new Tray(img)
|
|
202
|
+
tray.setToolTip('Clauddy')
|
|
203
|
+
tray.on('click', (_e, bounds) => {
|
|
204
|
+
trayBounds = bounds
|
|
205
|
+
togglePopover()
|
|
143
206
|
})
|
|
207
|
+
tray.on('right-click', () => tray.popUpContextMenu(trayMenu()))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function destroyTray() {
|
|
211
|
+
if (tray) {
|
|
212
|
+
tray.destroy()
|
|
213
|
+
tray = null
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function trayMenu() {
|
|
218
|
+
return Menu.buildFromTemplate([
|
|
219
|
+
{ label: 'Open Clauddy', click: () => showPopover() },
|
|
220
|
+
{
|
|
221
|
+
label: 'Open Usage page',
|
|
222
|
+
click: () => shell.openExternal('https://claude.ai/settings/usage'),
|
|
223
|
+
},
|
|
224
|
+
{ type: 'separator' },
|
|
225
|
+
{ label: 'Quit Clauddy', click: () => app.quit() },
|
|
226
|
+
])
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function togglePopover() {
|
|
230
|
+
if (Date.now() - lastBlurHide < 250) return // this click just dismissed it
|
|
231
|
+
if (win.isVisible()) win.hide()
|
|
232
|
+
else showPopover()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function showPopover() {
|
|
236
|
+
positionUnderTray()
|
|
237
|
+
win.show()
|
|
238
|
+
win.focus()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// center the popover under the tray icon, kept on-screen
|
|
242
|
+
function positionUnderTray() {
|
|
243
|
+
const b = win.getBounds()
|
|
244
|
+
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
245
|
+
let x = workAreaSize.width - b.width - 24
|
|
246
|
+
let y = 24
|
|
247
|
+
if (trayBounds?.width) {
|
|
248
|
+
x = Math.round(trayBounds.x + trayBounds.width / 2 - b.width / 2)
|
|
249
|
+
y = Math.round(trayBounds.y + trayBounds.height)
|
|
250
|
+
}
|
|
251
|
+
x = Math.max(8, Math.min(x, workAreaSize.width - b.width - 8))
|
|
252
|
+
win.setPosition(x, y)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function positionFloating() {
|
|
256
|
+
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
257
|
+
const b = win.getBounds()
|
|
258
|
+
win.setPosition(workAreaSize.width - b.width - 24, workAreaSize.height - b.height - 24)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// the tray shows the live session % (macOS title), turning 🔥 near the limit
|
|
262
|
+
function updateTray() {
|
|
263
|
+
if (!tray) return
|
|
264
|
+
if (sessionPct == null) {
|
|
265
|
+
if (process.platform === 'darwin') tray.setTitle('')
|
|
266
|
+
tray.setToolTip('Clauddy — connect your account for live %')
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const pct = Math.round(sessionPct)
|
|
270
|
+
const hot = pct >= (config?.fireThreshold ?? 90)
|
|
271
|
+
if (process.platform === 'darwin') tray.setTitle(hot ? ` ${pct}% 🔥` : ` ${pct}%`)
|
|
272
|
+
tray.setToolTip(`Clauddy — session ${pct}%`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// send real usage to the renderer and refresh the tray title in one place
|
|
276
|
+
function pushRealUsage(u) {
|
|
277
|
+
sessionPct = u?.session ? u.session.pct : null
|
|
278
|
+
updateTray()
|
|
279
|
+
if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
|
|
144
280
|
}
|
|
145
281
|
|
|
146
282
|
// resize the window to fit the content
|
|
@@ -149,8 +285,12 @@ ipcMain.on('resize', (_e, w, h) => {
|
|
|
149
285
|
const width = Math.max(100, Math.round(w))
|
|
150
286
|
const height = Math.max(110, Math.round(h))
|
|
151
287
|
win.setContentSize(width, height)
|
|
152
|
-
|
|
153
|
-
|
|
288
|
+
if (currentMode === 'menubar') {
|
|
289
|
+
if (win.isVisible()) positionUnderTray() // keep it anchored under the tray
|
|
290
|
+
} else {
|
|
291
|
+
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
292
|
+
win.setPosition(workAreaSize.width - width - 24, workAreaSize.height - height - 24)
|
|
293
|
+
}
|
|
154
294
|
})
|
|
155
295
|
|
|
156
296
|
ipcMain.on('open-usage', () => shell.openExternal('https://claude.ai/settings/usage'))
|
|
@@ -177,15 +317,15 @@ async function pollUsage() {
|
|
|
177
317
|
try {
|
|
178
318
|
const u = await auth.fetchUsage()
|
|
179
319
|
usageBackoff = 5 * 60 * 1000
|
|
180
|
-
|
|
320
|
+
pushRealUsage(u)
|
|
181
321
|
} catch (e) {
|
|
182
322
|
if (e && e.status === 429) {
|
|
183
323
|
usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
|
|
184
324
|
} else if (e && e.status === 401) {
|
|
185
325
|
auth.clear()
|
|
326
|
+
pushRealUsage(null)
|
|
186
327
|
if (win && !win.isDestroyed()) {
|
|
187
328
|
win.webContents.send('auth-state', { connected: false })
|
|
188
|
-
win.webContents.send('real-usage', null)
|
|
189
329
|
win.webContents.send('profile', null)
|
|
190
330
|
}
|
|
191
331
|
}
|
|
@@ -220,7 +360,7 @@ ipcMain.on('auth-code', async (_e, code) => {
|
|
|
220
360
|
try {
|
|
221
361
|
const u = await auth.fetchUsage() // validate the token
|
|
222
362
|
ok()
|
|
223
|
-
|
|
363
|
+
pushRealUsage(u)
|
|
224
364
|
} catch (e) {
|
|
225
365
|
if (e && e.status === 429) {
|
|
226
366
|
// token is fine, the usage endpoint is just throttled — keep it and retry later
|
|
@@ -239,9 +379,9 @@ ipcMain.on('auth-code', async (_e, code) => {
|
|
|
239
379
|
ipcMain.on('auth-logout', () => {
|
|
240
380
|
auth.clear()
|
|
241
381
|
clearTimeout(usageTimer)
|
|
382
|
+
pushRealUsage(null)
|
|
242
383
|
if (win && !win.isDestroyed()) {
|
|
243
384
|
win.webContents.send('auth-state', { connected: false })
|
|
244
|
-
win.webContents.send('real-usage', null)
|
|
245
385
|
win.webContents.send('profile', null)
|
|
246
386
|
}
|
|
247
387
|
})
|
|
@@ -263,6 +403,7 @@ ipcMain.on('save-config', (_e, patch) => {
|
|
|
263
403
|
armed.clear() // re-arm alerts with new thresholds
|
|
264
404
|
if (doTick) doTick()
|
|
265
405
|
if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
|
|
406
|
+
applyMode(config.mode) // switch between floating widget and menu-bar popover live
|
|
266
407
|
})
|
|
267
408
|
|
|
268
409
|
ipcMain.on('quit', () => app.quit())
|
|
@@ -280,6 +421,7 @@ app.whenReady().then(() => {
|
|
|
280
421
|
app.on('window-all-closed', () => {
|
|
281
422
|
if (pollTimer) clearInterval(pollTimer)
|
|
282
423
|
fs.unwatchFile(DEBUG_FILE)
|
|
424
|
+
destroyTray()
|
|
283
425
|
app.quit()
|
|
284
426
|
})
|
|
285
427
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauddy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "A cute desktop pet that tracks your Claude Code usage",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
|
@@ -68,7 +68,9 @@
|
|
|
68
68
|
"usage.js",
|
|
69
69
|
"auth.js",
|
|
70
70
|
"config.json",
|
|
71
|
-
"renderer/**"
|
|
71
|
+
"renderer/**",
|
|
72
|
+
"build/trayTemplate.png",
|
|
73
|
+
"build/trayTemplate@2x.png"
|
|
72
74
|
],
|
|
73
75
|
"mac": {
|
|
74
76
|
"category": "public.app-category.developer-tools",
|
package/renderer/index.html
CHANGED
|
@@ -305,6 +305,14 @@
|
|
|
305
305
|
</div>
|
|
306
306
|
</div>
|
|
307
307
|
|
|
308
|
+
<div class="set-hint set-hint-left">Where Clauddy lives: a floating pet, or a menu-bar icon with a pop-up.</div>
|
|
309
|
+
<label class="set-field">Display
|
|
310
|
+
<span class="seg" id="set-mode">
|
|
311
|
+
<button type="button" class="seg-btn" data-mode="floating">Floating pet</button>
|
|
312
|
+
<button type="button" class="seg-btn" data-mode="menubar">Menu bar</button>
|
|
313
|
+
</span>
|
|
314
|
+
</label>
|
|
315
|
+
|
|
308
316
|
<label class="set-check"><input id="set-alerts" type="checkbox" /> Alerts</label>
|
|
309
317
|
<div class="set-hint set-hint-left">A macOS notification when your session or weekly usage crosses each level.</div>
|
|
310
318
|
<label class="set-field">Alert thresholds (%)
|
package/renderer/pet.js
CHANGED
|
@@ -560,6 +560,7 @@ window.api.onError((msg) => {
|
|
|
560
560
|
})
|
|
561
561
|
window.api.onConfig((cfg) => {
|
|
562
562
|
currentConfig = cfg || {}
|
|
563
|
+
document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
|
|
563
564
|
})
|
|
564
565
|
window.api.onRealUsage((u) => {
|
|
565
566
|
realUsage = u || null
|
|
@@ -642,8 +643,16 @@ el('acc-logout').addEventListener('click', () => window.api.authLogout())
|
|
|
642
643
|
// settings panel
|
|
643
644
|
// snapshot of the editable fields, to tell whether there are unsaved changes
|
|
644
645
|
let settingsBaseline = null
|
|
646
|
+
let selectedMode = 'floating'
|
|
647
|
+
function setModeUI(mode) {
|
|
648
|
+
selectedMode = mode === 'menubar' ? 'menubar' : 'floating'
|
|
649
|
+
for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
|
|
650
|
+
b.classList.toggle('on', b.dataset.mode === selectedMode)
|
|
651
|
+
}
|
|
652
|
+
}
|
|
645
653
|
function snapshotSettings() {
|
|
646
654
|
return JSON.stringify({
|
|
655
|
+
mode: selectedMode,
|
|
647
656
|
alerts: el('set-alerts').checked,
|
|
648
657
|
t1: el('set-t1').value,
|
|
649
658
|
t2: el('set-t2').value,
|
|
@@ -660,6 +669,7 @@ function clearSaveDirty() {
|
|
|
660
669
|
}
|
|
661
670
|
function populateSettings() {
|
|
662
671
|
const c = currentConfig || {}
|
|
672
|
+
setModeUI(c.mode)
|
|
663
673
|
el('set-alerts').checked = c.alerts !== false
|
|
664
674
|
const th = c.alertThresholds || [80, 95]
|
|
665
675
|
el('set-t1').value = th[0] != null ? th[0] : 80
|
|
@@ -692,6 +702,13 @@ for (const id of ['set-alerts', 'set-t1', 'set-t2', 'set-fire']) {
|
|
|
692
702
|
el(id).addEventListener('input', refreshSaveDirty)
|
|
693
703
|
el(id).addEventListener('change', refreshSaveDirty)
|
|
694
704
|
}
|
|
705
|
+
// display-mode segmented control (floating pet | menu bar)
|
|
706
|
+
for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
|
|
707
|
+
b.addEventListener('click', () => {
|
|
708
|
+
setModeUI(b.dataset.mode)
|
|
709
|
+
refreshSaveDirty()
|
|
710
|
+
})
|
|
711
|
+
}
|
|
695
712
|
el('set-cancel').addEventListener('click', () => {
|
|
696
713
|
document.body.classList.remove('settings-open')
|
|
697
714
|
clearSaveDirty()
|
|
@@ -701,6 +718,7 @@ el('set-save').addEventListener('click', () => {
|
|
|
701
718
|
const num = (id) => parseFloat(el(id).value)
|
|
702
719
|
const fire = num('set-fire')
|
|
703
720
|
window.api.saveConfig({
|
|
721
|
+
mode: selectedMode,
|
|
704
722
|
alerts: el('set-alerts').checked,
|
|
705
723
|
alertThresholds: [num('set-t1'), num('set-t2')]
|
|
706
724
|
.filter((n) => n >= 1 && n <= 100)
|
package/renderer/style.css
CHANGED
|
@@ -238,6 +238,38 @@ body.settings-open #settings {
|
|
|
238
238
|
appearance: none;
|
|
239
239
|
margin: 0;
|
|
240
240
|
}
|
|
241
|
+
/* display-mode segmented control (floating pet | menu bar) */
|
|
242
|
+
.seg {
|
|
243
|
+
display: flex;
|
|
244
|
+
gap: 4px;
|
|
245
|
+
margin-top: 4px;
|
|
246
|
+
}
|
|
247
|
+
.seg-btn {
|
|
248
|
+
flex: 1;
|
|
249
|
+
padding: 6px 4px;
|
|
250
|
+
font-size: 11px;
|
|
251
|
+
color: var(--muted);
|
|
252
|
+
background: rgba(255, 255, 255, 0.06);
|
|
253
|
+
border: 0.5px solid var(--hair);
|
|
254
|
+
border-radius: 7px;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
transition:
|
|
257
|
+
background 0.15s,
|
|
258
|
+
color 0.15s,
|
|
259
|
+
border-color 0.15s;
|
|
260
|
+
}
|
|
261
|
+
.seg-btn:hover {
|
|
262
|
+
color: var(--text);
|
|
263
|
+
}
|
|
264
|
+
.seg-btn.on {
|
|
265
|
+
background: var(--coral);
|
|
266
|
+
border-color: var(--coral);
|
|
267
|
+
color: #fff;
|
|
268
|
+
}
|
|
269
|
+
/* menu-bar mode: the popover auto-sizes, so the minimize control is redundant */
|
|
270
|
+
body.is-menubar #min {
|
|
271
|
+
display: none;
|
|
272
|
+
}
|
|
241
273
|
.set-two {
|
|
242
274
|
display: flex;
|
|
243
275
|
gap: 6px;
|