clauddy 1.20.0 → 1.21.1

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
@@ -171,7 +171,7 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
171
171
  ## Controls
172
172
 
173
173
  - **Drag** the widget anywhere on screen
174
- - **–** minimizes to just the pet's face (showing the live session %); the **⤢** button or a double-click on the pet expands it back
174
+ - **–** minimizes to just the pet, ringed by the live session % (the number sits inside the ring); the **⤢** button or a double-click on the pet expands it back
175
175
  - **⚙** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
176
176
  - **↗** opens the official Usage page
177
177
  - **×** quits
package/accounts.js CHANGED
@@ -139,6 +139,32 @@ function remove(id) {
139
139
  return true
140
140
  }
141
141
 
142
+ // Folders left behind under `accounts/` by slots that are no longer listed —
143
+ // an add abandoned mid-login, mostly, since saving the alert state recreates
144
+ // the folder on its way out. Only ever drops a folder with no auth.json in it,
145
+ // so a token is never destroyed by a bad read of accounts.json.
146
+ function pruneOrphans() {
147
+ const root = path.join(BASE_DIR, 'accounts')
148
+ let dirs = []
149
+ try {
150
+ dirs = fs.readdirSync(root, { withFileTypes: true })
151
+ } catch {
152
+ return 0 // no extra accounts were ever added
153
+ }
154
+ const known = new Set(list().map((a) => a.id))
155
+ let n = 0
156
+ for (const d of dirs) {
157
+ if (!d.isDirectory() || known.has(d.name)) continue
158
+ const dir = path.join(root, d.name)
159
+ if (fs.existsSync(path.join(dir, 'auth.json'))) continue
160
+ try {
161
+ fs.rmSync(dir, { recursive: true, force: true })
162
+ n++
163
+ } catch {}
164
+ }
165
+ return n
166
+ }
167
+
142
168
  module.exports = {
143
169
  BASE_DIR,
144
170
  DEFAULT_ID,
@@ -151,4 +177,5 @@ module.exports = {
151
177
  label,
152
178
  setClaudeDir,
153
179
  remove,
180
+ pruneOrphans,
154
181
  }
package/main.js CHANGED
@@ -334,6 +334,7 @@ function createWindow() {
334
334
  if (fallback) accounts.setActive(fallback.id)
335
335
  }
336
336
  for (const a of accounts.list()) pruneEmpty(a.id)
337
+ accounts.pruneOrphans() // folders those slots left behind
337
338
  applyAccount(accounts.active())
338
339
  const { workAreaSize } = screen.getPrimaryDisplay()
339
340
  const H = 480
@@ -677,16 +678,32 @@ function startUsagePoll() {
677
678
  }
678
679
 
679
680
  // push the logged-in account's identity (email + plan) to the renderer
680
- async function sendProfile() {
681
+ let profileTimer = null
682
+ async function sendProfile(tries = 0) {
683
+ clearTimeout(profileTimer)
681
684
  if (!auth.isConnected()) return
685
+ // whose profile this is, decided *before* the await: a switch during the
686
+ // fetch would otherwise label the new account with the old one's email
687
+ const id = accounts.activeId()
682
688
  try {
683
689
  const p = await auth.fetchProfile()
690
+ if (id !== accounts.activeId()) return // switched under us — this is stale
684
691
  if (p?.email) {
685
- accounts.label(accounts.activeId(), p.email) // a better name than "acct-xyz"
692
+ accounts.label(id, p.email) // a better name than "acct-xyz"
686
693
  sendAccounts()
687
694
  }
688
695
  if (win && !win.isDestroyed()) win.webContents.send('profile', p)
689
- } catch {} // non-fatal: the chip just stays hidden
696
+ } catch {
697
+ // a rate limit or a blip would otherwise hide the chip — and with it the
698
+ // account switcher — until the app is restarted, so keep trying for a while
699
+ if (tries >= 4) return
700
+ profileTimer = setTimeout(
701
+ () => {
702
+ if (id === accounts.activeId()) sendProfile(tries + 1)
703
+ },
704
+ 30 * 1000 * 2 ** tries,
705
+ )
706
+ }
690
707
  }
691
708
 
692
709
  // begin() has to run *after* any account switch: switching resets the pending
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.20.0",
4
+ "version": "1.21.1",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
@@ -112,6 +112,12 @@
112
112
  <path class="dk-leaf" d="M198 103 q5 -12 3 -25 q-7 9 -3 25" />
113
113
  <path class="dk-leaf-c" d="M198 103 q0 -14 0 -27 q3 13 0 27" />
114
114
  </svg>
115
+ <!-- collapsed: a ring around the pet with the session % inside -->
116
+ <svg id="ring" viewBox="0 0 100 100" aria-hidden="true">
117
+ <circle class="ring-track" cx="50" cy="50" r="45" />
118
+ <circle id="ring-fill" cx="50" cy="50" r="45" />
119
+ </svg>
120
+ <span id="ring-pct">0%</span>
115
121
  <div id="shadow"></div>
116
122
  <div id="pet">
117
123
  <svg id="claude" viewBox="0 0 100 90" width="86" height="77">
package/renderer/pet.js CHANGED
@@ -455,6 +455,8 @@ function oneShot(cls, ms) {
455
455
  setTimeout(() => document.body.classList.remove(cls), ms)
456
456
  }
457
457
 
458
+ const RING_LEN = 2 * Math.PI * 45 // the collapsed ring's circumference (r=45)
459
+
458
460
  // session-reset celebration: jump + a colorful confetti burst
459
461
  const CONFETTI_COLORS = ['#ffd23f', '#ff5d86', '#7ec77d', '#6db3f2', '#e0805a', '#c89bff']
460
462
  function spawnConfetti(i) {
@@ -577,6 +579,13 @@ function render(d) {
577
579
  const mini = el('mini-pct')
578
580
  mini.textContent = liveOn ? `${Math.round(sessPct)}%` : '—'
579
581
  mini.classList.toggle('high', liveOn && sessPct >= 80)
582
+ // the collapsed ring: how much of the session is already spent
583
+ const rf = el('ring-fill')
584
+ rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sessPct, 100) / 100)}`
585
+ rf.classList.toggle('high', sessPct >= 80)
586
+ const rp = el('ring-pct')
587
+ rp.textContent = mini.textContent
588
+ rp.classList.toggle('high', liveOn && sessPct >= 80)
580
589
  const sf = el('session-fill')
581
590
  sf.style.width = `${sessPct}%`
582
591
  sf.classList.toggle('high', sessPct >= 80)
@@ -632,7 +641,7 @@ function fitSize() {
632
641
  requestAnimationFrame(() => {
633
642
  const collapsed = document.body.classList.contains('collapsed')
634
643
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
635
- const w = (collapsed ? 140 : 276) * zoom
644
+ const w = (collapsed ? 168 : 276) * zoom
636
645
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
637
646
  // empirically against this Electron build) — so measure the unzoomed content
638
647
  // height, then scale the whole thing (content + margin) ourselves
@@ -691,26 +700,38 @@ window.api.onAuthState((s) => {
691
700
  })
692
701
 
693
702
  // logged-in account chip (email + plan) shown top-left when connected
703
+ let lastProfile = null
694
704
  function showProfile(p) {
705
+ lastProfile = p
706
+ paintChip()
707
+ }
708
+
709
+ // The chip is also the account switcher, so it must not vanish just because the
710
+ // profile fetch failed — that would strand the user on one account until a
711
+ // restart. The label main stored for the active account is the same email, so
712
+ // it stands in until the profile lands.
713
+ function paintChip() {
714
+ const p = lastProfile
715
+ const a = lastAccounts
716
+ const active = (a?.accounts || []).find((x) => x.id === a?.active)
717
+ const email = p?.email || (active?.connected ? active.label : null)
695
718
  // the settings line says who is connected, not just that someone is
696
- el('acc-ok').textContent = p?.email
697
- ? `● ${p.email}${p.plan ? ` · ${p.plan}` : ''}`
698
- : '● Connected'
719
+ el('acc-ok').textContent = email ? `● ${email}${p?.plan ? ` · ${p.plan}` : ''}` : '● Connected'
699
720
  const chip = el('account-chip')
700
721
  const mini = el('mini-acct')
701
- if (!p?.email) {
722
+ if (!email) {
702
723
  chip.hidden = true
703
724
  mini.hidden = true
704
725
  return
705
726
  }
706
727
  // expanded: full email chip in the top bar
707
- el('ac-email').textContent = p.email
708
- chip.title = p.name ? `${p.name} · ${p.email}` : p.email
709
- setPlan(el('ac-plan'), p.plan)
728
+ el('ac-email').textContent = email
729
+ chip.title = p?.name ? `${p.name} · ${email}` : email
730
+ setPlan(el('ac-plan'), p?.plan)
710
731
  chip.hidden = false
711
732
  // collapsed: short name + plan in the mini block (email won't fit at 116px)
712
- el('mini-acct-name').textContent = p.name || p.email.split('@')[0]
713
- setPlan(el('mini-acct-plan'), p.plan)
733
+ el('mini-acct-name').textContent = p?.name || email.split('@')[0]
734
+ setPlan(el('mini-acct-plan'), p?.plan)
714
735
  mini.hidden = false
715
736
  }
716
737
  function setPlan(node, plan) {
@@ -756,6 +777,8 @@ function accountRow(acc, a) {
756
777
  function renderAccounts(a) {
757
778
  lastAccounts = a
758
779
  document.body.classList.toggle('one-account', (a?.accounts || []).length < 2)
780
+ paintChip() // the label may be all the chip has to go on
781
+
759
782
  if (el('acc-menu').hidden) return
760
783
  // the switch is done: the chip now names the account the menu was pointing at
761
784
  if (switching && !a?.busy) closeAccountMenu()
@@ -314,6 +314,19 @@ body.one-account .ac-caret {
314
314
  body.collapsed #account-chip {
315
315
  display: none;
316
316
  }
317
+ /* collapsed: the account rides up on the button line, like the chip does */
318
+ body.collapsed #mini-acct {
319
+ position: absolute;
320
+ top: 6px;
321
+ left: 10px;
322
+ z-index: 10;
323
+ height: 19px;
324
+ margin-bottom: 0;
325
+ justify-content: flex-start;
326
+ }
327
+ body.collapsed #mini-acct-name {
328
+ max-width: 62px;
329
+ }
317
330
 
318
331
  /* settings panel */
319
332
  #settings {
@@ -1076,7 +1089,7 @@ body.collapsed #mini {
1076
1089
  display: block;
1077
1090
  }
1078
1091
  body.collapsed #card {
1079
- width: 116px;
1092
+ width: 144px;
1080
1093
  padding: 12px 12px 13px;
1081
1094
  }
1082
1095
  body.collapsed #stage {
@@ -1148,7 +1161,7 @@ body.settings-open #pet {
1148
1161
 
1149
1162
  /* collapsed: just the face, on the warm card (like before) */
1150
1163
  body.collapsed #stage {
1151
- height: 98px;
1164
+ height: 120px;
1152
1165
  margin-top: 9px;
1153
1166
  background: transparent;
1154
1167
  box-shadow: none;
@@ -1171,19 +1184,95 @@ body.collapsed #gears,
1171
1184
  body.collapsed #glasses {
1172
1185
  display: none;
1173
1186
  }
1187
+ /* collapsed: the pet sits a bit high, so the % fits under it inside the ring */
1174
1188
  body.collapsed #pet {
1175
1189
  position: static;
1176
1190
  left: auto;
1177
1191
  bottom: auto;
1178
- zoom: 1;
1192
+ transform: translateY(-8px);
1193
+ zoom: 0.86;
1194
+ }
1195
+ /* the ring is a fixed frame, so the usual bob reads as jumping in there */
1196
+ body.collapsed #claude {
1197
+ animation: bob-mini 3.6s ease-in-out infinite;
1198
+ }
1199
+ @keyframes bob-mini {
1200
+ 0%,
1201
+ 100% {
1202
+ transform: translateY(0);
1203
+ }
1204
+ 50% {
1205
+ transform: translateY(-1.5px);
1206
+ }
1207
+ }
1208
+ /* the ring: session % drawn around the pet, only when there's a real % to show */
1209
+ #ring {
1210
+ display: none;
1211
+ }
1212
+ body.collapsed #ring {
1213
+ display: block;
1214
+ position: absolute;
1215
+ left: 50%;
1216
+ top: 50%;
1217
+ width: 118px;
1218
+ height: 118px;
1219
+ transform: translate(-50%, -50%);
1220
+ pointer-events: none;
1221
+ z-index: 1;
1222
+ }
1223
+ .ring-track {
1224
+ fill: none;
1225
+ stroke: var(--track);
1226
+ stroke-width: 3.5;
1227
+ }
1228
+ #ring-fill {
1229
+ fill: none;
1230
+ stroke: var(--green);
1231
+ stroke-width: 3.5;
1232
+ stroke-linecap: round;
1233
+ transform: rotate(-90deg);
1234
+ transform-origin: 50% 50%;
1235
+ stroke-dasharray: 282.7;
1236
+ stroke-dashoffset: 282.7; /* set from JS */
1237
+ transition:
1238
+ stroke-dashoffset 0.5s ease,
1239
+ stroke 0.3s ease;
1240
+ }
1241
+ #ring-fill.high {
1242
+ stroke: #e0553f;
1243
+ }
1244
+ #ring-pct {
1245
+ display: none;
1246
+ }
1247
+ body.collapsed #ring-pct {
1248
+ display: block;
1249
+ position: absolute;
1250
+ left: 0;
1251
+ right: 0;
1252
+ bottom: 12px;
1253
+ z-index: 3;
1254
+ text-align: center;
1255
+ font-size: 13px;
1256
+ font-weight: 700;
1257
+ color: var(--text);
1258
+ font-variant-numeric: tabular-nums;
1259
+ pointer-events: none;
1260
+ }
1261
+ #ring-pct.high {
1262
+ color: #e0553f;
1263
+ }
1264
+ /* the ring already says it — no need to repeat it under the pet */
1265
+ body.collapsed #mini-pct-row {
1266
+ display: none;
1179
1267
  }
1180
1268
  body.collapsed #shadow {
1181
1269
  display: block;
1182
1270
  position: absolute;
1183
- bottom: 2px;
1271
+ /* pinned under the pet's feet, which the ring lifted off the stage floor */
1272
+ top: calc(50% + 23px);
1184
1273
  left: 50%;
1185
- width: 60px;
1186
- height: 9px;
1274
+ width: 48px;
1275
+ height: 8px;
1187
1276
  background: radial-gradient(ellipse, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0));
1188
1277
  transform: translateX(-50%);
1189
1278
  }