clauddy 1.29.0 → 2.0.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/renderer/pet.js DELETED
@@ -1,2394 +0,0 @@
1
- const SVGNS = 'http://www.w3.org/2000/svg'
2
- const el = (id) => document.getElementById(id)
3
-
4
- // Claude pixel-art sprite
5
- const SPRITE = [
6
- '.########.',
7
- '.########.',
8
- '##########',
9
- '###o##o###',
10
- '##########',
11
- '.########.',
12
- '.########.',
13
- '.#.#..#.#.',
14
- '.#.#..#.#.',
15
- ]
16
-
17
- ;(function buildPixel() {
18
- const body = el('body')
19
- const eyes = el('eyes')
20
- const C = 10
21
- // Each pixel is its own <rect>, so neighbours share an edge. Under the mood
22
- // animations the sprite is scaled by fractions, that edge lands between device
23
- // pixels, and the card shows through as a hairline grid — crispEdges cannot
24
- // help, it rounds in local space, before the transform. So a cell is grown to
25
- // overlap the neighbour it actually has: same fill, invisible seam, and the
26
- // silhouette stays exact because edge cells are left alone.
27
- const SEAM = 0.5
28
- const filled = (r, c) => {
29
- const ch = SPRITE[r]?.[c]
30
- return ch !== undefined && ch !== '.'
31
- }
32
- SPRITE.forEach((row, r) => {
33
- for (let c = 0; c < row.length; c++) {
34
- const ch = row[c]
35
- if (ch === '.') continue
36
- const rect = document.createElementNS(SVGNS, 'rect')
37
- rect.setAttribute('x', c * C)
38
- rect.setAttribute('y', r * C)
39
- rect.setAttribute('width', C + (filled(r, c + 1) ? SEAM : 0))
40
- rect.setAttribute('height', C + (filled(r + 1, c) ? SEAM : 0))
41
- body.appendChild(rect)
42
- if (ch === 'o') {
43
- const eye = document.createElementNS(SVGNS, 'rect')
44
- eye.setAttribute('x', c * C)
45
- eye.setAttribute('y', r * C)
46
- eye.setAttribute('width', C)
47
- eye.setAttribute('height', C)
48
- eyes.appendChild(eye)
49
- }
50
- }
51
- })
52
- })()
53
-
54
- // ---- selectable pets (pets.js) ----------------------------------------------
55
- // The classic sprite stays in #claude; another pet is drawn inside it as #skin,
56
- // so every mood animation on #claude (sleep bob, slump, poke…) still applies.
57
- // Working swaps the shared hop for the pet's own move (pets.css).
58
- const SKIN_ID = 'skin'
59
- let shownPet = null
60
- let pickedPet = null // the Settings selection, previewed live until Save/Cancel
61
- function showPet(id) {
62
- if (id === shownPet) return
63
- shownPet = id
64
- el(SKIN_ID)?.remove()
65
- const b = document.body.classList
66
- for (const c of [...b]) if (c.startsWith('pet-move-')) b.remove(c)
67
- const pet = Pets.PETS.find((p) => p.id === id)
68
- el('claude').classList.toggle('skinned', !!pet)
69
- if (!pet) return
70
- el('claude').insertAdjacentHTML(
71
- 'beforeend',
72
- Pets.petMarkup(
73
- id,
74
- 'sk',
75
- `id="${SKIN_ID}" x="-8" y="-26" width="116" height="116" preserveAspectRatio="xMidYMax meet"`,
76
- ),
77
- )
78
- b.add(`pet-move-${pet.move}`)
79
- }
80
- const chosenPet = () => currentConfig.pet || Pets.DEFAULT_PET
81
- function refreshPet() {
82
- showPet(pickedPet ?? Pets.activePet(chosenPet()))
83
- }
84
- // holidays start and end at midnight, with the widget open
85
- setInterval(refreshPet, 60_000)
86
- // What the eyes follow: the sprite's eye group, or the pet's (its blink groups,
87
- // moved with `translate` so it composes with the blink's own transform). The
88
- // pet's viewBox is about twice the sprite's, so its units are half as big.
89
- function lookTargets() {
90
- const skin = el(SKIN_ID)
91
- return skin ? [...skin.querySelectorAll('.blink')] : [el('eyes')]
92
- }
93
- function lookAt(x, y) {
94
- const skin = el(SKIN_ID)
95
- if (!skin) return el('eyes').setAttribute('transform', `translate(${x} ${y})`)
96
- for (const g of lookTargets()) g.style.translate = `${x * 2}px ${y * 2}px`
97
- }
98
- // night scene backdrop (clouds, crescent moon, stars, dotted ground/sky)
99
- ;(function buildScene() {
100
- const s = el('scene')
101
- if (!s) return
102
- const add = (tag, attrs) => {
103
- const e = document.createElementNS(SVGNS, tag)
104
- for (const k in attrs) e.setAttribute(k, attrs[k])
105
- s.appendChild(e)
106
- }
107
- // dotted top + bottom (sky + ground)
108
- for (let x = 4; x <= 212; x += 9) {
109
- add('circle', { cx: x, cy: 6, r: 1.3, fill: '#fff', 'fill-opacity': 0.85 })
110
- add('circle', { cx: x, cy: 130, r: 1.3, fill: '#fff', 'fill-opacity': 0.85 })
111
- }
112
- // clouds (blocky, dark gray)
113
- const cloud = (x, y, b, m, t) => {
114
- add('rect', { x: x + 12, y: y, width: t, height: 9, fill: '#3a3a3a', class: 'cloud' })
115
- add('rect', { x: x + 5, y: y + 7, width: m, height: 9, fill: '#3a3a3a', class: 'cloud' })
116
- add('rect', { x: x, y: y + 14, width: b, height: 10, fill: '#3a3a3a', class: 'cloud' })
117
- }
118
- cloud(16, 10, 58, 42, 22)
119
- cloud(120, 50, 40, 28, 15)
120
- // stars (small plus)
121
- const star = (x, y) => {
122
- add('rect', { x: x - 3, y: y - 0.7, width: 6, height: 1.4, fill: '#d8d8d8' })
123
- add('rect', { x: x - 0.7, y: y - 3, width: 1.4, height: 6, fill: '#d8d8d8' })
124
- }
125
- ;[
126
- [100, 16],
127
- [60, 22],
128
- [150, 100],
129
- [196, 56],
130
- [205, 98],
131
- [128, 26],
132
- [182, 22],
133
- [202, 14],
134
- ].forEach(([x, y]) => {
135
- star(x, y)
136
- })
137
- })()
138
-
139
- // reading mode: the doc's filename slowly cycles through project docs, since
140
- // it's a Claude app "reading" different files (a gentle fade between names)
141
- ;(function cycleDocName() {
142
- const node = el('dc-fname')
143
- if (!node) return
144
- const names = [
145
- 'README.md',
146
- 'ARCHITECTURE.md',
147
- 'api.md',
148
- 'AUTH.md',
149
- 'MIGRATION.md',
150
- 'CONTRIBUTING.md',
151
- 'usage.md',
152
- 'config.md',
153
- ]
154
- let i = 0
155
- setInterval(() => {
156
- node.style.opacity = '0'
157
- setTimeout(() => {
158
- i = (i + 1) % names.length
159
- node.textContent = names[i]
160
- node.style.opacity = '1'
161
- }, 260)
162
- }, 3800)
163
- })()
164
-
165
- // helpers
166
- // labels come from log fields and directory names — neither is ours to trust
167
- function esc(s) {
168
- return String(s).replace(
169
- /[&<>"']/g,
170
- (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
171
- )
172
- }
173
-
174
- function fmtTokens(t) {
175
- t = t || 0
176
- if (t >= 1e9) return `${(t / 1e9).toFixed(2)}B`
177
- if (t >= 1e6) return `${(t / 1e6).toFixed(1)}M`
178
- if (t >= 1e3) return `${(t / 1e3).toFixed(1)}k`
179
- return String(t)
180
- }
181
- function fmtReset(ms) {
182
- if (!ms || ms <= 0) return 'now'
183
- // past a day, hours are the useful grain: "6d 16h", not "160h 57m"
184
- if (ms >= 86400000)
185
- return `${Math.floor(ms / 86400000)}d ${Math.floor((ms % 86400000) / 3600000)}h`
186
- const h = Math.floor(ms / 3600000)
187
- const m = Math.floor((ms % 3600000) / 60000)
188
- return h > 0 ? `${h}h ${m}m` : `${m}m`
189
- }
190
- // wall-clock time of the reset, in the machine's own locale + timezone.
191
- // Past 24h the day matters too, so prefix the weekday.
192
- function fmtResetClock(ms) {
193
- if (!ms || ms <= 0) return null
194
- const at = new Date(Date.now() + ms)
195
- // a week or more out (Cursor's month), a weekday would be ambiguous
196
- if (ms >= 6 * 86400000) return at.toLocaleDateString([], { month: 'short', day: 'numeric' })
197
- const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
198
- return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
199
- }
200
- // "2h 22m (14:35)" — countdown plus the clock time it lands on
201
- function fmtResetIn(ms) {
202
- const at = fmtResetClock(ms)
203
- return at ? `${fmtReset(ms)} (${at})` : fmtReset(ms)
204
- }
205
- // burn-rate projection lives in burn.js (shared with the tests)
206
- const burn = Burn.createBurnTracker()
207
-
208
- function setState(name) {
209
- const b = document.body
210
- ;[...b.classList].forEach((c) => {
211
- if (c.startsWith('state-')) b.classList.remove(c)
212
- })
213
- b.classList.add(`state-${name}`)
214
- }
215
-
216
- // coins (Claude "eating" tokens) — arc in, spin, get gulped with a crumb pop
217
- const W = 86
218
- const H = 77
219
- const MOUTH_X = 43
220
- const MOUTH_Y = 46
221
-
222
- function spawnCoin() {
223
- const zone = el('dropzone')
224
- const coin = document.createElement('div')
225
- coin.className = 'coin'
226
- const sz = 6 + Math.random() * 3
227
- coin.style.width = coin.style.height = `${sz.toFixed(1)}px`
228
-
229
- const side = Math.floor(Math.random() * 4)
230
- let x, y
231
- if (side === 0) {
232
- x = Math.random() * W
233
- y = -10
234
- } else if (side === 1) {
235
- x = W + 10
236
- y = Math.random() * H * 0.7
237
- } else if (side === 2) {
238
- x = Math.random() * W
239
- y = H + 10
240
- } else {
241
- x = -10
242
- y = Math.random() * H * 0.7
243
- }
244
- coin.style.left = `${x}px`
245
- coin.style.top = `${y}px`
246
- zone.appendChild(coin)
247
-
248
- const dx = MOUTH_X - x
249
- const dy = MOUTH_Y - y
250
- // perpendicular offset -> curved arc toward the mouth
251
- const mxo = dx * 0.5 - dy * 0.18
252
- const myo = dy * 0.5 + dx * 0.18
253
- const rot = (Math.random() * 2 - 1) * 320
254
- const anim = coin.animate(
255
- [
256
- { transform: 'translate(0,0) scale(0.7) rotate(0deg)', opacity: 0.95 },
257
- {
258
- transform: `translate(${mxo}px,${myo}px) scale(1) rotate(${(rot * 0.6).toFixed(0)}deg)`,
259
- opacity: 1,
260
- offset: 0.55,
261
- },
262
- {
263
- transform: `translate(${dx}px,${dy}px) scale(0.2) rotate(${rot.toFixed(0)}deg)`,
264
- opacity: 0,
265
- },
266
- ],
267
- { duration: 780 + Math.random() * 320, easing: 'cubic-bezier(0.45,0,0.55,1)' },
268
- )
269
- anim.onfinish = () => {
270
- coin.remove()
271
- popCrumbs()
272
- chomp() // mouth opens to eat it
273
- nibble() // tiny gulp reaction
274
- }
275
- }
276
-
277
- // the mouth opens and snaps shut on each token
278
- function chomp() {
279
- el('mouth').animate(
280
- [
281
- { transform: 'scaleY(0.1)' },
282
- { transform: 'scaleY(1)', offset: 0.4 },
283
- { transform: 'scaleY(0.1)' },
284
- ],
285
- { duration: 240, easing: 'ease-in-out' },
286
- )
287
- }
288
-
289
- // quick squash of the whole pet on each gulp (doesn't fight the hop on #claude)
290
- function nibble() {
291
- el('pet').animate(
292
- [
293
- { transform: 'scale(1, 1)' },
294
- { transform: 'scale(1.06, 0.94)', offset: 0.5 },
295
- { transform: 'scale(1, 1)' },
296
- ],
297
- { duration: 200, easing: 'ease' },
298
- )
299
- }
300
-
301
- // poke reaction: bouncy squish + little hearts floating up
302
- function popHearts() {
303
- const zone = el('dropzone')
304
- const n = 5
305
- for (let i = 0; i < n; i++) {
306
- const h = document.createElement('div')
307
- h.className = 'heart'
308
- h.textContent = '♥'
309
- // spread across lanes along the width + a little jitter
310
- const baseX = 14 + i * 15 + (Math.random() * 6 - 3)
311
- h.style.left = `${baseX.toFixed(0)}px`
312
- h.style.top = `${(14 + Math.random() * 12).toFixed(0)}px`
313
- zone.appendChild(h)
314
- // fan outward from the center
315
- const dx = (baseX - 43) * 0.55 + (Math.random() * 8 - 4)
316
- const a = h.animate(
317
- [
318
- { transform: 'translate(0, 8px) scale(0.4)', opacity: 0 },
319
- {
320
- transform: `translate(${(dx * 0.5).toFixed(1)}px, -12px) scale(1.3)`,
321
- opacity: 1,
322
- offset: 0.3,
323
- },
324
- { transform: `translate(${dx.toFixed(1)}px, -48px) scale(0.85)`, opacity: 0 },
325
- ],
326
- { duration: 1100 + Math.random() * 350, easing: 'ease-out', delay: i * 120 },
327
- )
328
- a.onfinish = () => h.remove()
329
- }
330
- }
331
- function pokePet() {
332
- oneShot('poke', 850)
333
- popHearts()
334
- }
335
-
336
- function popCrumbs() {
337
- const zone = el('dropzone')
338
- for (let i = 0; i < 3; i++) {
339
- const c = document.createElement('div')
340
- c.className = 'crumb'
341
- c.style.left = `${MOUTH_X}px`
342
- c.style.top = `${MOUTH_Y}px`
343
- zone.appendChild(c)
344
- const ang = Math.random() * Math.PI * 2
345
- const d = 5 + Math.random() * 8
346
- const a = c.animate(
347
- [
348
- { transform: 'translate(0,0) scale(1)', opacity: 0.9 },
349
- {
350
- transform: `translate(${(Math.cos(ang) * d).toFixed(1)}px, ${(Math.sin(ang) * d - 4).toFixed(1)}px) scale(0.2)`,
351
- opacity: 0,
352
- },
353
- ],
354
- { duration: 240 + Math.random() * 160, easing: 'ease-out' },
355
- )
356
- a.onfinish = () => c.remove()
357
- }
358
- }
359
-
360
- // continuous stream while working; faster when burning more tokens/min
361
- let eatTimer = null
362
- let eating = false
363
- let currentRate = 0
364
- function eatInterval() {
365
- return Math.max(750, 1700 - Math.min(850, currentRate / 2600))
366
- }
367
- function startEating() {
368
- stopEating()
369
- const loop = () => {
370
- spawnCoin()
371
- eatTimer = setTimeout(loop, eatInterval())
372
- }
373
- loop()
374
- }
375
- function stopEating() {
376
- if (eatTimer) clearTimeout(eatTimer)
377
- eatTimer = null
378
- }
379
-
380
- // 30-day map
381
- function renderHeat(days) {
382
- const row = el('heat-row')
383
- row.innerHTML = ''
384
- const max = Math.max(1, ...days)
385
- days.forEach((v, i) => {
386
- const sq = document.createElement('div')
387
- let lv = 0
388
- if (v > 0) {
389
- const r = v / max
390
- lv = r < 0.3 ? 1 : r < 0.6 ? 2 : r < 0.85 ? 3 : 4
391
- }
392
- sq.className = `sq${lv ? ` lv${lv}` : ''}`
393
- const daysAgo = days.length - 1 - i
394
- sq.title = `${daysAgo === 0 ? 'today' : `${daysAgo}d ago`} · ${fmtTokens(v)} tokens`
395
- row.appendChild(sq)
396
- })
397
- }
398
-
399
- // ranked bar list — shared by the model and project panels
400
- //
401
- // The name column is one width for the whole list, never per row: the bars are
402
- // only comparable if every track starts and ends at the same x. So it is sized
403
- // to the widest label actually present, clamped so a long path cannot squeeze
404
- // the bars into stubs, and anything past the clamp is clipped with an ellipsis.
405
-
406
- function renderBars(boxId, list, limit) {
407
- const box = el(boxId)
408
- box.innerHTML = ''
409
- const top = list.slice(0, limit)
410
- const max = Math.max(1, ...top.map((m) => m.tokens))
411
- for (const m of top) {
412
- const row = document.createElement('div')
413
- row.className = 'mrow'
414
- row.innerHTML =
415
- `<span class="mname">${esc(m.label)}</span>` +
416
- `<span class="mbar"><i style="width:${(m.tokens / max) * 100}%"></i></span>` +
417
- `<span class="mval">${fmtTokens(m.tokens)}</span>`
418
- // the column can clip, so keep the full label reachable
419
- row.firstChild.title = m.label
420
- box.appendChild(row)
421
- }
422
- if (!top.length) {
423
- box.innerHTML = '<div class="mrow" style="opacity:.5">no activity</div>'
424
- return
425
- }
426
- }
427
-
428
- // measure the labels unconstrained, then lock the column to the widest one
429
-
430
- // per-model weekly limits (e.g. "Fable" on Max) — one meter each, in the same
431
- // shape as the all-models one. The token count pairs the limit with the local
432
- // log totals for that model family (a "Fable" limit covers every Fable row).
433
- function renderScoped(list, byModel) {
434
- const box = el('scoped-meters')
435
- box.innerHTML = ''
436
- for (const s of list) {
437
- const key = s.label.toLowerCase()
438
- const tokens = byModel
439
- .filter((m) => m.label.toLowerCase().startsWith(key))
440
- .reduce((n, m) => n + m.tokens, 0)
441
- const m = document.createElement('div')
442
- m.className = 'meter'
443
- m.innerHTML =
444
- `<div class="meter-top"><span>weekly · ${esc(key)}</span><span class="${levelOf(s.pct)}">${Math.round(s.pct)}%</span></div>` +
445
- `<div class="track"><div class="fill week${s.pct >= 80 ? ' high' : ''} ${levelOf(s.pct)}" style="width:${s.pct}%"></div></div>` +
446
- `<div class="sub">${s.resetMs != null ? `resets in ${fmtResetIn(s.resetMs)} · ` : ''}${fmtTokens(tokens)} tokens</div>`
447
- box.appendChild(m)
448
- }
449
- }
450
-
451
- // One ramp for every meter: plain below 60, warm to 85, hot past it. The number
452
- // and its bar always read the same level.
453
- const levelOf = (pct) => (pct >= 85 ? 'hot' : pct >= 60 ? 'mid' : '')
454
- function setLevel(node, pct) {
455
- node.classList.toggle('mid', levelOf(pct) === 'mid')
456
- node.classList.toggle('hot', levelOf(pct) === 'hot')
457
- }
458
-
459
- // by model (7 days)
460
- function renderModels(list) {
461
- renderBars('bymodel-list', list, 4)
462
- }
463
-
464
- // by project (7 days) — usage.js already folds everything past the top few
465
- // into a single `other` row, so whatever arrives here is meant to be drawn
466
- function renderProjects(list) {
467
- renderBars('byproject-list', list, 6)
468
- }
469
-
470
- // one-shot reaction (adds a class, removes after ms)
471
- function oneShot(cls, ms) {
472
- document.body.classList.add(cls)
473
- setTimeout(() => document.body.classList.remove(cls), ms)
474
- }
475
-
476
- const RING_LEN = 2 * Math.PI * 45 // the collapsed ring's circumference (r=45)
477
-
478
- // session-reset celebration: jump + a colorful confetti burst
479
- const CONFETTI_COLORS = ['#ffd23f', '#ff5d86', '#7ec77d', '#6db3f2', '#e0805a', '#c89bff']
480
- function spawnConfetti(i) {
481
- const zone = el('dropzone')
482
- const p = document.createElement('div')
483
- p.className = 'confetti'
484
- p.style.background = CONFETTI_COLORS[i % CONFETTI_COLORS.length]
485
- const w = 4 + Math.random() * 4
486
- p.style.width = `${w.toFixed(1)}px`
487
- p.style.height = `${(w + 2 + Math.random() * 4).toFixed(1)}px`
488
- p.style.left = '43px'
489
- p.style.top = '38px'
490
- zone.appendChild(p)
491
- // launch up + outward, then fall back down with a tumble
492
- const ang = -Math.PI / 2 + (Math.random() * 2 - 1) * 1.15
493
- const speed = 34 + Math.random() * 36
494
- const ux = Math.cos(ang) * speed
495
- const uy = Math.sin(ang) * speed // negative = upward
496
- const fallY = 50 + Math.random() * 40
497
- const rot = (Math.random() * 2 - 1) * 600
498
- const a = p.animate(
499
- [
500
- { transform: 'translate(0,0) rotate(0) scale(0.5)', opacity: 1 },
501
- {
502
- transform: `translate(${ux.toFixed(0)}px, ${uy.toFixed(0)}px) rotate(${(rot * 0.4).toFixed(0)}deg) scale(1)`,
503
- opacity: 1,
504
- offset: 0.4,
505
- },
506
- {
507
- transform: `translate(${(ux * 1.4).toFixed(0)}px, ${fallY.toFixed(0)}px) rotate(${rot.toFixed(0)}deg) scale(0.9)`,
508
- opacity: 0,
509
- },
510
- ],
511
- { duration: 1150 + Math.random() * 550, easing: 'cubic-bezier(0.25, 0.7, 0.4, 1)' },
512
- )
513
- a.onfinish = () => p.remove()
514
- }
515
- function celebrate() {
516
- oneShot('celebrate', 1300)
517
- for (let i = 0; i < 24; i++) spawnConfetti(i)
518
- }
519
-
520
- // ---- voice ------------------------------------------------------------------
521
- // The bubble speaks on transitions only, at most once per REMARK_GAP_MS. The
522
- // headline moments (the day's first hello, a fresh window, catching fire) may
523
- // jump that queue, and only they blip: a noise on every remark wears out far
524
- // faster than the remark itself.
525
- const REMARK_GAP_MS = 10 * 60000
526
- const MUTE_MS = 60 * 60000
527
- const blipper = Voice.createBlipper(() => {
528
- const AC = globalThis.AudioContext || globalThis.webkitAudioContext
529
- return AC ? new AC() : null
530
- })
531
- let lastRemarkAt = 0
532
- let bubbleTimer = null
533
-
534
- const talkOn = () => currentConfig.talk !== false
535
- const mutedNow = () => currentConfig.soundMutedUntil > Date.now()
536
- // opt-in, and never where it would surprise someone: the menu-bar popover, the
537
- // mini face, or the hour after a mute
538
- function soundOn() {
539
- if (!currentConfig.sound || currentConfig.mode === 'menubar') return false
540
- if (document.body.classList.contains('collapsed')) return false
541
- return !mutedNow()
542
- }
543
-
544
- function moodOf(st) {
545
- return st === 'stressed' ? 'fire' : st === 'sleeping' || st === 'tired' ? 'sleepy' : 'normal'
546
- }
547
-
548
- // `line` is a remark from voice.js ({ text, short }) or a plain string. The
549
- // mini face has room for a glance, not a sentence, so it gets the short one.
550
- // `force` is the simulator's: it skips the gates but still respects the mute
551
- function say(line, { headline = false, mood = 'normal', force = false } = {}) {
552
- if (!line || document.body.classList.contains('pet-only')) return false
553
- const collapsed = document.body.classList.contains('collapsed')
554
- const text = typeof line === 'string' ? line : collapsed ? line.short : line.text
555
- if (!force) {
556
- const b = document.body.classList
557
- if (!talkOn() || b.contains('settings-open')) return false
558
- if (!headline && Date.now() - lastRemarkAt < REMARK_GAP_MS) return false
559
- }
560
- lastRemarkAt = Date.now()
561
- const bubble = el('bubble')
562
- el('bubble-text').textContent = text
563
- bubble.classList.remove('leaving')
564
- placeBubble()
565
- bubble.hidden = false
566
- fitSize() // collapsed, the bubble is in the flow: the window grows to fit it
567
- clearTimeout(bubbleTimer)
568
- bubbleTimer = setTimeout(hideBubble, Math.max(4500, text.length * 70 + 2500))
569
- // the mouth moves along, whether or not there's sound
570
- for (let i = 0; i < Math.min(4, Math.ceil(text.length / 18)); i++) setTimeout(chomp, i * 260)
571
- if ((headline || force) && soundOn()) blipper.play(text, mood)
572
- return true
573
- }
574
-
575
- // expanded, the bubble floats over the scene beside the pet; collapsed there's
576
- // no room beside it, so it moves above the pet, into the card's flow
577
- function placeBubble() {
578
- const bubble = el('bubble')
579
- const stage = el('stage')
580
- if (document.body.classList.contains('collapsed')) {
581
- if (bubble.nextElementSibling !== stage) stage.before(bubble)
582
- } else if (bubble.parentElement !== stage) stage.appendChild(bubble)
583
- }
584
-
585
- function hideBubble() {
586
- clearTimeout(bubbleTimer)
587
- const bubble = el('bubble')
588
- if (bubble.hidden) return
589
- bubble.classList.add('leaving')
590
- bubbleTimer = setTimeout(() => {
591
- bubble.hidden = true
592
- bubble.classList.remove('leaving')
593
- fitSize()
594
- }, 180)
595
- }
596
- el('bubble').addEventListener('click', (e) => {
597
- e.stopPropagation()
598
- hideBubble()
599
- })
600
-
601
- // once-a-day remarks, keyed by local date; memory is the fallback when storage
602
- // isn't there, so a broken profile can't make the pet repeat itself
603
- const toldMemo = {}
604
- const dayKey = () => new Date().toDateString()
605
- function toldToday(key) {
606
- try {
607
- if (localStorage.getItem(key) === dayKey()) return true
608
- } catch {}
609
- return toldMemo[key] === dayKey()
610
- }
611
- function markToday(key) {
612
- toldMemo[key] = dayKey()
613
- try {
614
- localStorage.setItem(key, dayKey())
615
- } catch {}
616
- }
617
-
618
- // what the pet has noticed so far — the transitions are all relative to it
619
- const AWAY_MS = 2 * 3600000
620
- const STREAK_MS = 90 * 60000
621
- const STREAK_GAP_MS = 10 * 60000
622
- let greeted = false
623
- let heardConfig = false
624
- let lastLivePct = null // Claude's session %, null until a live reading lands
625
- let awayMs = 0
626
- let streakFrom = 0
627
- let lastWorkAt = 0
628
- let streakTold = false
629
-
630
- // what the current Claude window has been like, for the recap when it closes
631
- // (#31). The peak is the session's tokens just before the rollover — after it
632
- // the counter starts over. Only the time the pet actually watched counts.
633
- const TALLY_GAP_MS = 15000 // a longer gap is the machine asleep, not work
634
- let tally = { peak: 0, activeMs: 0, acts: {}, at: 0 }
635
- function noteSession(d) {
636
- const now = Date.now()
637
- const dt = tally.at ? Math.min(now - tally.at, TALLY_GAP_MS) : 0
638
- tally.at = now
639
- if (d.active) {
640
- tally.activeMs += dt
641
- if (d.activity) tally.acts[d.activity] = (tally.acts[d.activity] || 0) + dt
642
- }
643
- tally.peak = Math.max(tally.peak, d.session?.tokens || 0)
644
- }
645
- function takeRecap(was) {
646
- const top = Object.entries(tally.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || null
647
- const r = { was, tokens: tally.peak, activeMs: tally.activeMs, top }
648
- tally = { peak: 0, activeMs: 0, acts: {}, at: tally.at }
649
- return r
650
- }
651
-
652
- // Codex and Cursor, followed whichever tab is on screen. Codex's logs only move
653
- // while it runs, so a stale reading keeps the baseline: the next fresh one is
654
- // compared against the last number actually seen.
655
- const lastExtPct = { codex: null, cursor: null }
656
- function listenExt(p) {
657
- const c = dataOf(p)
658
- if (!isOn(p) || !c) {
659
- lastExtPct[p] = null
660
- return
661
- }
662
- const pct = c.session?.pct
663
- if (pct == null || readingStale(c)) return
664
- const was = lastExtPct[p]
665
- lastExtPct[p] = pct
666
- if (was == null) return
667
- const fireAt = currentConfig.fireThreshold ?? 90
668
- const reset = c.session.resetMs
669
- const name = NAMES[p]
670
- if (was - pct > 25)
671
- say(Voice.codexResetLine(was, name, p === 'cursor' ? 'month' : 'window'), { headline: true })
672
- else if (was < 100 && pct >= 100)
673
- say(Voice.codexMaxedLine(fmtResetClock(reset), name), { mood: 'sleepy' })
674
- else if (was < fireAt && pct >= fireAt && pct < 100)
675
- say(Voice.codexFireLine(pct, reset, fmtReset, name), { headline: true, mood: 'fire' })
676
- }
677
-
678
- // called by paint() with the state it just drew. The Claude remarks follow the
679
- // Claude view; Codex and Cursor have their own, above.
680
- function listen(before, st, { isExt, liveOn, sp, sessReset, proj }) {
681
- // the saved config decides whether it talks at all, and it lands just after
682
- // the first usage tick: speaking before it would ignore a "talk: false"
683
- if (!heardConfig) return
684
- const d = lastData
685
- const mood = moodOf(st)
686
- if (!greeted) {
687
- greeted = true
688
- if (!toldToday('clauddy.greeted')) {
689
- markToday('clauddy.greeted')
690
- const line = Voice.greetingLine(d.days30, new Date().getHours(), fmtTokens)
691
- if (say(line, { headline: true, mood })) return
692
- }
693
- }
694
- listenExt('codex')
695
- listenExt('cursor')
696
- if (isExt) return
697
- const now = Date.now()
698
-
699
- if (liveOn) {
700
- const fireAt = currentConfig.fireThreshold ?? 90
701
- const was = lastLivePct
702
- lastLivePct = sp
703
- // a first reading is a baseline: launching at 95% is not "catching fire"
704
- if (was != null && was < 100 && sp >= 100) {
705
- say(Voice.maxedLine(fmtResetClock(sessReset)), { mood: 'sleepy' })
706
- } else if (was != null && was < fireAt && sp >= fireAt && sp < 100) {
707
- const eta = proj?.kind === 'eta' ? proj.ms : null
708
- say(Voice.fireLine(sp, eta, sessReset, fmtReset), { headline: true, mood: 'fire' })
709
- }
710
- } else {
711
- lastLivePct = null // logged out, or switching: the next reading starts over
712
- }
713
-
714
- if (st === 'sleeping' && Number.isFinite(d.lastActivityMs))
715
- awayMs = Math.max(awayMs, d.lastActivityMs)
716
- else if (before === 'sleeping') {
717
- if (awayMs >= AWAY_MS) say(Voice.welcomeLine(awayMs, liveOn ? sp : null, fmtReset))
718
- awayMs = 0
719
- }
720
-
721
- if (st === 'working') {
722
- if (!streakFrom || now - lastWorkAt > STREAK_GAP_MS) {
723
- streakFrom = now
724
- streakTold = false
725
- }
726
- lastWorkAt = now
727
- if (!streakTold && now - streakFrom >= STREAK_MS)
728
- streakTold = say(Voice.streakLine(now - streakFrom, fmtReset))
729
- }
730
-
731
- const record = Voice.recordLine(d.days30, fmtTokens)
732
- if (record && !toldToday('clauddy.record') && say(record, { mood })) markToday('clauddy.record')
733
- }
734
-
735
- // `./pet say <kind>` previews one remark — real numbers where there are some,
736
- // plausible ones where the moment hasn't happened
737
- function sampleLine(kind) {
738
- const days = lastData?.days30?.length ? [...lastData.days30] : new Array(30).fill(0)
739
- const pct = realUsage?.session?.pct
740
- const reset = realUsage?.session?.resetMs ?? 72 * 60000
741
- if (kind === 'fire') return Voice.fireLine(Math.max(pct ?? 0, 91), 40 * 60000, reset, fmtReset)
742
- if (kind === 'reset') {
743
- const t = tally.peak ? tally : { peak: 34e6, activeMs: 4 * 3600000 + 12 * 60000, acts: {} }
744
- const top = Object.entries(t.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'editing'
745
- return Voice.recapLine(
746
- { was: 92, tokens: t.peak, activeMs: t.activeMs, top },
747
- fmtTokens,
748
- fmtReset,
749
- )
750
- }
751
- if (kind === 'codex')
752
- return Voice.codexFireLine(91, codexData?.session?.resetMs ?? reset, fmtReset)
753
- if (kind === 'cursor')
754
- return Voice.codexFireLine(91, cursorData?.session?.resetMs ?? reset, fmtReset, 'Cursor')
755
- if (kind === 'maxed') return Voice.maxedLine(fmtResetClock(reset))
756
- if (kind === 'welcome') return Voice.welcomeLine(3 * 3600000 + 12 * 60000, pct ?? null, fmtReset)
757
- if (kind === 'streak') return Voice.streakLine(95 * 60000, fmtReset)
758
- if (kind === 'record') {
759
- days[days.length - 1] = Math.max(2e6, Math.max(...days.slice(0, -1)) * 1.15)
760
- return Voice.recordLine(days, fmtTokens)
761
- }
762
- if (!days[days.length - 2]) days[days.length - 2] = 12e6 // no yesterday yet: pretend one
763
- return Voice.greetingLine(days, new Date().getHours(), fmtTokens)
764
- }
765
- function sayDebug(kind) {
766
- const st = [...document.body.classList].find((c) => c.startsWith('state-'))?.slice(6)
767
- const mood =
768
- kind === 'fire' || kind === 'codex' || kind === 'cursor'
769
- ? 'fire'
770
- : kind === 'maxed'
771
- ? 'sleepy'
772
- : moodOf(st)
773
- return say(sampleLine(kind), { force: true, mood })
774
- }
775
-
776
- // mute sits in the title bar while the voice is on: one click before a call
777
- let unmuteTimer = null
778
- function paintMute() {
779
- const btn = el('mute')
780
- // menu-bar mode is always silent, so there is nothing there to mute
781
- btn.hidden = !(currentConfig.sound && talkOn()) || currentConfig.mode === 'menubar'
782
- document.body.classList.toggle('has-mute', !btn.hidden) // the chip makes room for it
783
- const muted = mutedNow()
784
- btn.classList.toggle('muted', muted)
785
- btn.title = muted
786
- ? `Muted until ${new Date(currentConfig.soundMutedUntil).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} — click to unmute`
787
- : 'Mute the voice for 1 hour'
788
- clearTimeout(unmuteTimer)
789
- if (muted) unmuteTimer = setTimeout(paintMute, currentConfig.soundMutedUntil - Date.now() + 50)
790
- }
791
- el('mute').addEventListener('click', (e) => {
792
- e.stopPropagation()
793
- const until = mutedNow() ? 0 : Date.now() + MUTE_MS
794
- currentConfig = { ...currentConfig, soundMutedUntil: until } // repaint now, main confirms
795
- paintMute()
796
- window.api.saveConfig({ soundMutedUntil: until })
797
- })
798
-
799
- // ---- services ---------------------------------------------------------------
800
- // Claude, Codex and Cursor are monitored side by side, but the panel shows one
801
- // at a time: tabs own the chip, meters, breakdowns and Usage arrow. The expanded
802
- // scene follows that view; compact pets follow activity across every service.
803
- const PROVIDER_KEY = 'clauddy.provider'
804
- const PROVIDERS = ['claude', 'codex', 'cursor']
805
- const NAMES = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor' }
806
- let codexData = null // last Codex payload; null while Codex isn't enabled
807
- let cursorData = null // last Cursor payload; null while Cursor isn't enabled
808
- let pickedProvider = (() => {
809
- try {
810
- const p = localStorage.getItem(PROVIDER_KEY)
811
- return PROVIDERS.includes(p) ? p : 'claude'
812
- } catch {
813
- return 'claude'
814
- }
815
- })()
816
-
817
- const claudeOn = () => document.body.classList.contains('auth-on')
818
- const codexOn = () => !!currentConfig.codex
819
- const cursorOn = () => !!currentConfig.cursor
820
- const isOn = (p) => (p === 'claude' ? claudeOn() : p === 'codex' ? codexOn() : cursorOn())
821
- const connected = () => PROVIDERS.filter(isOn)
822
- // Codex and Cursor payloads; Claude's live in lastData + realUsage
823
- const dataOf = (p) => (p === 'codex' ? codexData : p === 'cursor' ? cursorData : null)
824
- // each service's two meters: the window that resets, and the one beside it
825
- const sessionOf = (p) => (p === 'claude' ? realUsage?.session : dataOf(p)?.session)
826
- const secondOf = (p) =>
827
- p === 'claude' ? realUsage?.week : p === 'codex' ? codexData?.weekly : cursorData?.api
828
- // the service on screen: the pick, unless that one isn't there any more
829
- function viewProvider() {
830
- const on = connected()
831
- return on.includes(pickedProvider) ? pickedProvider : on[0] || 'claude'
832
- }
833
-
834
- function pickProvider(p) {
835
- if (p === pickedProvider) return
836
- pickedProvider = p
837
- try {
838
- localStorage.setItem(PROVIDER_KEY, p)
839
- } catch {}
840
- paint()
841
- }
842
-
843
- const STALE_MS = 15 * 60000
844
- const CODEX_SLEEP_MS = 5 * 60000 // main's default sleepThresholdMs
845
- // Codex logs are only written while it runs, so a reading can be hours old;
846
- // Cursor's comes from a fetch that can fail. Either way: say how old it is.
847
- const readingStale = (c) =>
848
- !!c && (c.session?.expired || (c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS))
849
-
850
- // the highest session % across connected services, and which ones are at or
851
- // past `from`; an expired Codex or Cursor reading is null, so it never counts
852
- function sessionHeat(from) {
853
- const known = connected()
854
- .map((p) => [p, sessionOf(p)?.pct])
855
- .filter(([, pct]) => pct != null)
856
- return {
857
- pct: Math.max(0, ...known.map(([, pct]) => pct)),
858
- providers: known.filter(([, pct]) => pct >= from).map(([p]) => p),
859
- }
860
- }
861
-
862
- // Codex's or Cursor's numbers in the shape the panel draws. Codex's logs carry
863
- // tokens but no activity; Cursor's transcripts carry activity but no tokens.
864
- function extView(p, c) {
865
- const idleFor = c.lastSeen ? Date.now() - c.lastSeen : null
866
- return {
867
- d: {
868
- active: c.active,
869
- activity: p === 'cursor' ? c.activity : null,
870
- // no data is not the same as asleep
871
- sleeping: !c.active && idleFor != null && idleFor >= CODEX_SLEEP_MS,
872
- tokensPerMin: 0,
873
- today: { tokens: c.tokensToday || 0 },
874
- session: { tokens: c.tokens5h || 0 },
875
- week: { tokens: c.tokensWeek || 0 },
876
- byModel: c.byModel || [],
877
- byProject: c.byProject || [],
878
- days30: c.days30 || [],
879
- monthTokens: c.monthTokens ?? null,
880
- },
881
- sess: c.session,
882
- week: p === 'cursor' ? c.api : c.weekly,
883
- // a Codex plan with no 5-hour window: its week sits in the session's place
884
- weeklyOnly: !!c.weeklyOnly,
885
- stale: c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS ? c.limitsAt : null,
886
- }
887
- }
888
-
889
- // a Codex or Cursor meter caption: its reset, or how old the reading is when
890
- // that's what matters. Cursor has no token counts, so `tokens` is null there.
891
- function extSub(w, tokens, stale, empty = 'limits not recorded yet') {
892
- const t = tokens == null ? '' : `${fmtTokens(tokens)} tokens`
893
- const join = (...parts) => parts.filter(Boolean).join(' · ')
894
- if (!w) return join(empty, t)
895
- if (w.pct == null) return join('waiting for a fresh reading', t)
896
- if (stale) {
897
- const at = w.resetMs != null ? `resets ${fmtResetClock(w.resetMs)}` : ''
898
- return join(at, `read ${fmtReset(Date.now() - stale)} ago`)
899
- }
900
- return join(w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)}` : '', t) || '—'
901
- }
902
-
903
- const pctText = (v) => (v == null ? '—' : `${Math.round(v)}%`)
904
- const warnAt = () => Math.min(...(currentConfig.alertThresholds || [80, 95]))
905
-
906
- // tabs + the collapsed dock: every service's session %, whichever is on screen
907
- function paintServices(view) {
908
- const on = connected()
909
- const many = on.length > 1
910
- document.body.classList.toggle('dual', many)
911
- document.body.classList.toggle('trio', on.length > 2)
912
- for (const p of ['codex', 'cursor']) document.body.classList.toggle(`view-${p}`, view === p)
913
- // collapsed with several: when the selected service's window resets
914
- const resetMs = sessionOf(view)?.resetMs
915
- el('mini-reset').hidden = !many || resetMs == null
916
- el('mini-reset').textContent = resetMs == null ? '' : `resets ${fmtResetClock(resetMs)}`
917
- el('service-panel').setAttribute('role', many ? 'tabpanel' : 'region')
918
- if (many) {
919
- el('service-panel').setAttribute('aria-labelledby', `tab-${view}`)
920
- el('service-panel').removeAttribute('aria-label')
921
- } else {
922
- el('service-panel').removeAttribute('aria-labelledby')
923
- el('service-panel').setAttribute('aria-label', `${NAMES[view]} usage`)
924
- return
925
- }
926
- // what each service calls its two meters
927
- const labels = {
928
- claude: ['session', 'weekly'],
929
- codex: ['session', 'weekly'],
930
- cursor: ['month', 'API'],
931
- }
932
- for (const p of PROVIDERS) {
933
- const tab = el(`tab-${p}`)
934
- const line = document.querySelector(`.mini-source[data-provider="${p}"]`)
935
- tab.hidden = !on.includes(p)
936
- line.hidden = tab.hidden
937
- if (tab.hidden) continue
938
- const pct = sessionOf(p)?.pct ?? null
939
- const second = secondOf(p)?.pct ?? null
940
- const active = p === 'claude' ? !!lastData?.active : !!dataOf(p)?.active
941
- const stale = p !== 'claude' && readingStale(dataOf(p))
942
- const selected = p === view
943
- const urgent = Math.max(pct ?? 0, second ?? 0) >= warnAt()
944
- tab.setAttribute('aria-selected', String(selected))
945
- tab.tabIndex = selected ? 0 : -1
946
- tab.classList.toggle('active', active)
947
- tab.classList.toggle('urgent', urgent)
948
- tab.querySelector('.tab-alert').hidden = !urgent
949
- el(`tab-${p}-value`).textContent = pctText(pct)
950
- const [first, next] = labels[p]
951
- tab.title = `${NAMES[p]} ${first} ${pctText(pct)} · ${next} ${pctText(second)}${active ? ' · working' : ''}${stale ? ' · not updated recently' : ''}`
952
- line.setAttribute('aria-pressed', String(selected))
953
- line.title = tab.title
954
- line.setAttribute('aria-label', `Show ${tab.title}`)
955
- line.classList.toggle('urgent', urgent)
956
- tab.setAttribute('aria-label', `Show ${tab.title}`)
957
- el(`mini-${p}-value`).textContent = pctText(pct)
958
- const bar = el(`mini-${p}-bar`)
959
- bar.style.width = `${Math.min(pct ?? 0, 100)}%`
960
- bar.classList.toggle('hot', (pct ?? 0) >= 80)
961
- }
962
- }
963
-
964
- for (const b of document.querySelectorAll('#harness-tabs button, .mini-source')) {
965
- b.addEventListener('click', (e) => {
966
- e.stopPropagation()
967
- pickProvider(b.dataset.provider)
968
- })
969
- }
970
- el('harness-tabs').addEventListener('keydown', (e) => {
971
- const providers = connected()
972
- const current = providers.indexOf(e.target.dataset.provider)
973
- if (current < 0) return
974
- let next
975
- if (e.key === 'ArrowRight') next = (current + 1) % providers.length
976
- else if (e.key === 'ArrowLeft') next = (current + providers.length - 1) % providers.length
977
- else if (e.key === 'Home') next = 0
978
- else if (e.key === 'End') next = providers.length - 1
979
- else return
980
- e.preventDefault()
981
- pickProvider(providers[next])
982
- el(`tab-${providers[next]}`).focus()
983
- })
984
-
985
- // ---- companion: pet-only size, reset reminders, and provider reactions -------
986
- const SIZE_KEY = 'clauddy.size'
987
- let sizeLoaded = false
988
- let peekTimer = null
989
- let nativePetInside = null
990
- let noticeTimer = null
991
- let reminders = { claude: null, codex: null, cursor: null }
992
- const activityTracker = Companion.createActivityTracker()
993
-
994
- function setPetPeek(on) {
995
- if (!document.body.classList.contains('pet-only')) return
996
- clearTimeout(peekTimer)
997
- // placed once as it appears: re-placing on every click or focus made it jump
998
- if (on && !document.body.classList.contains('pet-peek')) placeGlance()
999
- document.body.classList.toggle('pet-peek', on)
1000
- }
1001
- // The glance bubble sits just above the pet's head, and every pet's head is at
1002
- // its own height (the bat's ears, the wizard's hat), so measure the drawing's
1003
- // top instead of a fixed spot. Parts that only show while working (the owl,
1004
- // sparks, snow) don't count: they come and go. The pet is mid-animation (a
1005
- // bob, a hop, levitating), so the head is measured against #claude itself and
1006
- // laid on #claude's resting spot, at the foot of the still #pet box.
1007
- function placeGlance() {
1008
- const skin = el(SKIN_ID)
1009
- const parts = skin
1010
- ? [...skin.children].filter((c) => !c.matches('defs, .only-work'))
1011
- : [el('body')]
1012
- const tops = parts
1013
- .map((part) => part.getBoundingClientRect())
1014
- .filter((r) => r.width > 0)
1015
- .map((r) => r.top)
1016
- if (!tops.length) return
1017
- const zoom = Number.parseFloat(document.body.style.zoom) || 1
1018
- const sprite = el('claude').getBoundingClientRect()
1019
- const resting = el('pet').getBoundingClientRect().bottom - sprite.height
1020
- const head =
1021
- (Math.min(...tops) - sprite.top + resting - el('card').getBoundingClientRect().top) / zoom
1022
- const glance = el('pet-glance')
1023
- glance.style.top = `${Math.max(2, Math.round(head - glance.offsetHeight - 10))}px`
1024
- }
1025
- function setDisplaySize(size, remember = true) {
1026
- if (!['expanded', 'compact', 'pet'].includes(size)) size = 'expanded'
1027
- if (currentConfig.mode === 'menubar' && size === 'pet') size = 'compact'
1028
- hideBubble()
1029
- clearTimeout(peekTimer)
1030
- document.body.classList.remove('pet-peek')
1031
- document.body.classList.toggle('collapsed', size !== 'expanded')
1032
- document.body.classList.toggle('pet-only', size === 'pet')
1033
- nativePetInside = null
1034
- window.api.watchPetPointer(size === 'pet')
1035
- el('pet').tabIndex = size === 'pet' ? 0 : -1
1036
- if (size === 'pet') {
1037
- el('pet').setAttribute('role', 'button')
1038
- el('pet').setAttribute('aria-label', 'Drag to move; press Enter to open compact monitor')
1039
- } else {
1040
- el('pet').removeAttribute('role')
1041
- el('pet').removeAttribute('aria-label')
1042
- }
1043
- if (remember) {
1044
- try {
1045
- localStorage.setItem(SIZE_KEY, size)
1046
- } catch {}
1047
- }
1048
- paint()
1049
- fitSize()
1050
- }
1051
- el('pet-only-toggle').addEventListener('click', () => setDisplaySize('pet'))
1052
- el('pet-restore').addEventListener('click', () => setDisplaySize('compact'))
1053
- window.api.onPetPointer((point) => {
1054
- if (!document.body.classList.contains('pet-only')) return
1055
- const contains = (rect) =>
1056
- point &&
1057
- point.x >= rect.left &&
1058
- point.x <= rect.right &&
1059
- point.y >= rect.top &&
1060
- point.y <= rect.bottom
1061
- // Keep the preview open across the small gap to its controls. It opens from
1062
- // the sprite, not from the otherwise empty space below it.
1063
- const inside = !!(
1064
- contains(el('pet').getBoundingClientRect()) ||
1065
- (document.body.classList.contains('pet-peek') && contains(el('card').getBoundingClientRect()))
1066
- )
1067
- if (inside === nativePetInside) return
1068
- nativePetInside = inside
1069
- clearTimeout(peekTimer)
1070
- if (inside) setPetPeek(true)
1071
- else peekTimer = setTimeout(() => setPetPeek(false), 180)
1072
- })
1073
- window.addEventListener('blur', () => setPetPeek(false))
1074
- el('card').addEventListener('focusin', () => setPetPeek(true))
1075
- el('card').addEventListener('focusout', (e) => {
1076
- if (!el('card').contains(e.relatedTarget)) setPetPeek(false)
1077
- })
1078
- el('pet').addEventListener('keydown', (e) => {
1079
- if (!document.body.classList.contains('pet-only')) return
1080
- if (e.key === 'Enter' || e.key === ' ') {
1081
- e.preventDefault()
1082
- setDisplaySize('compact')
1083
- } else if (e.key === 'Escape') {
1084
- el('pet').blur()
1085
- setPetPeek(false)
1086
- }
1087
- })
1088
- for (const button of document.querySelectorAll('#pet-glance button[data-provider]')) {
1089
- button.addEventListener('click', () => pickProvider(button.dataset.provider))
1090
- }
1091
- function paintCompanion(view) {
1092
- for (const provider of PROVIDERS) {
1093
- const session = sessionOf(provider)
1094
- const button = el(`glance-${provider}`)
1095
- button.hidden = !isOn(provider)
1096
- button.setAttribute('aria-pressed', String(provider === view))
1097
- button.classList.toggle('urgent', session?.pct >= warnAt())
1098
- el(`glance-${provider}-value`).textContent = pctText(session?.pct)
1099
- // the bar under each reading fills with the session %; none when unknown
1100
- const pct = session?.pct
1101
- button.style.setProperty('--pct', pct == null ? '0' : String(Math.max(0, Math.min(100, pct))))
1102
- const stale = provider !== 'claude' && readingStale(dataOf(provider))
1103
- button.title = stale
1104
- ? 'Last recorded usage · waiting for a fresh reading'
1105
- : `Show ${provider} usage`
1106
- button.classList.toggle('stale-reading', !!stale)
1107
- }
1108
- el('glance-empty').hidden = connected().length > 0
1109
- const session = sessionOf(view)
1110
- const reminder = reminders[view]
1111
- const ext = dataOf(view)
1112
- const stale =
1113
- view === 'claude'
1114
- ? !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
1115
- : !ext?.limitsAt || readingStale(ext)
1116
- const available = session?.pct != null && session.resetMs > 0 && !stale
1117
- const name = NAMES[view]
1118
- for (const id of ['reminder-toggle', 'mini-reminder']) {
1119
- const button = el(id)
1120
- button.hidden = !reminder && (!available || session.pct < warnAt())
1121
- button.setAttribute('aria-pressed', String(!!reminder))
1122
- button.textContent = reminder ? '✓ Reminder on' : 'Notify at reset'
1123
- const at = reminder ? new Date(reminder.at) : null
1124
- // a month-out reset needs its date, not just a time
1125
- const when = at
1126
- ? at.getTime() - Date.now() >= 86400000
1127
- ? at.toLocaleDateString([], { month: 'short', day: 'numeric' })
1128
- : at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
1129
- : ''
1130
- button.title = reminder
1131
- ? `Cancel ${name} reminder for ${when}`
1132
- : `Remind me when ${name}'s ${view === 'cursor' ? 'monthly' : 'session'} reset is due`
1133
- }
1134
- }
1135
- function companionNotice(text, provider = null) {
1136
- clearTimeout(noticeTimer)
1137
- const notice = el('companion-notice')
1138
- notice.textContent = text
1139
- notice.title = text
1140
- notice.dataset.provider = provider || ''
1141
- notice.hidden = false
1142
- fitSize()
1143
- noticeTimer = setTimeout(() => {
1144
- notice.hidden = true
1145
- fitSize()
1146
- }, 5000)
1147
- }
1148
- for (const id of ['reminder-toggle', 'mini-reminder']) {
1149
- el(id).addEventListener('click', () => {
1150
- const provider = viewProvider()
1151
- window.api.setReminder(provider, !reminders[provider])
1152
- })
1153
- }
1154
- window.api.onReminders((state) => {
1155
- reminders = state || { claude: null, codex: null, cursor: null }
1156
- if (state?.error) companionNotice(state.error)
1157
- paintCompanion(viewProvider())
1158
- fitSize()
1159
- })
1160
- window.api.onReminderDue((event) => {
1161
- if (!event.isCurrent) return
1162
- const name = NAMES[event.provider] || 'Claude'
1163
- companionNotice(
1164
- event.confirmed ? `${name} · budget is back!` : `${name} · reset time reached`,
1165
- event.provider,
1166
- )
1167
- if (event.confirmed) celebrate()
1168
- })
1169
- function reactToProvider(provider, data) {
1170
- // no caption: the eyes glance toward whichever service changed
1171
- const cue = activityTracker.observe(provider, data)
1172
- if (!cue || document.body.classList.contains('settings-open')) return
1173
- if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1174
- // in tab order: Claude to the left, Codex to the right, Cursor down-right
1175
- const [x, y] = { claude: [-3, 0], codex: [3, 0], cursor: [3, 1] }[provider] || [0, 0]
1176
- const k = el(SKIN_ID) ? 2 : 1
1177
- for (const target of lookTargets()) {
1178
- target.animate(
1179
- [
1180
- { translate: '0 0' },
1181
- { translate: `${x * k}px ${y * k}px`, offset: 0.3 },
1182
- { translate: `${x * k}px ${y * k}px`, offset: 0.7 },
1183
- { translate: '0 0' },
1184
- ],
1185
- { duration: 1100, easing: 'ease-in-out' },
1186
- )
1187
- }
1188
- }
1189
- }
1190
-
1191
- function paintActivity() {
1192
- const activity = Companion.currentActivity({
1193
- claude: claudeOn() ? claudeActivityData : null,
1194
- codex: codexOn() ? codexData : null,
1195
- cursor: cursorOn() ? cursorData : null,
1196
- })
1197
- paintLogos(activity.providers, activity.activity)
1198
- return activity
1199
- }
1200
-
1201
- // the logos under the pet name who it's about: the workers, or the service on fire
1202
- function paintLogos(providers, caption) {
1203
- const names = providers.map((p) => NAMES[p]).join(' + ')
1204
- const label = names ? `${names} · ${caption}` : ''
1205
- for (const id of ['pet-activity', 'mini-workers']) {
1206
- const badge = el(id)
1207
- badge.hidden = !names
1208
- badge.setAttribute('aria-label', label)
1209
- for (const logo of badge.querySelectorAll('[data-provider]')) {
1210
- logo.hidden = !providers.includes(logo.dataset.provider)
1211
- }
1212
- }
1213
- el('mini-dot').hidden = providers.length > 0 && document.body.classList.contains('collapsed')
1214
- }
1215
-
1216
- // main render
1217
- let prevState = null
1218
- const prevPct = {} // per service: a switch must not read as a reset
1219
- let lastData = null
1220
- // Claude's activity, kept apart from lastData: an account switch or a logout
1221
- // must drop the old account's worker at once, not a tick later
1222
- let claudeActivityData = null
1223
- function render(d) {
1224
- if (claudeOn()) reactToProvider('claude', d)
1225
- lastData = d
1226
- claudeActivityData = claudeOn() ? d : null
1227
- noteSession(d)
1228
- // the burn trail is Claude's, fed on every Claude tick whatever is on screen
1229
- const cl = realUsage
1230
- burn.note(d.session.tokens, !!cl && cl.session.resetMs != null)
1231
- paint()
1232
- }
1233
-
1234
- function paint() {
1235
- setPlan(el('codex-plan'), codexData?.plan)
1236
- setPlan(el('cursor-plan'), cursorData?.plan)
1237
- paintConnections()
1238
- const d0 = lastData
1239
- if (!d0) return
1240
- const view = viewProvider()
1241
- paintServices(view)
1242
- paintCompanion(view)
1243
- // Codex or Cursor on screen: their own numbers, never Claude's
1244
- const ext = view !== 'claude' ? dataOf(view) : null
1245
- const isExt = !!ext
1246
- const isCursor = view === 'cursor' && isExt
1247
- const cv = isExt ? extView(view, ext) : null
1248
- const d = isExt ? cv.d : d0
1249
- // % comes only from the connected account (or the service's own data) — no estimates
1250
- const liveOn = isExt || !!realUsage
1251
- document.body.classList.toggle('live', liveOn)
1252
- const sessPct = isExt ? (cv.sess?.pct ?? null) : liveOn ? realUsage.session.pct : 0
1253
- const sessReset = isExt ? (cv.sess?.resetMs ?? null) : liveOn ? realUsage.session.resetMs : null
1254
- const sessActive = liveOn && sessReset != null
1255
- const wkPct = isExt ? (cv.week?.pct ?? null) : liveOn ? realUsage.week.pct : 0
1256
- const wkReset = isExt ? (cv.week?.resetMs ?? null) : liveOn ? realUsage.week.resetMs : null
1257
- const sp = sessPct ?? 0
1258
- const wp = wkPct ?? 0
1259
-
1260
- const fireAt = currentConfig.fireThreshold ?? 90
1261
- let st =
1262
- liveOn && sp >= 100
1263
- ? 'tired'
1264
- : d.active
1265
- ? 'working'
1266
- : liveOn && sp >= fireAt
1267
- ? 'stressed'
1268
- : d.sleeping
1269
- ? 'sleeping'
1270
- : 'idle'
1271
- const acts = ['editing', 'reading', 'planning', 'running', 'researching', 'delegating', 'waiting']
1272
- let curActivity = d.activity
1273
- const activity = paintActivity()
1274
- const collapsedNow = document.body.classList.contains('collapsed')
1275
- if (collapsedNow) {
1276
- // work wins; at rest, the hottest connected session still sets the mood,
1277
- // and its logo takes the status dot's place
1278
- const heat = sessionHeat(fireAt)
1279
- st = activity.providers.length
1280
- ? 'working'
1281
- : heat.pct >= 100
1282
- ? 'tired'
1283
- : heat.pct >= fireAt
1284
- ? 'stressed'
1285
- : activity.sleeping
1286
- ? 'sleeping'
1287
- : 'idle'
1288
- if (st === 'tired') paintLogos(sessionHeat(100).providers, 'maxed out')
1289
- else if (st === 'stressed') paintLogos(heat.providers, 'on fire')
1290
- curActivity = activity.providers.length ? activity.activity : null
1291
- }
1292
- // dev override from `./pet <state>` (base states or an activity name)
1293
- if (debugState) {
1294
- const map = {
1295
- working: 'working',
1296
- sleeping: 'sleeping',
1297
- fire: 'stressed',
1298
- tired: 'tired',
1299
- idle: 'idle',
1300
- }
1301
- if (map[debugState]) {
1302
- st = map[debugState]
1303
- curActivity = null
1304
- } else if (acts.includes(debugState)) {
1305
- st = 'working'
1306
- curActivity = debugState
1307
- }
1308
- }
1309
- setState(st)
1310
- // expose the current activity as a body class so per-action animations can
1311
- // hook it (e.g. body.act-reading). Only while actively working.
1312
- for (const a of acts) {
1313
- document.body.classList.toggle(`act-${a}`, st === 'working' && curActivity === a)
1314
- }
1315
- const before = prevState
1316
- if (prevState && prevState !== st) {
1317
- if (st === 'sleeping') oneShot('drowse', 700)
1318
- else if (prevState === 'sleeping') oneShot('wake', 700)
1319
- }
1320
- prevState = st
1321
-
1322
- const word =
1323
- st === 'working'
1324
- ? curActivity || 'working' // show what Claude's doing (editing/reading/…)
1325
- : st === 'sleeping'
1326
- ? 'sleeping'
1327
- : st === 'stressed'
1328
- ? 'on fire'
1329
- : st === 'tired'
1330
- ? 'maxed out'
1331
- : 'idle'
1332
- el('status-text').textContent = word
1333
- el('mini-text').textContent = word
1334
- // Cursor's transcripts carry no tokens: when it last ran is what there is to say
1335
- const ranAgo = isCursor && ext.lastSeen ? Date.now() - ext.lastSeen : null
1336
- el('rate').textContent = isCursor
1337
- ? ranAgo == null
1338
- ? 'no agent runs yet'
1339
- : ranAgo < 60000
1340
- ? 'agent ran just now'
1341
- : `agent ran ${fmtReset(ranAgo)} ago`
1342
- : d.active && d.tokensPerMin > 0
1343
- ? `${fmtTokens(d.tokensPerMin)} tok/min`
1344
- : `${fmtTokens(d.today.tokens)} tokens today`
1345
-
1346
- const was = prevPct[view]
1347
- if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) {
1348
- celebrate()
1349
- // Codex's and Cursor's own resets are called out by listenExt, on screen or not
1350
- if (!isExt) say(Voice.recapLine(takeRecap(was), fmtTokens, fmtReset), { headline: true })
1351
- }
1352
- prevPct[view] = sessPct
1353
- el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
1354
- setLevel(el('session-pct'), liveOn ? sp : 0)
1355
- const mini = el('mini-pct')
1356
- mini.textContent = liveOn ? pctText(sessPct) : '—'
1357
- mini.classList.toggle('high', liveOn && sp >= 80)
1358
- // the collapsed ring: how much of the session is already spent
1359
- const rf = el('ring-fill')
1360
- rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sp, 100) / 100)}`
1361
- rf.classList.toggle('high', sp >= 80)
1362
- const rp = el('ring-pct')
1363
- rp.textContent = mini.textContent
1364
- rp.classList.toggle('high', liveOn && sp >= 80)
1365
- const sf = el('session-fill')
1366
- sf.style.width = `${sp}%`
1367
- sf.classList.toggle('high', sp >= 80)
1368
- setLevel(sf, sp)
1369
- const weeklyOnly = !!cv?.weeklyOnly
1370
- el('session-label').textContent = isCursor
1371
- ? 'this month · included'
1372
- : weeklyOnly
1373
- ? 'weekly · all models'
1374
- : 'current session'
1375
- el('week-meter').hidden = weeklyOnly
1376
- el('week-label').textContent = isCursor ? 'API models · this month' : 'weekly · all models'
1377
- el('session-sub').textContent = isExt
1378
- ? isCursor
1379
- ? extSub(
1380
- cv.sess,
1381
- null,
1382
- cv.stale,
1383
- ext.signedOut ? 'sign in to the Cursor app' : 'no reading yet',
1384
- )
1385
- : extSub(cv.sess, weeklyOnly ? d.week.tokens : d.session.tokens, cv.stale)
1386
- : sessActive
1387
- ? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
1388
- : 'no active session'
1389
-
1390
- // where this pace is taking you — Claude's only: the others have no trail to read
1391
- const proj =
1392
- !isExt && sessActive && sp < 100 ? burn.project(sp, sessReset, d.session.tokens) : null
1393
- const pe = el('session-proj')
1394
- pe.hidden = !proj
1395
- pe.classList.toggle('tight', proj?.kind === 'eta')
1396
- if (proj) {
1397
- pe.textContent =
1398
- proj.kind === 'eta' ? `~${fmtReset(proj.ms)} left at this pace` : 'resets before you run out'
1399
- }
1400
-
1401
- el('week-pct').textContent = pctText(liveOn ? wkPct : 0)
1402
- setLevel(el('week-pct'), wp)
1403
- const wf = el('week-fill')
1404
- wf.style.width = `${wp}%`
1405
- wf.classList.toggle('high', wp >= 80)
1406
- setLevel(wf, wp)
1407
- el('week-sub').textContent = isExt
1408
- ? isCursor
1409
- ? extSub(cv.week, null, cv.stale, '—')
1410
- : extSub(cv.week, d.week.tokens, cv.stale)
1411
- : wkReset != null
1412
- ? `resets in ${fmtResetIn(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
1413
- : `${fmtTokens(d.week.tokens)} tokens · last 7 days`
1414
-
1415
- renderScoped(!isExt && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
1416
- renderModels(d.byModel || [])
1417
- renderProjects(d.byProject || [])
1418
- renderHeat(d.days30 || [])
1419
- el('month-total').textContent = d.monthTokens == null ? '—' : `${fmtTokens(d.monthTokens)} tokens`
1420
- paintChip()
1421
-
1422
- // compact eats at Claude's pace only while Claude is one of the workers
1423
- const claudeRate = !collapsedNow || activity.providers.includes('claude')
1424
- currentRate = claudeRate ? (collapsedNow ? d0 : d).tokensPerMin || 0 : 0
1425
- if (st === 'working') {
1426
- if (!eating) {
1427
- eating = true
1428
- startEating()
1429
- }
1430
- } else if (eating) {
1431
- eating = false
1432
- stopEating()
1433
- }
1434
-
1435
- listen(before, st, { isExt, liveOn, sp, sessReset, proj })
1436
- fitSize()
1437
- }
1438
-
1439
- // fit the window to the content (no leftover border)
1440
- let lastH = 0
1441
- let lastW = 0
1442
- // `animate` eases the window to its new size (opening and closing Settings);
1443
- // every other resize is immediate, so the widget tracks its content exactly
1444
- function fitSize(animate = false) {
1445
- requestAnimationFrame(() => {
1446
- const collapsed = document.body.classList.contains('collapsed')
1447
- const zoom = Number.parseFloat(document.body.style.zoom) || 1
1448
- // The multi-service dock trades height for a little horizontal room, and
1449
- // three readings widen the pet-only glance.
1450
- const dual = document.body.classList.contains('dual')
1451
- const trio = document.body.classList.contains('trio')
1452
- const petOnly = document.body.classList.contains('pet-only')
1453
- const w = (petOnly ? (trio ? 212 : 168) : collapsed ? (dual ? 240 : 170) : 304) * zoom
1454
- // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
1455
- // empirically against this Electron build) — so measure the unzoomed content
1456
- // height, then scale the whole thing (content + margin) ourselves
1457
- const h = (el('card').offsetHeight + 24) * zoom // 12px margin top + bottom
1458
- if (Math.abs(h - lastH) > 2 || w !== lastW) {
1459
- lastH = h
1460
- lastW = w
1461
- window.api.resize(w, h, animate)
1462
- }
1463
- })
1464
- }
1465
-
1466
- // widget scale, applied as a CSS zoom (not transform) so offsetWidth/Height
1467
- // keep reflecting it — offsetHeight is a layout px, so it must be measured as one
1468
- function applyZoom(z) {
1469
- document.body.style.zoom = (z || 100) / 100
1470
- }
1471
-
1472
- let currentConfig = {}
1473
- let realUsage = null
1474
- let realUsageReadAt = 0
1475
- let debugState = null
1476
- window.api.onDebugState((o) => {
1477
- const s = o?.state
1478
- if (s === 'poke') return pokePet()
1479
- if (s === 'celebrate') return celebrate()
1480
- if (s === 'say') return sayDebug(o.kind)
1481
- debugState = s === 'auto' || s === 'clear' || !s ? null : s
1482
- if (lastData) render(lastData)
1483
- })
1484
- window.api.onUsage(render)
1485
- // menu-bar popover: where its nib sits, so it points at the tray icon
1486
- window.api.onPopoverAnchor((x) => document.body.style.setProperty('--nib-x', `${x}px`))
1487
- window.api.onError((msg) => {
1488
- el('status-text').textContent = 'error'
1489
- console.error(msg)
1490
- })
1491
- window.api.onConfig((cfg) => {
1492
- currentConfig = cfg || {}
1493
- document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
1494
- document.body.classList.toggle('codex-on', !!currentConfig.codex)
1495
- if (!currentConfig.codex) codexData = null
1496
- if (!currentConfig.codex) activityTracker.forget('codex')
1497
- document.body.classList.toggle('cursor-on', !!currentConfig.cursor)
1498
- if (!currentConfig.cursor) cursorData = null
1499
- if (!currentConfig.cursor) activityTracker.forget('cursor')
1500
- if (!sizeLoaded) {
1501
- sizeLoaded = true
1502
- let size = 'expanded'
1503
- try {
1504
- size = localStorage.getItem(SIZE_KEY) || size
1505
- } catch {}
1506
- setDisplaySize(size, false)
1507
- } else if (currentConfig.mode === 'menubar' && document.body.classList.contains('pet-only')) {
1508
- setDisplaySize('compact', false)
1509
- }
1510
- heardConfig = true
1511
- refreshPet()
1512
- paintMute()
1513
- paint()
1514
- applyZoom(currentConfig.zoom)
1515
- fitSize()
1516
- })
1517
- window.api.onCodex((c) => {
1518
- codexData = c || null
1519
- reactToProvider('codex', codexOn() ? c : null)
1520
- paint()
1521
- })
1522
- window.api.onCursor((c) => {
1523
- cursorData = c || null
1524
- reactToProvider('cursor', cursorOn() ? c : null)
1525
- paint()
1526
- })
1527
- window.api.onRealUsage((u) => {
1528
- realUsage = u || null
1529
- realUsageReadAt = u ? Date.now() : 0
1530
- if (lastData) render(lastData)
1531
- })
1532
- window.api.onAuthState((s) => {
1533
- const on = !!s?.connected
1534
- document.body.classList.toggle('auth-on', on)
1535
- if (!on) {
1536
- activityTracker.forget('claude')
1537
- claudeActivityData = null
1538
- realUsage = null
1539
- document.body.classList.remove('live')
1540
- // main sends this before it opens the browser for a new login, so the
1541
- // pending state that comes next is not undone by it
1542
- endLogin()
1543
- showProfile(null)
1544
- }
1545
- paint() // losing Claude can leave Codex or Cursor as the only service
1546
- if (document.body.classList.contains('settings-open')) fitSize()
1547
- })
1548
-
1549
- // logged-in account chip (email + plan) shown top-left when connected
1550
- let lastProfile = null
1551
- function showProfile(p) {
1552
- lastProfile = p
1553
- paintChip()
1554
- paintConnections()
1555
- }
1556
-
1557
- // The chip is also the account switcher, so it must not vanish just because the
1558
- // profile fetch failed — that would strand the user on one account until a
1559
- // restart. The label main stored for the active account is the same email, so
1560
- // it stands in until the profile lands.
1561
- function paintChip() {
1562
- const p = lastProfile
1563
- const a = lastAccounts
1564
- const active = (a?.accounts || []).find((x) => x.id === a?.active)
1565
- const email = p?.email || (active?.connected ? active.label : null)
1566
- // the settings block says who is connected, not just that someone is: the
1567
- // logo identifies the service, followed by the same plan badge and email.
1568
- el('acc-ok').textContent = email || 'Connected'
1569
- setPlan(el('acc-sub'), p?.plan)
1570
- el('acc-ok').title = email || 'Connected'
1571
- const chip = el('account-chip')
1572
- const mini = el('mini-acct')
1573
- // the Codex and Cursor views never borrow Claude's identity
1574
- const view = viewProvider()
1575
- if (view !== 'claude') {
1576
- const plan = dataOf(view)?.plan || null
1577
- const name = NAMES[view]
1578
- el('ac-email').textContent = name
1579
- chip.title = `${name} on this computer`
1580
- setPlan(el('ac-plan'), plan)
1581
- chip.hidden = false
1582
- el('mini-acct-name').textContent = name
1583
- setPlan(el('mini-acct-plan'), plan)
1584
- mini.hidden = false
1585
- return
1586
- }
1587
- if (!email) {
1588
- chip.hidden = true
1589
- mini.hidden = true
1590
- return
1591
- }
1592
- // expanded: full email chip in the top bar
1593
- el('ac-email').textContent = email
1594
- chip.title = p?.name ? `${p.name} · ${email}` : email
1595
- setPlan(el('ac-plan'), p?.plan)
1596
- chip.hidden = false
1597
- // collapsed: short name + plan in the mini block (email won't fit at 116px)
1598
- el('mini-acct-name').textContent = p?.name || email.split('@')[0]
1599
- setPlan(el('mini-acct-plan'), p?.plan)
1600
- mini.hidden = false
1601
- }
1602
- function setPlan(node, plan) {
1603
- if (plan) {
1604
- node.textContent = plan
1605
- node.hidden = false
1606
- } else {
1607
- node.hidden = true
1608
- }
1609
- }
1610
- window.api.onProfile(showProfile)
1611
-
1612
- // ---- accounts ---------------------------------------------------------------
1613
- // The account chip doubles as the switcher: click it for the list, with the
1614
- // active one marked. Settings only keeps the login flow itself.
1615
- let pendingRemove = null // id whose × is armed, so removal takes two clicks
1616
- let lastAccounts = null
1617
- let switching = false // a switch is in flight, so the menu waits for its answer
1618
-
1619
- // one row of the chip dropdown
1620
- function accountRow(acc, a) {
1621
- const active = acc.id === a.active
1622
- const row = document.createElement('div')
1623
- row.className = 'acc-item'
1624
- if (active) row.classList.add('active')
1625
- if (acc.connected) row.classList.add('connected')
1626
- // the account being switched to owns the spinner: everything on screen
1627
- // still belongs to the one we're leaving
1628
- if (a.busy === acc.id) row.classList.add('loading')
1629
-
1630
- const dot = document.createElement('span')
1631
- dot.className = 'dot'
1632
- const who = document.createElement('span')
1633
- who.className = 'who'
1634
- who.textContent =
1635
- acc.label || (acc.connected ? 'Connected' : active ? 'Waiting for login…' : 'Not connected')
1636
- row.append(dot, who)
1637
- return row
1638
- }
1639
-
1640
- // Everything about accounts lives in the chip dropdown: the chip already says
1641
- // which one you are on, so switching, adding and removing all happen there.
1642
- function renderAccounts(a) {
1643
- lastAccounts = a
1644
- document.body.classList.toggle('one-account', (a?.accounts || []).length < 2)
1645
- paintChip() // the label may be all the chip has to go on
1646
-
1647
- if (el('acc-menu').hidden) return
1648
- // the switch is done: the chip now names the account the menu was pointing at
1649
- if (switching && !a?.busy) closeAccountMenu()
1650
- else renderAccountMenu()
1651
- }
1652
-
1653
- function renderAccountMenu() {
1654
- const a = lastAccounts
1655
- const box = el('acc-menu')
1656
- box.textContent = ''
1657
- box.classList.toggle('busy', !!a?.busy)
1658
- for (const acc of a?.accounts || []) {
1659
- const active = acc.id === a.active
1660
- const row = accountRow(acc, a)
1661
-
1662
- // the active account is what the widget is showing: it can't be removed
1663
- // from under you, and the last one standing can't be removed at all
1664
- if (!active && (a.accounts || []).length > 1) {
1665
- const x = document.createElement('button')
1666
- const armed = pendingRemove === acc.id
1667
- x.className = armed ? 'drop armed' : 'drop'
1668
- x.textContent = armed ? 'remove?' : '\u00d7'
1669
- x.title = 'Remove this account'
1670
- x.addEventListener('click', (e) => {
1671
- e.stopPropagation() // the row itself switches accounts
1672
- if (armed) {
1673
- pendingRemove = null
1674
- window.api.accountRemove(acc.id)
1675
- } else {
1676
- pendingRemove = acc.id // a click deletes a token: ask once
1677
- renderAccountMenu()
1678
- }
1679
- })
1680
- row.appendChild(x)
1681
- }
1682
-
1683
- // stopPropagation: re-rendering detaches this row, and the document-level
1684
- // "clicked outside" handler would then read that as a click off the menu
1685
- if (!active)
1686
- row.addEventListener('click', (e) => {
1687
- e.stopPropagation()
1688
- switchAccount(acc.id)
1689
- })
1690
- box.appendChild(row)
1691
- }
1692
-
1693
- const add = document.createElement('div')
1694
- add.className = 'acc-item acc-add'
1695
- add.textContent = '+ Add another account'
1696
- add.addEventListener('click', () => {
1697
- closeAccountMenu()
1698
- el('acc-msg').textContent = ''
1699
- window.api.accountAdd() // switches to a fresh slot and opens the browser
1700
- })
1701
- box.appendChild(add)
1702
- }
1703
-
1704
- function closeAccountMenu() {
1705
- el('acc-menu').hidden = true
1706
- el('acc-backdrop').hidden = true
1707
- pendingRemove = null
1708
- switching = false
1709
- }
1710
-
1711
- function toggleAccountMenu() {
1712
- const box = el('acc-menu')
1713
- if (!box.hidden) {
1714
- closeAccountMenu()
1715
- return
1716
- }
1717
- renderAccountMenu()
1718
- box.hidden = false
1719
- el('acc-backdrop').hidden = false
1720
- }
1721
-
1722
- el('account-chip').addEventListener('click', (e) => {
1723
- e.stopPropagation()
1724
- // Codex and Cursor have no account switcher: their chip leads to their card
1725
- if (viewProvider() !== 'claude') return openSettings(viewProvider())
1726
- toggleAccountMenu()
1727
- })
1728
- // clicking anywhere else — the pet, the gear, another app — puts it away
1729
- el('acc-backdrop').addEventListener('mousedown', closeAccountMenu)
1730
- document.addEventListener('click', (e) => {
1731
- if (!el('acc-menu').hidden && !el('acc-menu').contains(e.target)) closeAccountMenu()
1732
- })
1733
- window.addEventListener('blur', closeAccountMenu)
1734
- document.addEventListener('keydown', (e) => {
1735
- if (e.key === 'Escape') closeAccountMenu()
1736
- })
1737
-
1738
- // paint the pending state from the click itself rather than waiting for main to
1739
- // answer — the answer is exactly what takes a moment
1740
- function switchAccount(id) {
1741
- if (lastAccounts?.busy) return
1742
- endLogin() // the code from the account we're leaving is no good here
1743
- pendingRemove = null
1744
- switching = true // the row stays on screen, pending, until main answers
1745
- renderAccounts({ ...lastAccounts, busy: id })
1746
- window.api.accountSwitch(id)
1747
- }
1748
-
1749
- window.api.onAccounts((a) => {
1750
- if (lastAccounts?.active !== a?.active) {
1751
- activityTracker.forget('claude')
1752
- claudeActivityData = null
1753
- }
1754
- pendingRemove = null
1755
- renderAccounts(a)
1756
- paint()
1757
- })
1758
-
1759
- // closing the panel while a login is pending gives up on it: main puts us back
1760
- // on the account we were using, and the half-made one goes away
1761
- function abandonLogin() {
1762
- if (!document.body.classList.contains('awaiting')) return
1763
- endLogin()
1764
- window.api.accountCancelAdd()
1765
- }
1766
-
1767
- function endLogin() {
1768
- document.body.classList.remove('awaiting')
1769
- el('acc-paste').classList.remove('show')
1770
- el('acc-code').value = ''
1771
- el('acc-msg').textContent = ''
1772
- }
1773
- let successTimer = null
1774
- window.api.onAuthResult((r) => {
1775
- if (r?.ok) {
1776
- endLogin()
1777
- // logging in is done: land back on the home panel, where the fresh live
1778
- // meters show up under a short-lived cheer — and the pet throws confetti
1779
- document.body.classList.remove('settings-open')
1780
- clearSaveDirty()
1781
- el('home-success').hidden = false
1782
- clearTimeout(successTimer)
1783
- successTimer = setTimeout(() => {
1784
- el('home-success').hidden = true
1785
- fitSize()
1786
- }, 6000)
1787
- celebrate()
1788
- } else {
1789
- // failed: restore the connection card so a retry is one click away
1790
- document.body.classList.remove('awaiting')
1791
- const e = r?.error || ''
1792
- el('acc-msg').textContent = /429|rate_limit/i.test(e)
1793
- ? 'Rate limited by Anthropic — wait a few minutes, then try once with a fresh code.'
1794
- : `Failed: ${e || 'check the code and try again'}`
1795
- }
1796
- fitSize()
1797
- })
1798
-
1799
- // Keep the main view focused on limits; history is an explicit, remembered choice.
1800
- const DETAILS_KEY = 'clauddy.details-open'
1801
- function setDetailsOpen(open) {
1802
- el('usage-details').hidden = !open
1803
- el('details-toggle').setAttribute('aria-expanded', String(open))
1804
- fitSize()
1805
- }
1806
- try {
1807
- setDetailsOpen(localStorage.getItem(DETAILS_KEY) === 'true')
1808
- } catch {
1809
- setDetailsOpen(false)
1810
- }
1811
- el('details-toggle').addEventListener('click', () => {
1812
- const open = el('usage-details').hidden
1813
- setDetailsOpen(open)
1814
- try {
1815
- localStorage.setItem(DETAILS_KEY, String(open))
1816
- } catch {}
1817
- })
1818
-
1819
- // collapsible panel sections — the widget is a desktop pet, not a dashboard, so
1820
- // each breakdown can be folded away and the choice is remembered per machine
1821
- const SEC_KEY = 'clauddy.folded'
1822
-
1823
- function readFolded() {
1824
- try {
1825
- return new Set(JSON.parse(localStorage.getItem(SEC_KEY) || '[]'))
1826
- } catch {
1827
- return new Set()
1828
- }
1829
- }
1830
-
1831
- function toggleSection(id, force) {
1832
- const sec = el(id)
1833
- if (!sec) return
1834
- // `folded`, not `collapsed` — the body already uses that word for the pet
1835
- const folded = force !== undefined ? force : !sec.classList.contains('folded')
1836
- sec.classList.toggle('folded', folded)
1837
- const head = sec.querySelector('.sec-head')
1838
- if (head) head.setAttribute('aria-expanded', String(!folded))
1839
- // the card just changed height, and the next usage poll is seconds away —
1840
- // without this the window keeps its old size and clips the content. The body
1841
- // slides now rather than snapping, so size it again once that settles.
1842
- fitSize()
1843
- // ...and again when the slide lands. The listener has to check what finished:
1844
- // the bar widths inside the section transition too, and those events bubble up
1845
- // here — resizing on one of them catches the card mid-slide.
1846
- const body = sec.querySelector('.sec-body')
1847
- if (!body) return
1848
- const settle = (e) => {
1849
- if (e.target !== body || e.propertyName !== 'grid-template-rows') return
1850
- body.removeEventListener('transitionend', settle)
1851
- fitSize()
1852
- }
1853
- body.addEventListener('transitionend', settle)
1854
- }
1855
-
1856
- for (const head of document.querySelectorAll('.sec-head')) {
1857
- head.addEventListener('click', () => {
1858
- const id = head.dataset.sec
1859
- toggleSection(id)
1860
- const open = readFolded()
1861
- if (el(id).classList.contains('folded')) open.add(id)
1862
- else open.delete(id)
1863
- try {
1864
- localStorage.setItem(SEC_KEY, JSON.stringify([...open]))
1865
- } catch {
1866
- // private mode or a wiped profile — the panel just forgets, which is fine
1867
- }
1868
- })
1869
- }
1870
-
1871
- for (const id of readFolded()) toggleSection(id, true)
1872
-
1873
- el('close').addEventListener('click', () => window.api.quit())
1874
- el('usage').addEventListener('click', () => window.api.openUsage(viewProvider()))
1875
-
1876
- // Settings → Codex: Connect looks for a local session and turns monitoring on if found
1877
- function endCodexSetup() {
1878
- document.body.classList.remove('codex-setup')
1879
- el('codex-setup').hidden = true
1880
- el('codex-hint').textContent = 'Track Codex alongside Claude, in this pet.'
1881
- }
1882
- el('codex-connect').addEventListener('click', () => {
1883
- el('codex-hint').textContent = 'Looking…'
1884
- window.api.codexDetect()
1885
- })
1886
- el('codex-retry').addEventListener('click', () => window.api.codexDetect())
1887
- // Connect is the consent: found → monitoring starts, not found → say why
1888
- window.api.onCodexDetected((r) => {
1889
- if (r?.found) {
1890
- endCodexSetup()
1891
- window.api.codexEnable(true)
1892
- return
1893
- }
1894
- document.body.classList.add('codex-setup')
1895
- el('codex-setup').hidden = false
1896
- el('codex-hint').textContent = 'No session — sign in to Codex'
1897
- fitSize()
1898
- })
1899
- el('codex-setup-cancel').addEventListener('click', () => {
1900
- endCodexSetup()
1901
- fitSize()
1902
- })
1903
- el('codex-disconnect').addEventListener('click', () => window.api.codexEnable(false))
1904
-
1905
- // Settings → Cursor: the same, but what it looks for is the Cursor app's login
1906
- function endCursorSetup() {
1907
- document.body.classList.remove('cursor-setup')
1908
- el('cursor-setup').hidden = true
1909
- el('cursor-hint').textContent = 'Track Cursor alongside Claude, in this pet.'
1910
- }
1911
- el('cursor-connect').addEventListener('click', () => {
1912
- el('cursor-hint').textContent = 'Looking…'
1913
- window.api.cursorDetect()
1914
- })
1915
- el('cursor-retry').addEventListener('click', () => window.api.cursorDetect())
1916
- window.api.onCursorDetected((r) => {
1917
- if (r?.found) {
1918
- endCursorSetup()
1919
- window.api.cursorEnable(true)
1920
- return
1921
- }
1922
- document.body.classList.add('cursor-setup')
1923
- el('cursor-setup').hidden = false
1924
- el('cursor-hint').textContent = 'Not signed in — open the Cursor app and log in'
1925
- fitSize()
1926
- })
1927
- el('cursor-setup-cancel').addEventListener('click', () => {
1928
- endCursorSetup()
1929
- fitSize()
1930
- })
1931
- el('cursor-disconnect').addEventListener('click', () => window.api.cursorEnable(false))
1932
-
1933
- // account login (browser flow)
1934
- el('acc-connect').addEventListener('click', () => window.api.authStart())
1935
- // main opened the browser — the only thing left to do is paste the code back,
1936
- // so the panel narrows to exactly that
1937
- window.api.onAuthPending(() => {
1938
- // the login can start from the account menu, with the panel closed: the code
1939
- // field is where the flow continues, so bring it up
1940
- openSettings('claude')
1941
- document.body.classList.add('awaiting')
1942
- el('acc-paste').classList.add('show')
1943
- el('acc-code').classList.remove('filled')
1944
- el('acc-code').focus()
1945
- fitSize()
1946
- })
1947
- // the Connect button only lights up once there's a code to submit, and the
1948
- // field stops asking for attention once it has one
1949
- el('acc-code').addEventListener('input', () => {
1950
- const code = el('acc-code').value.trim()
1951
- el('acc-confirm').classList.toggle('ready', !!code)
1952
- el('acc-code').classList.toggle('filled', !!code)
1953
- })
1954
- el('acc-confirm').addEventListener('click', () => {
1955
- const code = el('acc-code').value.trim()
1956
- if (!code) return
1957
- el('acc-msg').textContent = 'Checking…'
1958
- window.api.authCode(code)
1959
- const b = el('acc-confirm')
1960
- b.disabled = true
1961
- setTimeout(() => (b.disabled = false), 5000) // avoid hammering the rate-limited endpoint
1962
- })
1963
- el('acc-logout').addEventListener('click', () => window.api.authLogout())
1964
-
1965
- // self-update: show the version, check the latest release, one-click update
1966
- window.api.onVersion((v) => {
1967
- if (v) el('app-version').textContent = v
1968
- })
1969
- window.api.onUpdateStatus((s) => {
1970
- const status = el('upd-status')
1971
- const now = el('upd-now')
1972
- const check = el('upd-check')
1973
- // reset to the idle look, then layer each state on top
1974
- now.hidden = true
1975
- check.hidden = false
1976
- status.textContent = ''
1977
- status.className = ''
1978
- const state = s?.state
1979
- if (state === 'checking') {
1980
- status.textContent = 'Checking…'
1981
- check.hidden = true
1982
- } else if (state === 'uptodate') {
1983
- status.textContent = "You're up to date"
1984
- status.className = 'ok'
1985
- } else if (state === 'available') {
1986
- // the button carries the version, so no separate status text is needed
1987
- now.textContent = `Update to ${s.latest}`
1988
- now.hidden = false
1989
- check.hidden = true
1990
- } else if (state === 'updating') {
1991
- status.textContent = 'Updating… the app will restart'
1992
- check.hidden = true
1993
- } else if (state === 'error') {
1994
- status.textContent = 'Check failed'
1995
- status.className = 'err'
1996
- }
1997
- // pulse a dot on the gear whenever an update is waiting (cleared once it's
1998
- // up to date or actively updating) — so it's noticeable without opening Settings
1999
- if (state === 'available') document.body.classList.add('has-update')
2000
- else if (state === 'uptodate' || state === 'updating')
2001
- document.body.classList.remove('has-update')
2002
- if (document.body.classList.contains('settings-open')) fitSize()
2003
- })
2004
- el('upd-check').addEventListener('click', () => window.api.checkUpdates())
2005
- el('upd-now').addEventListener('click', () => {
2006
- el('upd-now').disabled = true
2007
- window.api.doUpdate()
2008
- })
2009
-
2010
- // settings panel
2011
- // snapshot of the editable fields, to tell whether there are unsaved changes
2012
- let settingsBaseline = null
2013
- let selectedMode = 'floating'
2014
- function setModeUI(mode) {
2015
- selectedMode = mode === 'menubar' ? 'menubar' : 'floating'
2016
- for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
2017
- b.classList.toggle('on', b.dataset.mode === selectedMode)
2018
- }
2019
- }
2020
- function snapshotSettings() {
2021
- return JSON.stringify({
2022
- mode: selectedMode,
2023
- alerts: el('set-alerts').checked,
2024
- t1: el('set-t1').value,
2025
- t2: el('set-t2').value,
2026
- fire: el('set-fire').value,
2027
- zoom: el('set-zoom').value,
2028
- talk: el('set-talk').checked,
2029
- sound: el('set-sound').checked,
2030
- pet: pickedPet,
2031
- })
2032
- }
2033
- function refreshSaveDirty() {
2034
- if (settingsBaseline == null) return
2035
- const dirty = snapshotSettings() !== settingsBaseline
2036
- const b = el('set-save')
2037
- b.classList.toggle('dirty', dirty)
2038
- b.disabled = !dirty // nothing to save, nothing to click
2039
- paintSettingsFooter(dirty)
2040
- }
2041
- // nothing changed: a single Done; once something has, Cancel and Save
2042
- function paintSettingsFooter(dirty) {
2043
- el('settings').classList.toggle('dirty', dirty)
2044
- el('set-cancel').textContent = dirty ? 'Cancel' : 'Done'
2045
- }
2046
- function clearSaveDirty() {
2047
- settingsBaseline = snapshotSettings()
2048
- const b = el('set-save')
2049
- b.classList.remove('dirty')
2050
- b.disabled = true
2051
- paintSettingsFooter(false)
2052
- }
2053
- function reflectAlertsOn() {
2054
- el('set-rows').classList.toggle('alerts-off', !el('set-alerts').checked)
2055
- }
2056
- function reflectTalkOn() {
2057
- el('set-rows').classList.toggle('talk-off', !el('set-talk').checked)
2058
- }
2059
- // zoom applies as you step it, so the widget shows the size right away;
2060
- // Cancel puts the saved value back
2061
- function previewZoom() {
2062
- const v = Number.parseFloat(el('set-zoom').value)
2063
- if (v >= 100 && v <= 200) {
2064
- applyZoom(v)
2065
- fitSize()
2066
- }
2067
- }
2068
- function populateSettings() {
2069
- const c = currentConfig || {}
2070
- setModeUI(c.mode)
2071
- el('set-alerts').checked = c.alerts !== false
2072
- reflectAlertsOn()
2073
- const th = c.alertThresholds || [80, 95]
2074
- el('set-t1').value = th[0] != null ? th[0] : 80
2075
- el('set-t2').value = th[1] != null ? th[1] : 95
2076
- el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
2077
- el('set-zoom').value = c.zoom != null ? c.zoom : 100
2078
- el('set-talk').checked = c.talk !== false
2079
- el('set-sound').checked = !!c.sound
2080
- reflectTalkOn()
2081
- pickedPet = Pets.findPet(c.pet) ? c.pet : Pets.DEFAULT_PET
2082
- paintPetPicker()
2083
- const visiting = Pets.findPet(Pets.seasonalPet())
2084
- el('pet-season').hidden = !visiting
2085
- if (visiting) {
2086
- el('pet-season').textContent =
2087
- `${visiting.name} takes over for the holiday, then your pick comes back.`
2088
- }
2089
- clearSaveDirty() // fields now match the saved config
2090
- }
2091
- // `provider` opens that service's connection card straight away
2092
- function openSettings(provider = null) {
2093
- hideBubble()
2094
- setDisplaySize('expanded', false)
2095
- populateSettings()
2096
- showSettingsTab(typeof provider === 'string' ? 'accounts' : settingsTab)
2097
- if (typeof provider === 'string') openConnection(provider)
2098
- document.body.classList.add('settings-open')
2099
- fitSize(true)
2100
- }
2101
-
2102
- // Settings tabs: one pane at a time, each at its own height, and the window
2103
- // eases between them rather than keeping the tallest one's empty space
2104
- let settingsTab = 'accounts'
2105
- function showSettingsTab(tab) {
2106
- const changed = tab !== settingsTab
2107
- settingsTab = tab
2108
- for (const b of document.querySelectorAll('.set-tab')) {
2109
- const on = b.dataset.tab === tab
2110
- b.classList.toggle('on', on)
2111
- b.setAttribute('aria-selected', String(on))
2112
- b.tabIndex = on ? 0 : -1
2113
- el(`pane-${b.dataset.tab}`).classList.toggle('on', on)
2114
- }
2115
- if (changed && document.body.classList.contains('settings-open')) fitSize(true)
2116
- }
2117
- for (const b of document.querySelectorAll('.set-tab')) {
2118
- b.addEventListener('click', () => showSettingsTab(b.dataset.tab))
2119
- }
2120
- // arrow keys move between tabs, as a tablist should
2121
- el('set-tabs').addEventListener('keydown', (e) => {
2122
- const tabs = [...document.querySelectorAll('.set-tab')]
2123
- const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key]
2124
- if (!step) return
2125
- const next =
2126
- tabs[(tabs.findIndex((t) => t.dataset.tab === settingsTab) + step + tabs.length) % tabs.length]
2127
- showSettingsTab(next.dataset.tab)
2128
- next.focus()
2129
- e.preventDefault()
2130
- })
2131
- showSettingsTab(settingsTab)
2132
-
2133
- // Settings → Connections: a tile per service says whether it's on (a check and
2134
- // its plan); a click opens its card underneath, one at a time
2135
- let openConn = null
2136
- function openConnection(provider) {
2137
- openConn = provider
2138
- paintConnections()
2139
- fitSize()
2140
- }
2141
- function paintConnections() {
2142
- const plans = { claude: lastProfile?.plan, codex: codexData?.plan, cursor: cursorData?.plan }
2143
- for (const p of PROVIDERS) {
2144
- const tile = el(`conn-${p}`)
2145
- const on = isOn(p)
2146
- const plan = on ? plans[p] || null : null
2147
- const open = openConn === p
2148
- tile.setAttribute('aria-expanded', String(open))
2149
- tile.classList.toggle('on', on)
2150
- tile.querySelector('.conn-check').hidden = !on
2151
- const state = el(`conn-${p}-state`)
2152
- state.textContent = on ? plan || 'Connected' : 'Connect'
2153
- state.classList.toggle('plan-badge', !!plan)
2154
- tile.setAttribute(
2155
- 'aria-label',
2156
- `${NAMES[p]} · ${on ? `connected${plan ? `, ${plan}` : ''}` : 'not connected'}`,
2157
- )
2158
- el(p === 'claude' ? 'account' : `${p}-settings`).classList.toggle('open', open)
2159
- }
2160
- }
2161
- for (const tile of document.querySelectorAll('.conn-tile')) {
2162
- tile.addEventListener('click', () =>
2163
- openConnection(openConn === tile.dataset.conn ? null : tile.dataset.conn),
2164
- )
2165
- }
2166
-
2167
- // Leaving settings plays the panel out to the right while home slides back in.
2168
- // The panel is lifted out of the flow for those few frames (see .settings-closing
2169
- // in the stylesheet), so the card can already be measured at its home height.
2170
- let closingTimer = null
2171
- function closeSettings() {
2172
- if (!document.body.classList.contains('settings-open')) return
2173
- abandonLogin()
2174
- endCodexSetup()
2175
- endCursorSetup()
2176
- openConn = null
2177
- paintConnections()
2178
- document.body.classList.remove('settings-open')
2179
- document.body.classList.add('settings-closing')
2180
- clearTimeout(closingTimer)
2181
- closingTimer = setTimeout(() => document.body.classList.remove('settings-closing'), 420)
2182
- clearSaveDirty()
2183
- applyZoom(currentConfig?.zoom != null ? currentConfig.zoom : 100) // undo the preview
2184
- pickedPet = null
2185
- el('pet-modal').hidden = true
2186
- refreshPet()
2187
- fitSize(true)
2188
- }
2189
- el('gear').addEventListener('click', () => {
2190
- if (document.body.classList.contains('settings-open')) closeSettings()
2191
- else openSettings()
2192
- })
2193
-
2194
- // the "connect" placeholder jumps straight to settings
2195
- el('limits-connect').addEventListener('click', () => openSettings('claude'))
2196
- // custom number steppers (▲ / ▼)
2197
- for (const b of document.querySelectorAll('.num-btn')) {
2198
- b.addEventListener('click', () => {
2199
- const input = el(b.dataset.for)
2200
- const min = Number(input.min) || 1
2201
- const max = Number(input.max) || 100
2202
- const next = (Number.parseInt(input.value, 10) || 0) + Number(b.dataset.step)
2203
- input.value = Math.min(max, Math.max(min, next))
2204
- refreshSaveDirty() // steppers change the value without an 'input' event
2205
- if (input.id === 'set-zoom') previewZoom()
2206
- })
2207
- }
2208
- // the pet picker: one button per pet, grouped by pack; a click previews it
2209
- function clauddyThumb() {
2210
- // one path per colour: separate squares leave hairline seams when scaled
2211
- const cells = (pick) =>
2212
- SPRITE.flatMap((row, r) =>
2213
- [...row].map((ch, c) => (pick(ch) ? `M${c} ${r}h1v1h-1z` : '')),
2214
- ).join('')
2215
- return `<svg viewBox="-1 -1 12 11" aria-hidden="true"><path d="${cells((ch) => ch !== '.')}" fill="var(--pixel)"/><path d="${cells((ch) => ch === 'o')}" fill="var(--eye)"/></svg>`
2216
- }
2217
- function buildPetPicker() {
2218
- for (const pack of Pets.PACKS) {
2219
- const pets = Pets.PETS.filter((p) => p.pack === pack.id)
2220
- if (pack.id === 'everyday') pets.unshift(Pets.findPet(Pets.DEFAULT_PET))
2221
- const group = document.createElement('div')
2222
- group.innerHTML = `<div class="pet-pack-name">${pack.name}</div><div class="pet-grid"></div>`
2223
- for (const pet of pets) {
2224
- const b = document.createElement('button')
2225
- b.type = 'button'
2226
- b.className = 'pet-opt'
2227
- b.dataset.pet = pet.id
2228
- b.setAttribute('role', 'radio')
2229
- b.setAttribute('aria-label', pet.name)
2230
- b.innerHTML =
2231
- pet.id === Pets.DEFAULT_PET ? clauddyThumb() : Pets.petMarkup(pet.id, `th-${pet.id}-`)
2232
- b.addEventListener('click', () => {
2233
- pickedPet = pet.id
2234
- paintPetPicker()
2235
- showPet(pet.id)
2236
- refreshSaveDirty()
2237
- closePetModal()
2238
- })
2239
- group.querySelector('.pet-grid').appendChild(b)
2240
- }
2241
- el('set-pet').appendChild(group)
2242
- }
2243
- }
2244
- function paintPetPicker() {
2245
- for (const b of el('set-pet').querySelectorAll('.pet-opt')) {
2246
- const on = b.dataset.pet === pickedPet
2247
- b.classList.toggle('on', on)
2248
- b.setAttribute('aria-checked', String(on))
2249
- }
2250
- // the row shows the pick; the full catalog waits behind the pencil
2251
- const pet = Pets.findPet(pickedPet)
2252
- el('pet-current-name').textContent = pet?.name || ''
2253
- el('pet-current-thumb').innerHTML =
2254
- pickedPet === Pets.DEFAULT_PET ? clauddyThumb() : Pets.petMarkup(pickedPet, 'cur-')
2255
- }
2256
- function openPetModal() {
2257
- el('pet-modal').hidden = false
2258
- ;(el('set-pet').querySelector('.pet-opt.on') || el('set-pet').querySelector('.pet-opt'))?.focus()
2259
- }
2260
- function closePetModal() {
2261
- if (el('pet-modal').hidden) return
2262
- el('pet-modal').hidden = true
2263
- el('pet-edit').focus()
2264
- }
2265
- buildPetPicker()
2266
- el('pet-edit').addEventListener('click', openPetModal)
2267
- el('pet-modal-close').addEventListener('click', closePetModal)
2268
- // a click on the dimmed backdrop, outside the card, puts it away too
2269
- el('pet-modal').addEventListener('click', (e) => {
2270
- if (e.target === el('pet-modal')) closePetModal()
2271
- })
2272
- document.addEventListener('keydown', (e) => {
2273
- if (e.key === 'Escape') closePetModal()
2274
- })
2275
-
2276
- // light up Save whenever an editable field changes
2277
- for (const id of [
2278
- 'set-alerts',
2279
- 'set-t1',
2280
- 'set-t2',
2281
- 'set-fire',
2282
- 'set-zoom',
2283
- 'set-talk',
2284
- 'set-sound',
2285
- ]) {
2286
- el(id).addEventListener('input', refreshSaveDirty)
2287
- el(id).addEventListener('change', refreshSaveDirty)
2288
- }
2289
- el('set-alerts').addEventListener('change', reflectAlertsOn)
2290
- el('set-talk').addEventListener('change', reflectTalkOn)
2291
- // turning the voice on plays a sample, so you hear what you just agreed to
2292
- el('set-sound').addEventListener('change', () => {
2293
- if (el('set-sound').checked) blipper.play('Hello there!')
2294
- })
2295
- el('set-zoom').addEventListener('input', previewZoom)
2296
- // display-mode segmented control (floating pet | menu bar)
2297
- for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
2298
- b.addEventListener('click', () => {
2299
- setModeUI(b.dataset.mode)
2300
- refreshSaveDirty()
2301
- })
2302
- }
2303
- el('set-cancel').addEventListener('click', closeSettings)
2304
- el('set-save').addEventListener('click', () => {
2305
- abandonLogin() // leaving the panel gives up on a login waiting for its code
2306
- const num = (id) => parseFloat(el(id).value)
2307
- const fire = num('set-fire')
2308
- const zoomRaw = num('set-zoom')
2309
- const zoom = zoomRaw >= 100 && zoomRaw <= 200 ? Math.round(zoomRaw) : 100
2310
- applyZoom(zoom) // apply immediately so the fitSize() below measures the new size
2311
- window.api.saveConfig({
2312
- mode: selectedMode,
2313
- alerts: el('set-alerts').checked,
2314
- alertThresholds: [num('set-t1'), num('set-t2')]
2315
- .filter((n) => n >= 1 && n <= 100)
2316
- .sort((a, b) => a - b),
2317
- fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
2318
- zoom,
2319
- talk: el('set-talk').checked,
2320
- sound: el('set-sound').checked,
2321
- pet: pickedPet,
2322
- })
2323
- const saved = pickedPet
2324
- pickedPet = null
2325
- showPet(Pets.activePet(saved))
2326
- clearSaveDirty()
2327
- document.body.classList.remove('settings-open')
2328
- fitSize()
2329
- })
2330
- el('min').addEventListener('click', () => {
2331
- hideBubble()
2332
- setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
2333
- })
2334
- // double-click the pet to collapse / expand
2335
- el('pet').addEventListener('dblclick', () => {
2336
- if (document.body.classList.contains('pet-only')) return
2337
- hideBubble()
2338
- setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
2339
- })
2340
-
2341
- // poke the pet -> bouncy squish + hearts
2342
- el('pet').addEventListener('click', () => {
2343
- if (!document.body.classList.contains('pet-only')) pokePet()
2344
- })
2345
-
2346
- // eyes follow the cursor
2347
- window.addEventListener('mousemove', (e) => {
2348
- const b = document.body.classList
2349
- if (b.contains('state-sleeping') || b.contains('state-tired')) return
2350
- const r = el('pet').getBoundingClientRect()
2351
- const dx = e.clientX - (r.left + r.width / 2)
2352
- const dy = e.clientY - (r.top + r.height / 2)
2353
- const len = Math.hypot(dx, dy) || 1
2354
- lookAt(((dx / len) * 3).toFixed(2), ((dy / len) * 2).toFixed(2))
2355
- })
2356
- window.addEventListener('mouseout', (e) => {
2357
- if (!e.relatedTarget) lookAt(0, 0)
2358
- })
2359
-
2360
- // welcome wave
2361
- document.body.classList.add('greet')
2362
- setTimeout(() => document.body.classList.remove('greet'), 1200)
2363
-
2364
- // Loaded as a plain <script> by the widget, where `module` doesn't exist. The
2365
- // tests import it instead, against a happy-dom document and a stub bridge —
2366
- // same dual export as burn.js.
2367
- if (typeof module === 'object' && module.exports) {
2368
- module.exports = {
2369
- render,
2370
- fmtTokens,
2371
- fmtReset,
2372
- fmtResetIn,
2373
- setState,
2374
- renderModels,
2375
- renderProjects,
2376
- renderHeat,
2377
- showProfile,
2378
- renderAccounts,
2379
- renderAccountMenu,
2380
- paint,
2381
- pickProvider,
2382
- burn,
2383
- say,
2384
- hideBubble,
2385
- soundOn,
2386
- setDisplaySize,
2387
- setPetPeek,
2388
- paintCompanion,
2389
- activityTracker,
2390
- blipper,
2391
- showPet,
2392
- refreshPet,
2393
- }
2394
- }