clauddy 1.29.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/bin/clauddy.js +82 -79
- package/package.json +10 -82
- package/accounts.js +0 -181
- package/auth.js +0 -300
- package/codex.js +0 -345
- package/config.json +0 -11
- package/cursor.js +0 -278
- package/main.js +0 -1305
- package/preload.js +0 -43
- package/reminders.js +0 -54
- package/renderer/burn.js +0 -98
- package/renderer/companion.js +0 -52
- package/renderer/index.html +0 -527
- package/renderer/motion.css +0 -646
- package/renderer/pet.js +0 -2394
- package/renderer/pets.css +0 -916
- package/renderer/pets.js +0 -579
- package/renderer/style.css +0 -3758
- package/renderer/voice.js +0 -244
- package/usage.js +0 -539
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
}
|