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 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
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "_note": "Defaults bundled with the app. User settings are saved to ~/.claude-usage-monitor/config.json. Session/weekly % come from your account (log in via Settings).",
3
+ "mode": "floating",
3
4
  "alerts": true,
4
5
  "alertThresholds": [80, 95],
5
6
  "fireThreshold": 90,
package/main.js CHANGED
@@ -1,4 +1,14 @@
1
- const { app, BrowserWindow, ipcMain, screen, Notification, shell } = require('electron')
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
- const { workAreaSize } = screen.getPrimaryDisplay()
153
- win.setPosition(workAreaSize.width - width - 24, workAreaSize.height - height - 24)
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
- if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
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
- if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
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.8.0",
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",
@@ -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)
@@ -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;