clauddy 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/bin/clauddy.js +12 -2
- package/main.js +9 -0
- package/package.json +1 -1
- package/renderer/index.html +22 -2
- package/renderer/pet.js +312 -2
- package/renderer/style.css +261 -31
- package/renderer/voice.js +243 -0
package/README.md
CHANGED
|
@@ -109,6 +109,35 @@ Other activities — **planning**, **researching**, **delegating**, **waiting**
|
|
|
109
109
|
show up in the status line as they happen. When Claude goes quiet, the pet drops
|
|
110
110
|
back to plain **working** / **idle**.
|
|
111
111
|
|
|
112
|
+
## The pet talks
|
|
113
|
+
|
|
114
|
+
Now and then the pet says something in a speech bubble. It only speaks when something changes, never on a timer, and it keeps quiet when it has nothing specific to say. Remarks are at least 10 minutes apart; the big moments can cut that line.
|
|
115
|
+
|
|
116
|
+
| Moment | What it says (expanded) | Minimized | Blip |
|
|
117
|
+
| --- | --- | --- | --- |
|
|
118
|
+
| First launch of the day | _Good morning! Yesterday was your heaviest day this week, 12M tokens._ | _Morning! Big day yesterday._ | ✓ |
|
|
119
|
+
| Session crosses your fire threshold | _Getting warm, 91% already. At this pace you'll run out in about 40m._ | _91%, getting warm!_ | ✓ |
|
|
120
|
+
| The session window resets | _Fresh window! That was 34M tokens over 4h 12m, mostly editing code._ | _Fresh window! 34M last time._ | ✓ |
|
|
121
|
+
| The session hits 100% | _That's the limit. I'll be back at 2:35 PM._ | _Maxed out till 2:35 PM._ | |
|
|
122
|
+
| You're back after 2+ hours away | _Welcome back! You were gone 3h 12m. The session's at 12%._ | _Welcome back!_ | |
|
|
123
|
+
| 90 minutes of unbroken work | _You've been at it for 1h 35m straight. Stretch break?_ | _Stretch break?_ | |
|
|
124
|
+
| A new record day (30-day best) | _New record! 45M tokens today, your biggest day in a month._ | _New record: 45M!_ | |
|
|
125
|
+
| Codex crosses fire, hits 100%, or resets | _Codex is at 91% now. It resets in 1h 12m._ | _Codex at 91%!_ | ✓ (fire, reset) |
|
|
126
|
+
|
|
127
|
+
The greeting follows your clock (_Good morning_, _Good afternoon_, _Good evening_, or _Still up?_ before 5 AM) and appears once a day, and so does the record. The reset recap covers only what the pet actually saw, so if you launch it mid-session it says less. Click the bubble to dismiss it.
|
|
128
|
+
|
|
129
|
+
### The voice
|
|
130
|
+
|
|
131
|
+
Optionally the pet also **blips** as it talks: short chiptune notes, one per letter, generated live with Web Audio (square waves, no sound files). It blips faster and higher when it's on fire, slower and lower when it's sleepy. Only the headline moments blip (the ✓ rows above), because a sound on every remark wears out fast.
|
|
132
|
+
|
|
133
|
+
Audio should never catch you off guard in a meeting:
|
|
134
|
+
|
|
135
|
+
- **Off by default.** Turn it on under **⚙ Settings → voice → Chiptune voice** (you'll hear a sample).
|
|
136
|
+
- **One-click mute.** While the voice is on, a 🔊 button sits in the title bar. Click it to mute for an hour, and again to unmute.
|
|
137
|
+
- **Always silent** in menu-bar mode and while the pet is minimized.
|
|
138
|
+
|
|
139
|
+
**Speech bubbles** can be switched off entirely in the same place. Try any remark from the terminal with `bunx clauddy say <kind>` ([see below](#play-with-the-pet)).
|
|
140
|
+
|
|
112
141
|
## Install
|
|
113
142
|
|
|
114
143
|
**macOS (Apple Silicon)** is the first-class build. **Windows (x64)** and **Linux (x64)** work too. The `bunx`/`npx` route below runs on all of them today.
|
|
@@ -232,6 +261,9 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
|
|
|
232
261
|
"activeThresholdMs": 20000, // "active" if Claude wrote to its logs within this window
|
|
233
262
|
"sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
|
|
234
263
|
"codex": { "enabled": false }, // set by Settings → Codex → Connect
|
|
264
|
+
"talk": true, // speech bubbles on transitions
|
|
265
|
+
"sound": false, // chiptune blips, opt-in
|
|
266
|
+
"soundMutedUntil": 0, // set by the 🔊 button: muted until this epoch ms
|
|
235
267
|
}
|
|
236
268
|
```
|
|
237
269
|
|
|
@@ -248,6 +280,8 @@ bunx clauddy working # 🍴 eats token coins
|
|
|
248
280
|
bunx clauddy tired # 🥵 maxed out
|
|
249
281
|
bunx clauddy idle # 🙂 calm
|
|
250
282
|
bunx clauddy auto # ↩️ back to your real usage
|
|
283
|
+
bunx clauddy say # 💬 a remark in the speech bubble
|
|
284
|
+
bunx clauddy say fire # or a specific one: greeting, fire, reset, maxed, welcome, streak, record, codex
|
|
251
285
|
```
|
|
252
286
|
|
|
253
287
|
Each state is written to the data dir the running widget watches, so it reacts
|
|
@@ -259,7 +293,7 @@ live. (Installed globally? Drop the `bunx`: `clauddy poke`. Working on the repo?
|
|
|
259
293
|
- **`main.js`** — Electron main process: frameless, transparent, always-on-top window; polls usage; fires macOS notifications; watches `config.json` and `debug.json`.
|
|
260
294
|
- **`usage.js`** — reads `~/.claude/projects/**/*.jsonl`, sums tokens per model/project/day, detects the rolling 5-hour session window, the working/sleeping status, and which activity (reading/editing/running/…) Claude is on from its latest tool use.
|
|
261
295
|
- **`auth.js`** — OAuth login (PKCE, same public client as Claude Code) that fetches the authoritative usage %. Token stored locally, never committed.
|
|
262
|
-
- **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles.
|
|
296
|
+
- **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles. `voice.js` writes its remarks and synthesizes the blips.
|
|
263
297
|
- **`make-icon.js`** — generates the app icon from the pixel sprite (`build/icon.icns`).
|
|
264
298
|
|
|
265
299
|
## Data & privacy
|
package/bin/clauddy.js
CHANGED
|
@@ -20,6 +20,7 @@ const STATES = [
|
|
|
20
20
|
'idle',
|
|
21
21
|
'poke',
|
|
22
22
|
'celebrate',
|
|
23
|
+
'say',
|
|
23
24
|
'auto',
|
|
24
25
|
'clear',
|
|
25
26
|
// activity states (what Claude Code is doing)
|
|
@@ -31,7 +32,10 @@ const STATES = [
|
|
|
31
32
|
'delegating',
|
|
32
33
|
'waiting',
|
|
33
34
|
]
|
|
35
|
+
// `clauddy say <kind>` previews one of the pet's remarks
|
|
36
|
+
const REMARKS = ['greeting', 'fire', 'reset', 'maxed', 'welcome', 'streak', 'record', 'codex']
|
|
34
37
|
const arg = process.argv[2]
|
|
38
|
+
const kind = arg === 'say' ? process.argv[3] || 'greeting' : undefined
|
|
35
39
|
|
|
36
40
|
if (!arg) {
|
|
37
41
|
// no argument → launch the app
|
|
@@ -47,19 +51,25 @@ if (!arg) {
|
|
|
47
51
|
'Usage:',
|
|
48
52
|
' clauddy launch the widget',
|
|
49
53
|
' clauddy <state> poke the running widget (just for fun)',
|
|
54
|
+
' clauddy say [kind] make the pet say one of its remarks',
|
|
50
55
|
'',
|
|
51
56
|
'States: fire, sleeping, working, tired, idle, poke, celebrate, auto',
|
|
57
|
+
`Remarks: ${REMARKS.join(', ')}`,
|
|
52
58
|
'(the widget must be running for a state to show)',
|
|
53
59
|
].join('\n'),
|
|
54
60
|
)
|
|
61
|
+
} else if (kind && !REMARKS.includes(kind)) {
|
|
62
|
+
console.error(`clauddy: unknown remark "${kind}"`)
|
|
63
|
+
console.error(`try: ${REMARKS.join(', ')}`)
|
|
64
|
+
process.exit(1)
|
|
55
65
|
} else if (STATES.includes(arg)) {
|
|
56
66
|
// write the state for the running app to pick up
|
|
57
67
|
fs.mkdirSync(dataDir, { recursive: true })
|
|
58
68
|
fs.writeFileSync(
|
|
59
69
|
path.join(dataDir, 'debug.json'),
|
|
60
|
-
`${JSON.stringify({ state: arg, t: Date.now() })}\n`,
|
|
70
|
+
`${JSON.stringify({ state: arg, kind, t: Date.now() })}\n`,
|
|
61
71
|
)
|
|
62
|
-
console.log(`clauddy → ${arg} (the running widget will react)`)
|
|
72
|
+
console.log(`clauddy → ${arg}${kind ? ` ${kind}` : ''} (the running widget will react)`)
|
|
63
73
|
} else {
|
|
64
74
|
console.error(`clauddy: unknown command "${arg}"`)
|
|
65
75
|
console.error('try: fire, sleeping, working, tired, idle, poke, celebrate, auto — or --help')
|
package/main.js
CHANGED
|
@@ -77,6 +77,9 @@ function publicConfig(c) {
|
|
|
77
77
|
fireThreshold: c.fireThreshold,
|
|
78
78
|
zoom: c.zoom,
|
|
79
79
|
codex: !!c.codex?.enabled,
|
|
80
|
+
talk: c.talk,
|
|
81
|
+
sound: c.sound,
|
|
82
|
+
soundMutedUntil: c.soundMutedUntil,
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -92,6 +95,9 @@ function loadConfig() {
|
|
|
92
95
|
fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
|
|
93
96
|
zoom: 100, // widget scale %, 100-200
|
|
94
97
|
codex: { enabled: false }, // opt-in from Settings, never on by finding ~/.codex
|
|
98
|
+
talk: true, // speech bubbles on transitions
|
|
99
|
+
sound: false, // chiptune blips: opt-in, audio must never surprise anyone
|
|
100
|
+
soundMutedUntil: 0, // the one-click "mute for 1 hour", as an epoch ms
|
|
95
101
|
pollIntervalMs: 4000,
|
|
96
102
|
activeThresholdMs: 20000,
|
|
97
103
|
sleepThresholdMs: 300000,
|
|
@@ -379,6 +385,9 @@ function createWindow() {
|
|
|
379
385
|
preload: path.join(__dirname, 'preload.js'),
|
|
380
386
|
contextIsolation: true,
|
|
381
387
|
nodeIntegration: false,
|
|
388
|
+
// the pet's blips start from a usage change, never from a click, so
|
|
389
|
+
// Chromium would otherwise hold the AudioContext suspended
|
|
390
|
+
autoplayPolicy: 'no-user-gesture-required',
|
|
382
391
|
},
|
|
383
392
|
})
|
|
384
393
|
|
package/package.json
CHANGED
package/renderer/index.html
CHANGED
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
<circle cx="7" cy="7" r="3" />
|
|
34
34
|
</svg>
|
|
35
35
|
</button>
|
|
36
|
+
<button id="mute" title="Mute the voice for 1 hour" hidden>
|
|
37
|
+
<svg class="ic ic-sound" viewBox="0 0 24 24" aria-hidden="true">
|
|
38
|
+
<path d="M11 5 6 9H2v6h4l5 4Z" /><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14" />
|
|
39
|
+
</svg>
|
|
40
|
+
<svg class="ic ic-muted" viewBox="0 0 24 24" aria-hidden="true">
|
|
41
|
+
<path d="M11 5 6 9H2v6h4l5 4Z" /><path d="m22 9-6 6M16 9l6 6" />
|
|
42
|
+
</svg>
|
|
43
|
+
</button>
|
|
36
44
|
<button id="usage" title="Open Usage page">
|
|
37
45
|
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
|
|
38
46
|
<path d="M7 7h10v10M7 17 17 7" />
|
|
@@ -146,6 +154,8 @@
|
|
|
146
154
|
<div id="sweat"><i></i><i></i></div>
|
|
147
155
|
<div id="dropzone"></div>
|
|
148
156
|
</div>
|
|
157
|
+
<!-- the pet's remarks: only on transitions, never on a timer -->
|
|
158
|
+
<div id="bubble" role="status" aria-live="polite" hidden><span id="bubble-text"></span></div>
|
|
149
159
|
<!-- editing mode, foreground: coffee mug in front of the pet -->
|
|
150
160
|
<!-- mug: its own SVG with UNIFORM scaling (no stage stretch) so it stays perfectly shaped -->
|
|
151
161
|
<svg id="mug" viewBox="0 0 46 44" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
|
@@ -197,10 +207,10 @@
|
|
|
197
207
|
<!-- Dual-service dock: two glanceable readings, one shared pet. -->
|
|
198
208
|
<div id="mini-dual">
|
|
199
209
|
<button type="button" class="mini-source" data-provider="claude">
|
|
200
|
-
<span>Claude</span><span class="mini-track"><i id="mini-claude-bar"></i></span><b id="mini-claude-value">—</b>
|
|
210
|
+
<svg class="mini-logo" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude" /></svg><span class="mini-source-name">Claude</span><span class="mini-track"><i id="mini-claude-bar"></i></span><b id="mini-claude-value">—</b>
|
|
201
211
|
</button>
|
|
202
212
|
<button type="button" class="mini-source" data-provider="codex">
|
|
203
|
-
<span>Codex</span><span class="mini-track"><i id="mini-codex-bar"></i></span><b id="mini-codex-value">—</b>
|
|
213
|
+
<svg class="mini-logo" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-openai" /></svg><span class="mini-source-name">Codex</span><span class="mini-track"><i id="mini-codex-bar"></i></span><b id="mini-codex-value">—</b>
|
|
204
214
|
</button>
|
|
205
215
|
</div>
|
|
206
216
|
<div id="mini-reset" hidden></div>
|
|
@@ -380,6 +390,15 @@
|
|
|
380
390
|
<span class="set-row-label">Zoom</span>
|
|
381
391
|
<span class="num"><button type="button" class="num-btn" data-for="set-zoom" data-step="-25">−</button><span class="num-val"><input id="set-zoom" type="number" min="100" max="200" step="25" /><i>%</i></span><button type="button" class="num-btn" data-for="set-zoom" data-step="25">+</button></span>
|
|
382
392
|
</div>
|
|
393
|
+
<div class="settings-label threshold-label">voice</div>
|
|
394
|
+
<div class="set-row">
|
|
395
|
+
<span class="set-row-label">Speech bubbles</span>
|
|
396
|
+
<input id="set-talk" class="set-toggle" type="checkbox" aria-label="Let the pet talk" />
|
|
397
|
+
</div>
|
|
398
|
+
<div class="set-row voice-sub">
|
|
399
|
+
<span class="set-row-label">Chiptune voice</span>
|
|
400
|
+
<input id="set-sound" class="set-toggle" type="checkbox" aria-label="Play blips when the pet talks" />
|
|
401
|
+
</div>
|
|
383
402
|
</div>
|
|
384
403
|
|
|
385
404
|
<div class="set-actions">
|
|
@@ -398,6 +417,7 @@
|
|
|
398
417
|
</div>
|
|
399
418
|
</div>
|
|
400
419
|
<script src="burn.js"></script>
|
|
420
|
+
<script src="voice.js"></script>
|
|
401
421
|
<script src="pet.js"></script>
|
|
402
422
|
</body>
|
|
403
423
|
</html>
|
package/renderer/pet.js
CHANGED
|
@@ -471,6 +471,276 @@ function celebrate() {
|
|
|
471
471
|
for (let i = 0; i < 24; i++) spawnConfetti(i)
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
// ---- voice ------------------------------------------------------------------
|
|
475
|
+
// The bubble speaks on transitions only, at most once per REMARK_GAP_MS. The
|
|
476
|
+
// headline moments (the day's first hello, a fresh window, catching fire) may
|
|
477
|
+
// jump that queue, and only they blip: a noise on every remark wears out far
|
|
478
|
+
// faster than the remark itself.
|
|
479
|
+
const REMARK_GAP_MS = 10 * 60000
|
|
480
|
+
const MUTE_MS = 60 * 60000
|
|
481
|
+
const blipper = Voice.createBlipper(() => {
|
|
482
|
+
const AC = globalThis.AudioContext || globalThis.webkitAudioContext
|
|
483
|
+
return AC ? new AC() : null
|
|
484
|
+
})
|
|
485
|
+
let lastRemarkAt = 0
|
|
486
|
+
let bubbleTimer = null
|
|
487
|
+
|
|
488
|
+
const talkOn = () => currentConfig.talk !== false
|
|
489
|
+
const mutedNow = () => currentConfig.soundMutedUntil > Date.now()
|
|
490
|
+
// opt-in, and never where it would surprise someone: the menu-bar popover, the
|
|
491
|
+
// mini face, or the hour after a mute
|
|
492
|
+
function soundOn() {
|
|
493
|
+
if (!currentConfig.sound || currentConfig.mode === 'menubar') return false
|
|
494
|
+
if (document.body.classList.contains('collapsed')) return false
|
|
495
|
+
return !mutedNow()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function moodOf(st) {
|
|
499
|
+
return st === 'stressed' ? 'fire' : st === 'sleeping' || st === 'tired' ? 'sleepy' : 'normal'
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// `line` is a remark from voice.js ({ text, short }) or a plain string. The
|
|
503
|
+
// mini face has room for a glance, not a sentence, so it gets the short one.
|
|
504
|
+
// `force` is the simulator's: it skips the gates but still respects the mute
|
|
505
|
+
function say(line, { headline = false, mood = 'normal', force = false } = {}) {
|
|
506
|
+
if (!line) return false
|
|
507
|
+
const collapsed = document.body.classList.contains('collapsed')
|
|
508
|
+
const text = typeof line === 'string' ? line : collapsed ? line.short : line.text
|
|
509
|
+
if (!force) {
|
|
510
|
+
const b = document.body.classList
|
|
511
|
+
if (!talkOn() || b.contains('settings-open')) return false
|
|
512
|
+
if (!headline && Date.now() - lastRemarkAt < REMARK_GAP_MS) return false
|
|
513
|
+
}
|
|
514
|
+
lastRemarkAt = Date.now()
|
|
515
|
+
const bubble = el('bubble')
|
|
516
|
+
el('bubble-text').textContent = text
|
|
517
|
+
bubble.classList.remove('leaving')
|
|
518
|
+
placeBubble()
|
|
519
|
+
bubble.hidden = false
|
|
520
|
+
fitSize() // collapsed, the bubble is in the flow: the window grows to fit it
|
|
521
|
+
clearTimeout(bubbleTimer)
|
|
522
|
+
bubbleTimer = setTimeout(hideBubble, Math.max(4500, text.length * 70 + 2500))
|
|
523
|
+
// the mouth moves along, whether or not there's sound
|
|
524
|
+
for (let i = 0; i < Math.min(4, Math.ceil(text.length / 18)); i++) setTimeout(chomp, i * 260)
|
|
525
|
+
if ((headline || force) && soundOn()) blipper.play(text, mood)
|
|
526
|
+
return true
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// expanded, the bubble floats over the scene beside the pet; collapsed there's
|
|
530
|
+
// no room beside it, so it moves above the pet, into the card's flow
|
|
531
|
+
function placeBubble() {
|
|
532
|
+
const bubble = el('bubble')
|
|
533
|
+
const stage = el('stage')
|
|
534
|
+
if (document.body.classList.contains('collapsed')) {
|
|
535
|
+
if (bubble.nextElementSibling !== stage) stage.before(bubble)
|
|
536
|
+
} else if (bubble.parentElement !== stage) stage.appendChild(bubble)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function hideBubble() {
|
|
540
|
+
clearTimeout(bubbleTimer)
|
|
541
|
+
const bubble = el('bubble')
|
|
542
|
+
if (bubble.hidden) return
|
|
543
|
+
bubble.classList.add('leaving')
|
|
544
|
+
bubbleTimer = setTimeout(() => {
|
|
545
|
+
bubble.hidden = true
|
|
546
|
+
bubble.classList.remove('leaving')
|
|
547
|
+
fitSize()
|
|
548
|
+
}, 180)
|
|
549
|
+
}
|
|
550
|
+
el('bubble').addEventListener('click', (e) => {
|
|
551
|
+
e.stopPropagation()
|
|
552
|
+
hideBubble()
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// once-a-day remarks, keyed by local date; memory is the fallback when storage
|
|
556
|
+
// isn't there, so a broken profile can't make the pet repeat itself
|
|
557
|
+
const toldMemo = {}
|
|
558
|
+
const dayKey = () => new Date().toDateString()
|
|
559
|
+
function toldToday(key) {
|
|
560
|
+
try {
|
|
561
|
+
if (localStorage.getItem(key) === dayKey()) return true
|
|
562
|
+
} catch {}
|
|
563
|
+
return toldMemo[key] === dayKey()
|
|
564
|
+
}
|
|
565
|
+
function markToday(key) {
|
|
566
|
+
toldMemo[key] = dayKey()
|
|
567
|
+
try {
|
|
568
|
+
localStorage.setItem(key, dayKey())
|
|
569
|
+
} catch {}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// what the pet has noticed so far — the transitions are all relative to it
|
|
573
|
+
const AWAY_MS = 2 * 3600000
|
|
574
|
+
const STREAK_MS = 90 * 60000
|
|
575
|
+
const STREAK_GAP_MS = 10 * 60000
|
|
576
|
+
let greeted = false
|
|
577
|
+
let heardConfig = false
|
|
578
|
+
let lastLivePct = null // Claude's session %, null until a live reading lands
|
|
579
|
+
let awayMs = 0
|
|
580
|
+
let streakFrom = 0
|
|
581
|
+
let lastWorkAt = 0
|
|
582
|
+
let streakTold = false
|
|
583
|
+
|
|
584
|
+
// what the current Claude window has been like, for the recap when it closes
|
|
585
|
+
// (#31). The peak is the session's tokens just before the rollover — after it
|
|
586
|
+
// the counter starts over. Only the time the pet actually watched counts.
|
|
587
|
+
const TALLY_GAP_MS = 15000 // a longer gap is the machine asleep, not work
|
|
588
|
+
let tally = { peak: 0, activeMs: 0, acts: {}, at: 0 }
|
|
589
|
+
function noteSession(d) {
|
|
590
|
+
const now = Date.now()
|
|
591
|
+
const dt = tally.at ? Math.min(now - tally.at, TALLY_GAP_MS) : 0
|
|
592
|
+
tally.at = now
|
|
593
|
+
if (d.active) {
|
|
594
|
+
tally.activeMs += dt
|
|
595
|
+
if (d.activity) tally.acts[d.activity] = (tally.acts[d.activity] || 0) + dt
|
|
596
|
+
}
|
|
597
|
+
tally.peak = Math.max(tally.peak, d.session?.tokens || 0)
|
|
598
|
+
}
|
|
599
|
+
function takeRecap(was) {
|
|
600
|
+
const top = Object.entries(tally.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || null
|
|
601
|
+
const r = { was, tokens: tally.peak, activeMs: tally.activeMs, top }
|
|
602
|
+
tally = { peak: 0, activeMs: 0, acts: {}, at: tally.at }
|
|
603
|
+
return r
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Codex, followed whichever tab is on screen. Its logs only move while it
|
|
607
|
+
// runs, so a stale reading keeps the baseline: the next fresh one is compared
|
|
608
|
+
// against the last number actually seen.
|
|
609
|
+
let lastCodexPct = null
|
|
610
|
+
function listenCodex() {
|
|
611
|
+
const c = codexData
|
|
612
|
+
if (!codexOn() || !c) {
|
|
613
|
+
lastCodexPct = null
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
const pct = c.session?.pct
|
|
617
|
+
if (pct == null || codexStale(c)) return
|
|
618
|
+
const was = lastCodexPct
|
|
619
|
+
lastCodexPct = pct
|
|
620
|
+
if (was == null) return
|
|
621
|
+
const fireAt = currentConfig.fireThreshold ?? 90
|
|
622
|
+
const reset = c.session.resetMs
|
|
623
|
+
if (was - pct > 25) say(Voice.codexResetLine(was), { headline: true })
|
|
624
|
+
else if (was < 100 && pct >= 100)
|
|
625
|
+
say(Voice.codexMaxedLine(fmtResetClock(reset)), { mood: 'sleepy' })
|
|
626
|
+
else if (was < fireAt && pct >= fireAt && pct < 100)
|
|
627
|
+
say(Voice.codexFireLine(pct, reset, fmtReset), { headline: true, mood: 'fire' })
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// called by paint() with the state it just drew. The Claude remarks follow the
|
|
631
|
+
// Claude view; Codex has its own, above.
|
|
632
|
+
function listen(before, st, { isCodex, liveOn, sp, sessReset, proj }) {
|
|
633
|
+
// the saved config decides whether it talks at all, and it lands just after
|
|
634
|
+
// the first usage tick: speaking before it would ignore a "talk: false"
|
|
635
|
+
if (!heardConfig) return
|
|
636
|
+
const d = lastData
|
|
637
|
+
const mood = moodOf(st)
|
|
638
|
+
if (!greeted) {
|
|
639
|
+
greeted = true
|
|
640
|
+
if (!toldToday('clauddy.greeted')) {
|
|
641
|
+
markToday('clauddy.greeted')
|
|
642
|
+
const line = Voice.greetingLine(d.days30, new Date().getHours(), fmtTokens)
|
|
643
|
+
if (say(line, { headline: true, mood })) return
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
listenCodex()
|
|
647
|
+
if (isCodex) return
|
|
648
|
+
const now = Date.now()
|
|
649
|
+
|
|
650
|
+
if (liveOn) {
|
|
651
|
+
const fireAt = currentConfig.fireThreshold ?? 90
|
|
652
|
+
const was = lastLivePct
|
|
653
|
+
lastLivePct = sp
|
|
654
|
+
// a first reading is a baseline: launching at 95% is not "catching fire"
|
|
655
|
+
if (was != null && was < 100 && sp >= 100) {
|
|
656
|
+
say(Voice.maxedLine(fmtResetClock(sessReset)), { mood: 'sleepy' })
|
|
657
|
+
} else if (was != null && was < fireAt && sp >= fireAt && sp < 100) {
|
|
658
|
+
const eta = proj?.kind === 'eta' ? proj.ms : null
|
|
659
|
+
say(Voice.fireLine(sp, eta, sessReset, fmtReset), { headline: true, mood: 'fire' })
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
lastLivePct = null // logged out, or switching: the next reading starts over
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (st === 'sleeping' && Number.isFinite(d.lastActivityMs))
|
|
666
|
+
awayMs = Math.max(awayMs, d.lastActivityMs)
|
|
667
|
+
else if (before === 'sleeping') {
|
|
668
|
+
if (awayMs >= AWAY_MS) say(Voice.welcomeLine(awayMs, liveOn ? sp : null, fmtReset))
|
|
669
|
+
awayMs = 0
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (st === 'working') {
|
|
673
|
+
if (!streakFrom || now - lastWorkAt > STREAK_GAP_MS) {
|
|
674
|
+
streakFrom = now
|
|
675
|
+
streakTold = false
|
|
676
|
+
}
|
|
677
|
+
lastWorkAt = now
|
|
678
|
+
if (!streakTold && now - streakFrom >= STREAK_MS)
|
|
679
|
+
streakTold = say(Voice.streakLine(now - streakFrom, fmtReset))
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const record = Voice.recordLine(d.days30, fmtTokens)
|
|
683
|
+
if (record && !toldToday('clauddy.record') && say(record, { mood })) markToday('clauddy.record')
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// `./pet say <kind>` previews one remark — real numbers where there are some,
|
|
687
|
+
// plausible ones where the moment hasn't happened
|
|
688
|
+
function sampleLine(kind) {
|
|
689
|
+
const days = lastData?.days30?.length ? [...lastData.days30] : new Array(30).fill(0)
|
|
690
|
+
const pct = realUsage?.session?.pct
|
|
691
|
+
const reset = realUsage?.session?.resetMs ?? 72 * 60000
|
|
692
|
+
if (kind === 'fire') return Voice.fireLine(Math.max(pct ?? 0, 91), 40 * 60000, reset, fmtReset)
|
|
693
|
+
if (kind === 'reset') {
|
|
694
|
+
const t = tally.peak ? tally : { peak: 34e6, activeMs: 4 * 3600000 + 12 * 60000, acts: {} }
|
|
695
|
+
const top = Object.entries(t.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'editing'
|
|
696
|
+
return Voice.recapLine(
|
|
697
|
+
{ was: 92, tokens: t.peak, activeMs: t.activeMs, top },
|
|
698
|
+
fmtTokens,
|
|
699
|
+
fmtReset,
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
if (kind === 'codex')
|
|
703
|
+
return Voice.codexFireLine(91, codexData?.session?.resetMs ?? reset, fmtReset)
|
|
704
|
+
if (kind === 'maxed') return Voice.maxedLine(fmtResetClock(reset))
|
|
705
|
+
if (kind === 'welcome') return Voice.welcomeLine(3 * 3600000 + 12 * 60000, pct ?? null, fmtReset)
|
|
706
|
+
if (kind === 'streak') return Voice.streakLine(95 * 60000, fmtReset)
|
|
707
|
+
if (kind === 'record') {
|
|
708
|
+
days[days.length - 1] = Math.max(2e6, Math.max(...days.slice(0, -1)) * 1.15)
|
|
709
|
+
return Voice.recordLine(days, fmtTokens)
|
|
710
|
+
}
|
|
711
|
+
if (!days[days.length - 2]) days[days.length - 2] = 12e6 // no yesterday yet: pretend one
|
|
712
|
+
return Voice.greetingLine(days, new Date().getHours(), fmtTokens)
|
|
713
|
+
}
|
|
714
|
+
function sayDebug(kind) {
|
|
715
|
+
const st = [...document.body.classList].find((c) => c.startsWith('state-'))?.slice(6)
|
|
716
|
+
const mood =
|
|
717
|
+
kind === 'fire' || kind === 'codex' ? 'fire' : kind === 'maxed' ? 'sleepy' : moodOf(st)
|
|
718
|
+
return say(sampleLine(kind), { force: true, mood })
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// mute sits in the title bar while the voice is on: one click before a call
|
|
722
|
+
let unmuteTimer = null
|
|
723
|
+
function paintMute() {
|
|
724
|
+
const btn = el('mute')
|
|
725
|
+
// menu-bar mode is always silent, so there is nothing there to mute
|
|
726
|
+
btn.hidden = !(currentConfig.sound && talkOn()) || currentConfig.mode === 'menubar'
|
|
727
|
+
document.body.classList.toggle('has-mute', !btn.hidden) // the chip makes room for it
|
|
728
|
+
const muted = mutedNow()
|
|
729
|
+
btn.classList.toggle('muted', muted)
|
|
730
|
+
btn.title = muted
|
|
731
|
+
? `Muted until ${new Date(currentConfig.soundMutedUntil).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} — click to unmute`
|
|
732
|
+
: 'Mute the voice for 1 hour'
|
|
733
|
+
clearTimeout(unmuteTimer)
|
|
734
|
+
if (muted) unmuteTimer = setTimeout(paintMute, currentConfig.soundMutedUntil - Date.now() + 50)
|
|
735
|
+
}
|
|
736
|
+
el('mute').addEventListener('click', (e) => {
|
|
737
|
+
e.stopPropagation()
|
|
738
|
+
const until = mutedNow() ? 0 : Date.now() + MUTE_MS
|
|
739
|
+
currentConfig = { ...currentConfig, soundMutedUntil: until } // repaint now, main confirms
|
|
740
|
+
paintMute()
|
|
741
|
+
window.api.saveConfig({ soundMutedUntil: until })
|
|
742
|
+
})
|
|
743
|
+
|
|
474
744
|
// ---- services ---------------------------------------------------------------
|
|
475
745
|
// Claude and Codex are monitored side by side, but the panel shows one at a
|
|
476
746
|
// time: the tabs under the pet pick which one owns the chip, the pet, the
|
|
@@ -635,6 +905,7 @@ const prevPct = {} // per service: a switch must not read as a reset
|
|
|
635
905
|
let lastData = null
|
|
636
906
|
function render(d) {
|
|
637
907
|
lastData = d
|
|
908
|
+
noteSession(d)
|
|
638
909
|
// the burn trail is Claude's, fed on every Claude tick whatever is on screen
|
|
639
910
|
const cl = realUsage
|
|
640
911
|
burn.note(d.session.tokens, !!cl && cl.session.resetMs != null)
|
|
@@ -697,6 +968,7 @@ function paint() {
|
|
|
697
968
|
for (const a of acts) {
|
|
698
969
|
document.body.classList.toggle(`act-${a}`, st === 'working' && curActivity === a)
|
|
699
970
|
}
|
|
971
|
+
const before = prevState
|
|
700
972
|
if (prevState && prevState !== st) {
|
|
701
973
|
if (st === 'sleeping') oneShot('drowse', 700)
|
|
702
974
|
else if (prevState === 'sleeping') oneShot('wake', 700)
|
|
@@ -721,7 +993,11 @@ function paint() {
|
|
|
721
993
|
: `${fmtTokens(d.today.tokens)} tokens today`
|
|
722
994
|
|
|
723
995
|
const was = prevPct[view]
|
|
724
|
-
if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25)
|
|
996
|
+
if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) {
|
|
997
|
+
celebrate()
|
|
998
|
+
// Codex's own reset is called out by listenCodex, on screen or not
|
|
999
|
+
if (!isCodex) say(Voice.recapLine(takeRecap(was), fmtTokens, fmtReset), { headline: true })
|
|
1000
|
+
}
|
|
725
1001
|
prevPct[view] = sessPct
|
|
726
1002
|
el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
|
|
727
1003
|
setLevel(el('session-pct'), liveOn ? sp : 0)
|
|
@@ -786,6 +1062,7 @@ function paint() {
|
|
|
786
1062
|
stopEating()
|
|
787
1063
|
}
|
|
788
1064
|
|
|
1065
|
+
listen(before, st, { isCodex, liveOn, sp, sessReset, proj })
|
|
789
1066
|
fitSize()
|
|
790
1067
|
}
|
|
791
1068
|
|
|
@@ -824,6 +1101,7 @@ window.api.onDebugState((o) => {
|
|
|
824
1101
|
const s = o?.state
|
|
825
1102
|
if (s === 'poke') return pokePet()
|
|
826
1103
|
if (s === 'celebrate') return celebrate()
|
|
1104
|
+
if (s === 'say') return sayDebug(o.kind)
|
|
827
1105
|
debugState = s === 'auto' || s === 'clear' || !s ? null : s
|
|
828
1106
|
if (lastData) render(lastData)
|
|
829
1107
|
})
|
|
@@ -837,6 +1115,8 @@ window.api.onConfig((cfg) => {
|
|
|
837
1115
|
document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
|
|
838
1116
|
document.body.classList.toggle('codex-on', !!currentConfig.codex)
|
|
839
1117
|
if (!currentConfig.codex) codexData = null
|
|
1118
|
+
heardConfig = true
|
|
1119
|
+
paintMute()
|
|
840
1120
|
paint()
|
|
841
1121
|
applyZoom(currentConfig.zoom)
|
|
842
1122
|
fitSize()
|
|
@@ -1307,6 +1587,8 @@ function snapshotSettings() {
|
|
|
1307
1587
|
t2: el('set-t2').value,
|
|
1308
1588
|
fire: el('set-fire').value,
|
|
1309
1589
|
zoom: el('set-zoom').value,
|
|
1590
|
+
talk: el('set-talk').checked,
|
|
1591
|
+
sound: el('set-sound').checked,
|
|
1310
1592
|
})
|
|
1311
1593
|
}
|
|
1312
1594
|
function refreshSaveDirty() {
|
|
@@ -1325,6 +1607,9 @@ function clearSaveDirty() {
|
|
|
1325
1607
|
function reflectAlertsOn() {
|
|
1326
1608
|
el('set-rows').classList.toggle('alerts-off', !el('set-alerts').checked)
|
|
1327
1609
|
}
|
|
1610
|
+
function reflectTalkOn() {
|
|
1611
|
+
el('set-rows').classList.toggle('talk-off', !el('set-talk').checked)
|
|
1612
|
+
}
|
|
1328
1613
|
// zoom applies as you step it, so the widget shows the size right away;
|
|
1329
1614
|
// Cancel puts the saved value back
|
|
1330
1615
|
function previewZoom() {
|
|
@@ -1344,9 +1629,13 @@ function populateSettings() {
|
|
|
1344
1629
|
el('set-t2').value = th[1] != null ? th[1] : 95
|
|
1345
1630
|
el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
|
|
1346
1631
|
el('set-zoom').value = c.zoom != null ? c.zoom : 100
|
|
1632
|
+
el('set-talk').checked = c.talk !== false
|
|
1633
|
+
el('set-sound').checked = !!c.sound
|
|
1634
|
+
reflectTalkOn()
|
|
1347
1635
|
clearSaveDirty() // fields now match the saved config
|
|
1348
1636
|
}
|
|
1349
1637
|
function openSettings() {
|
|
1638
|
+
hideBubble()
|
|
1350
1639
|
document.body.classList.remove('collapsed')
|
|
1351
1640
|
populateSettings()
|
|
1352
1641
|
document.body.classList.add('settings-open')
|
|
@@ -1388,11 +1677,24 @@ for (const b of document.querySelectorAll('.num-btn')) {
|
|
|
1388
1677
|
})
|
|
1389
1678
|
}
|
|
1390
1679
|
// light up Save whenever an editable field changes
|
|
1391
|
-
for (const id of [
|
|
1680
|
+
for (const id of [
|
|
1681
|
+
'set-alerts',
|
|
1682
|
+
'set-t1',
|
|
1683
|
+
'set-t2',
|
|
1684
|
+
'set-fire',
|
|
1685
|
+
'set-zoom',
|
|
1686
|
+
'set-talk',
|
|
1687
|
+
'set-sound',
|
|
1688
|
+
]) {
|
|
1392
1689
|
el(id).addEventListener('input', refreshSaveDirty)
|
|
1393
1690
|
el(id).addEventListener('change', refreshSaveDirty)
|
|
1394
1691
|
}
|
|
1395
1692
|
el('set-alerts').addEventListener('change', reflectAlertsOn)
|
|
1693
|
+
el('set-talk').addEventListener('change', reflectTalkOn)
|
|
1694
|
+
// turning the voice on plays a sample, so you hear what you just agreed to
|
|
1695
|
+
el('set-sound').addEventListener('change', () => {
|
|
1696
|
+
if (el('set-sound').checked) blipper.play('Hello there!')
|
|
1697
|
+
})
|
|
1396
1698
|
el('set-zoom').addEventListener('input', previewZoom)
|
|
1397
1699
|
// display-mode segmented control (floating pet | menu bar)
|
|
1398
1700
|
for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
|
|
@@ -1417,17 +1719,21 @@ el('set-save').addEventListener('click', () => {
|
|
|
1417
1719
|
.sort((a, b) => a - b),
|
|
1418
1720
|
fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
|
|
1419
1721
|
zoom,
|
|
1722
|
+
talk: el('set-talk').checked,
|
|
1723
|
+
sound: el('set-sound').checked,
|
|
1420
1724
|
})
|
|
1421
1725
|
clearSaveDirty()
|
|
1422
1726
|
document.body.classList.remove('settings-open')
|
|
1423
1727
|
fitSize()
|
|
1424
1728
|
})
|
|
1425
1729
|
el('min').addEventListener('click', () => {
|
|
1730
|
+
hideBubble()
|
|
1426
1731
|
document.body.classList.toggle('collapsed')
|
|
1427
1732
|
fitSize()
|
|
1428
1733
|
})
|
|
1429
1734
|
// double-click the pet to collapse / expand
|
|
1430
1735
|
el('pet').addEventListener('dblclick', () => {
|
|
1736
|
+
hideBubble()
|
|
1431
1737
|
document.body.classList.toggle('collapsed')
|
|
1432
1738
|
fitSize()
|
|
1433
1739
|
})
|
|
@@ -1476,5 +1782,9 @@ if (typeof module === 'object' && module.exports) {
|
|
|
1476
1782
|
paint,
|
|
1477
1783
|
pickProvider,
|
|
1478
1784
|
burn,
|
|
1785
|
+
say,
|
|
1786
|
+
hideBubble,
|
|
1787
|
+
soundOn,
|
|
1788
|
+
blipper,
|
|
1479
1789
|
}
|
|
1480
1790
|
}
|
package/renderer/style.css
CHANGED
|
@@ -1100,7 +1100,10 @@ body:not(.live) #bymodel {
|
|
|
1100
1100
|
border: 1px solid rgba(206, 146, 109, 0.22);
|
|
1101
1101
|
border-radius: 7px;
|
|
1102
1102
|
background: rgba(206, 146, 109, 0.055);
|
|
1103
|
-
|
|
1103
|
+
/* the same breathing room the meters get, so it never touches the tabs
|
|
1104
|
+
above it or the Details rule below */
|
|
1105
|
+
margin: 12px 0;
|
|
1106
|
+
padding: 12px 13px;
|
|
1104
1107
|
cursor: pointer;
|
|
1105
1108
|
-webkit-app-region: no-drag;
|
|
1106
1109
|
transition:
|
|
@@ -2429,13 +2432,21 @@ body.collapsed.dual #mini-acct-name {
|
|
|
2429
2432
|
max-width: 112px;
|
|
2430
2433
|
font-size: 10px;
|
|
2431
2434
|
}
|
|
2435
|
+
|
|
2436
|
+
/* The mood sits beneath the pet, level with the selected source reset. */
|
|
2432
2437
|
body.collapsed.dual .mini-line {
|
|
2433
|
-
|
|
2434
|
-
|
|
2438
|
+
position: absolute;
|
|
2439
|
+
bottom: 12px;
|
|
2440
|
+
left: 12px;
|
|
2441
|
+
width: 66px;
|
|
2442
|
+
justify-content: center;
|
|
2443
|
+
margin: 0;
|
|
2444
|
+
gap: 5px;
|
|
2435
2445
|
}
|
|
2436
2446
|
body.collapsed.dual #mini-text {
|
|
2437
|
-
font-size:
|
|
2447
|
+
font-size: 9px;
|
|
2438
2448
|
font-weight: 400;
|
|
2449
|
+
color: #b9afa3;
|
|
2439
2450
|
}
|
|
2440
2451
|
body.collapsed.dual #mini-dot {
|
|
2441
2452
|
width: 4px;
|
|
@@ -2443,67 +2454,97 @@ body.collapsed.dual #mini-dot {
|
|
|
2443
2454
|
}
|
|
2444
2455
|
body.collapsed.dual #mini-dual {
|
|
2445
2456
|
display: grid;
|
|
2446
|
-
grid-template-columns: 1fr
|
|
2447
|
-
gap:
|
|
2457
|
+
grid-template-columns: 1fr;
|
|
2458
|
+
gap: 5px;
|
|
2448
2459
|
}
|
|
2449
2460
|
.mini-source {
|
|
2461
|
+
--src: #e4a17c;
|
|
2462
|
+
position: relative;
|
|
2450
2463
|
display: grid;
|
|
2451
|
-
grid-template-columns: 1fr;
|
|
2452
|
-
|
|
2464
|
+
grid-template-columns: 12px minmax(0, 1fr) auto;
|
|
2465
|
+
align-items: center;
|
|
2466
|
+
column-gap: 5px;
|
|
2467
|
+
row-gap: 4px;
|
|
2453
2468
|
min-width: 0;
|
|
2454
|
-
|
|
2469
|
+
min-height: 29px;
|
|
2470
|
+
padding: 3px 0 3px 5px;
|
|
2455
2471
|
border: 0;
|
|
2456
|
-
border-radius:
|
|
2472
|
+
border-radius: 4px;
|
|
2457
2473
|
background: transparent;
|
|
2458
|
-
color: #
|
|
2474
|
+
color: #aaa298;
|
|
2459
2475
|
text-align: left;
|
|
2460
2476
|
font: inherit;
|
|
2461
2477
|
font-size: 10px;
|
|
2462
2478
|
cursor: pointer;
|
|
2463
2479
|
-webkit-app-region: no-drag;
|
|
2480
|
+
transition: color 0.15s;
|
|
2464
2481
|
}
|
|
2465
|
-
.mini-source
|
|
2466
|
-
|
|
2482
|
+
.mini-source[data-provider="codex"] {
|
|
2483
|
+
--src: #b9c8b0;
|
|
2467
2484
|
}
|
|
2468
|
-
.mini-
|
|
2469
|
-
|
|
2485
|
+
.mini-logo {
|
|
2486
|
+
grid-column: 1;
|
|
2487
|
+
grid-row: 1;
|
|
2488
|
+
width: 12px;
|
|
2489
|
+
height: 12px;
|
|
2490
|
+
fill: currentColor;
|
|
2470
2491
|
}
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2492
|
+
.mini-source-name {
|
|
2493
|
+
grid-column: 2;
|
|
2494
|
+
grid-row: 1;
|
|
2474
2495
|
}
|
|
2475
|
-
.
|
|
2476
|
-
|
|
2496
|
+
/* Selection uses contrast; urgent readings retain their warning color. */
|
|
2497
|
+
.mini-source[aria-pressed="false"] .mini-track i {
|
|
2498
|
+
opacity: 0.55;
|
|
2477
2499
|
}
|
|
2478
|
-
.mini-source[
|
|
2479
|
-
|
|
2500
|
+
.mini-source[aria-pressed="false"]:hover .mini-track i,
|
|
2501
|
+
.mini-source.urgent .mini-track i {
|
|
2502
|
+
opacity: 1;
|
|
2480
2503
|
}
|
|
2481
|
-
.mini-source[
|
|
2482
|
-
|
|
2504
|
+
.mini-source[aria-pressed="true"] {
|
|
2505
|
+
color: #eee4d9;
|
|
2506
|
+
}
|
|
2507
|
+
.mini-source[aria-pressed="true"] .mini-logo {
|
|
2508
|
+
color: var(--src);
|
|
2509
|
+
}
|
|
2510
|
+
.mini-source:hover {
|
|
2511
|
+
color: #fff4e8;
|
|
2512
|
+
}
|
|
2513
|
+
.mini-source:hover .mini-logo {
|
|
2514
|
+
color: var(--src);
|
|
2483
2515
|
}
|
|
2484
2516
|
.mini-source b {
|
|
2485
|
-
grid-
|
|
2486
|
-
|
|
2517
|
+
grid-column: 3;
|
|
2518
|
+
grid-row: 1;
|
|
2519
|
+
font-size: 15px;
|
|
2487
2520
|
line-height: 1.1;
|
|
2488
2521
|
letter-spacing: -0.4px;
|
|
2489
2522
|
font-weight: 500;
|
|
2490
|
-
color:
|
|
2523
|
+
color: inherit;
|
|
2491
2524
|
font-variant-numeric: tabular-nums;
|
|
2492
2525
|
}
|
|
2493
2526
|
.mini-source.urgent b {
|
|
2494
2527
|
color: #f3a38b;
|
|
2495
2528
|
}
|
|
2496
|
-
.mini-source.urgent
|
|
2529
|
+
.mini-source.urgent .mini-source-name::after {
|
|
2497
2530
|
content: " !";
|
|
2498
2531
|
color: #f3a38b;
|
|
2499
2532
|
}
|
|
2500
2533
|
.mini-track {
|
|
2501
|
-
grid-
|
|
2534
|
+
grid-column: 2 / -1;
|
|
2535
|
+
grid-row: 2;
|
|
2502
2536
|
height: 2px;
|
|
2503
2537
|
border-radius: 2px;
|
|
2504
2538
|
overflow: hidden;
|
|
2505
2539
|
background: #ffffff0d;
|
|
2506
2540
|
}
|
|
2541
|
+
body.collapsed.dual #mini-reset {
|
|
2542
|
+
text-align: right;
|
|
2543
|
+
margin-top: 7px;
|
|
2544
|
+
font-size: 8px;
|
|
2545
|
+
color: #b4a18a;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2507
2548
|
.mini-track i {
|
|
2508
2549
|
display: block;
|
|
2509
2550
|
height: 100%;
|
|
@@ -2578,11 +2619,19 @@ body.view-codex .mrow .mname::before {
|
|
|
2578
2619
|
body.view-codex .ac-caret {
|
|
2579
2620
|
display: none;
|
|
2580
2621
|
}
|
|
2622
|
+
/* keyboard focus: a soft inner glow in the brand coral rather than a detached
|
|
2623
|
+
cream ring — still visible, but it reads as the control lighting up */
|
|
2581
2624
|
#card button:focus-visible,
|
|
2582
2625
|
#card input:focus-visible,
|
|
2583
2626
|
#account-chip:focus-visible {
|
|
2584
|
-
outline:
|
|
2585
|
-
|
|
2627
|
+
outline: none;
|
|
2628
|
+
box-shadow:
|
|
2629
|
+
inset 0 0 0 1px #e6a07f8c,
|
|
2630
|
+
0 0 0 3px #d977571f;
|
|
2631
|
+
}
|
|
2632
|
+
#controls button:focus-visible {
|
|
2633
|
+
background: rgba(255, 255, 255, 0.1);
|
|
2634
|
+
color: #fff;
|
|
2586
2635
|
}
|
|
2587
2636
|
@media (prefers-reduced-motion: reduce) {
|
|
2588
2637
|
#card,
|
|
@@ -2790,3 +2839,184 @@ body.awaiting #acc-intro.connection-card {
|
|
|
2790
2839
|
.claude-connect-btn:hover {
|
|
2791
2840
|
background: #ce926d33;
|
|
2792
2841
|
}
|
|
2842
|
+
|
|
2843
|
+
/* ---- voice: speech bubble + mute ---- */
|
|
2844
|
+
/* the bubble speaks the sprite's language: square pixels, a notched 2px
|
|
2845
|
+
outline (no radius), a stepped tail aimed at the pet's head, small type */
|
|
2846
|
+
#bubble {
|
|
2847
|
+
--ink: #221b16;
|
|
2848
|
+
--paper: #f1e9dd;
|
|
2849
|
+
position: absolute;
|
|
2850
|
+
left: 84px;
|
|
2851
|
+
top: 16px;
|
|
2852
|
+
z-index: 4;
|
|
2853
|
+
max-width: 148px;
|
|
2854
|
+
padding: 5px 7px 6px;
|
|
2855
|
+
background: var(--paper);
|
|
2856
|
+
color: var(--ink);
|
|
2857
|
+
font:
|
|
2858
|
+
600 9px / 1.35 ui-monospace,
|
|
2859
|
+
"SF Mono",
|
|
2860
|
+
Menlo,
|
|
2861
|
+
monospace;
|
|
2862
|
+
letter-spacing: -0.01em;
|
|
2863
|
+
/* four offset edges instead of a border: the corners stay open, like a sprite */
|
|
2864
|
+
box-shadow:
|
|
2865
|
+
0 -2px 0 0 var(--ink),
|
|
2866
|
+
0 2px 0 0 var(--ink),
|
|
2867
|
+
-2px 0 0 0 var(--ink),
|
|
2868
|
+
2px 0 0 0 var(--ink),
|
|
2869
|
+
2px 4px 0 0 #0006;
|
|
2870
|
+
cursor: pointer;
|
|
2871
|
+
-webkit-app-region: no-drag;
|
|
2872
|
+
transform-origin: 0% 100%;
|
|
2873
|
+
animation: bubble-pop 0.18s steps(3, end);
|
|
2874
|
+
}
|
|
2875
|
+
#bubble[hidden] {
|
|
2876
|
+
display: none;
|
|
2877
|
+
}
|
|
2878
|
+
/* the tail: a stepped right triangle hanging off the bottom-left edge, aimed
|
|
2879
|
+
down at the pet. The ink one is the outline; the paper one sits 2px inside it
|
|
2880
|
+
and starts on the outline row, so the bubble opens into the tail */
|
|
2881
|
+
#bubble::before,
|
|
2882
|
+
#bubble::after {
|
|
2883
|
+
content: "";
|
|
2884
|
+
position: absolute;
|
|
2885
|
+
top: 100%;
|
|
2886
|
+
}
|
|
2887
|
+
#bubble::before {
|
|
2888
|
+
left: 8px;
|
|
2889
|
+
width: 14px;
|
|
2890
|
+
height: 12px;
|
|
2891
|
+
background: var(--ink);
|
|
2892
|
+
clip-path: polygon(
|
|
2893
|
+
0 0,
|
|
2894
|
+
14px 0,
|
|
2895
|
+
14px 2px,
|
|
2896
|
+
12px 2px,
|
|
2897
|
+
12px 4px,
|
|
2898
|
+
10px 4px,
|
|
2899
|
+
10px 6px,
|
|
2900
|
+
8px 6px,
|
|
2901
|
+
8px 8px,
|
|
2902
|
+
6px 8px,
|
|
2903
|
+
6px 10px,
|
|
2904
|
+
4px 10px,
|
|
2905
|
+
4px 12px,
|
|
2906
|
+
0 12px
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
#bubble::after {
|
|
2910
|
+
left: 10px;
|
|
2911
|
+
width: 10px;
|
|
2912
|
+
height: 10px;
|
|
2913
|
+
background: var(--paper);
|
|
2914
|
+
clip-path: polygon(
|
|
2915
|
+
0 0,
|
|
2916
|
+
10px 0,
|
|
2917
|
+
10px 2px,
|
|
2918
|
+
8px 2px,
|
|
2919
|
+
8px 4px,
|
|
2920
|
+
6px 4px,
|
|
2921
|
+
6px 6px,
|
|
2922
|
+
4px 6px,
|
|
2923
|
+
4px 8px,
|
|
2924
|
+
2px 8px,
|
|
2925
|
+
2px 10px,
|
|
2926
|
+
0 10px
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2929
|
+
#bubble.leaving {
|
|
2930
|
+
animation: bubble-out 0.15s steps(2, end) forwards;
|
|
2931
|
+
}
|
|
2932
|
+
@keyframes bubble-pop {
|
|
2933
|
+
from {
|
|
2934
|
+
transform: scale(0.5);
|
|
2935
|
+
opacity: 0;
|
|
2936
|
+
}
|
|
2937
|
+
to {
|
|
2938
|
+
transform: scale(1);
|
|
2939
|
+
opacity: 1;
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
@keyframes bubble-out {
|
|
2943
|
+
to {
|
|
2944
|
+
transform: scale(0.8);
|
|
2945
|
+
opacity: 0;
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
body.settings-open #bubble {
|
|
2949
|
+
display: none;
|
|
2950
|
+
}
|
|
2951
|
+
/* collapsed: above the pet, in the card's flow, tail pointing straight down */
|
|
2952
|
+
body.collapsed #bubble {
|
|
2953
|
+
position: relative;
|
|
2954
|
+
left: auto;
|
|
2955
|
+
top: auto;
|
|
2956
|
+
max-width: none;
|
|
2957
|
+
margin: 6px 2px 14px;
|
|
2958
|
+
grid-column: 1 / -1;
|
|
2959
|
+
transform-origin: 50% 100%;
|
|
2960
|
+
}
|
|
2961
|
+
/* one service: the card has no title band, so clear the window controls, and
|
|
2962
|
+
hug the short line instead of spanning the whole card */
|
|
2963
|
+
body.collapsed:not(.dual) #bubble {
|
|
2964
|
+
align-self: center;
|
|
2965
|
+
margin-top: 26px;
|
|
2966
|
+
text-align: center;
|
|
2967
|
+
}
|
|
2968
|
+
body.collapsed.dual #bubble {
|
|
2969
|
+
justify-self: start;
|
|
2970
|
+
}
|
|
2971
|
+
body.collapsed #bubble::before {
|
|
2972
|
+
left: calc(50% - 7px);
|
|
2973
|
+
}
|
|
2974
|
+
body.collapsed #bubble::after {
|
|
2975
|
+
left: calc(50% - 5px);
|
|
2976
|
+
}
|
|
2977
|
+
/* with both services the pet sits in the left column: aim there */
|
|
2978
|
+
body.collapsed.dual #bubble::before {
|
|
2979
|
+
left: 24px;
|
|
2980
|
+
}
|
|
2981
|
+
body.collapsed.dual #bubble::after {
|
|
2982
|
+
left: 26px;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
#controls button[hidden] {
|
|
2986
|
+
display: none;
|
|
2987
|
+
}
|
|
2988
|
+
/* a fifth button pushes #controls ~23px left: the account chip gives that up.
|
|
2989
|
+
Minimized is always silent, so the button (and the squeeze) go away there */
|
|
2990
|
+
body.has-mute #account-chip {
|
|
2991
|
+
max-width: 141px;
|
|
2992
|
+
}
|
|
2993
|
+
body.collapsed #mute {
|
|
2994
|
+
display: none;
|
|
2995
|
+
}
|
|
2996
|
+
#mute .ic-muted,
|
|
2997
|
+
#mute.muted .ic-sound {
|
|
2998
|
+
display: none;
|
|
2999
|
+
}
|
|
3000
|
+
#mute.muted .ic-muted {
|
|
3001
|
+
display: block;
|
|
3002
|
+
}
|
|
3003
|
+
#mute.muted {
|
|
3004
|
+
color: var(--coral-soft);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
.set-row.voice-sub .set-row-label {
|
|
3008
|
+
font-weight: 400;
|
|
3009
|
+
font-size: 10.5px;
|
|
3010
|
+
color: var(--muted);
|
|
3011
|
+
}
|
|
3012
|
+
#set-rows.talk-off .voice-sub {
|
|
3013
|
+
opacity: 0.35;
|
|
3014
|
+
pointer-events: none;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3018
|
+
#bubble,
|
|
3019
|
+
#bubble.leaving {
|
|
3020
|
+
animation: none;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// ---- the pet's voice ----
|
|
2
|
+
// Two channels. The bubble carries the meaning: a short remark, only on a
|
|
3
|
+
// transition, and only when there's something specific to say — a pet that
|
|
4
|
+
// talks on a timer is Clippy. The sound carries the personality: chiptune
|
|
5
|
+
// blips, one square-wave note per letter, Animal Crossing style. Oscillators
|
|
6
|
+
// only, so no assets, no dependency, and no language baked into the sound.
|
|
7
|
+
;(() => {
|
|
8
|
+
const BASE_HZ = 400
|
|
9
|
+
const SPREAD_HZ = 250
|
|
10
|
+
const STEP_MS = 115
|
|
11
|
+
const MAX_BLIPS = 14 // a sentence is a babble, not a recital
|
|
12
|
+
const GAIN = 0.05
|
|
13
|
+
|
|
14
|
+
// the tone doubles as state: hot is faster and higher, sleepy slower and lower
|
|
15
|
+
const MOODS = {
|
|
16
|
+
normal: { speed: 1, pitch: 1 },
|
|
17
|
+
fire: { speed: 0.72, pitch: 1.25 },
|
|
18
|
+
sleepy: { speed: 1.35, pitch: 0.8 },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// letters and digits each get a fixed pitch, so the same word always sounds
|
|
22
|
+
// the same; anything else is a rest
|
|
23
|
+
function pitchOf(ch) {
|
|
24
|
+
const c = ch.toLowerCase()
|
|
25
|
+
if (c >= 'a' && c <= 'z') return BASE_HZ + ((c.charCodeAt(0) - 97) / 25) * SPREAD_HZ
|
|
26
|
+
if (c >= '0' && c <= '9') return BASE_HZ + ((c.charCodeAt(0) - 48) / 9) * SPREAD_HZ
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// the schedule for a line: [{ at, hz, dur }] in ms, pure so it can be tested
|
|
31
|
+
function blipPlan(text, mood = 'normal') {
|
|
32
|
+
const m = MOODS[mood] || MOODS.normal
|
|
33
|
+
const step = STEP_MS * m.speed
|
|
34
|
+
const plan = []
|
|
35
|
+
let t = 0
|
|
36
|
+
for (const ch of String(text)) {
|
|
37
|
+
if (plan.length >= MAX_BLIPS) break
|
|
38
|
+
const hz = pitchOf(ch)
|
|
39
|
+
if (hz != null) {
|
|
40
|
+
plan.push({ at: Math.round(t), hz: Math.round(hz * m.pitch), dur: Math.round(step * 0.6) })
|
|
41
|
+
t += step
|
|
42
|
+
} else if (/[.,!?—:;]/.test(ch)) {
|
|
43
|
+
t += step * 1.5 // a breath on punctuation
|
|
44
|
+
} else if (ch === ' ') {
|
|
45
|
+
t += step * 0.4
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return plan
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `makeCtx` builds the AudioContext lazily: creating one up front would hold
|
|
52
|
+
// an audio device open for a pet that, by default, never makes a sound
|
|
53
|
+
function createBlipper(makeCtx) {
|
|
54
|
+
let ctx = null
|
|
55
|
+
return {
|
|
56
|
+
play(text, mood) {
|
|
57
|
+
try {
|
|
58
|
+
ctx = ctx || makeCtx()
|
|
59
|
+
if (!ctx) return 0
|
|
60
|
+
if (ctx.state === 'suspended') ctx.resume?.()
|
|
61
|
+
const plan = blipPlan(text, mood)
|
|
62
|
+
const t0 = ctx.currentTime + 0.02
|
|
63
|
+
for (const b of plan) {
|
|
64
|
+
const start = t0 + b.at / 1000
|
|
65
|
+
const end = start + b.dur / 1000
|
|
66
|
+
const osc = ctx.createOscillator()
|
|
67
|
+
const gain = ctx.createGain()
|
|
68
|
+
osc.type = 'square'
|
|
69
|
+
osc.frequency.setValueAtTime(b.hz, start)
|
|
70
|
+
// a quick attack and decay, or every note clicks
|
|
71
|
+
gain.gain.setValueAtTime(0, start)
|
|
72
|
+
gain.gain.linearRampToValueAtTime(GAIN, start + 0.008)
|
|
73
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
|
74
|
+
osc.connect(gain)
|
|
75
|
+
gain.connect(ctx.destination)
|
|
76
|
+
osc.start(start)
|
|
77
|
+
osc.stop(end + 0.01)
|
|
78
|
+
}
|
|
79
|
+
return plan.length
|
|
80
|
+
} catch {
|
|
81
|
+
return 0 // no audio device, or a context the OS took away: stay quiet
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- remarks ----
|
|
88
|
+
// Each returns { text, short }, or null when there's nothing worth saying.
|
|
89
|
+
// `text` is a full, spoken sentence for the scene; `short` is the glance the
|
|
90
|
+
// mini face has room for. Numbers arrive preformatted through `fmt` / `fmtDur`
|
|
91
|
+
// so this file stays free of the widget.
|
|
92
|
+
const line = (text, short) => ({ text, short: short || text })
|
|
93
|
+
|
|
94
|
+
// the small hours get their own hello: "Good morning!" at 2am rings false
|
|
95
|
+
const LATE_UNTIL = 5
|
|
96
|
+
function salutation(hour) {
|
|
97
|
+
if (hour < LATE_UNTIL) return 'Still up?'
|
|
98
|
+
return hour < 12 ? 'Good morning!' : hour < 18 ? 'Good afternoon!' : 'Good evening!'
|
|
99
|
+
}
|
|
100
|
+
function hello(hour) {
|
|
101
|
+
if (hour < LATE_UNTIL) return 'Up late!'
|
|
102
|
+
return hour < 12 ? 'Morning!' : hour < 18 ? 'Afternoon!' : 'Evening!'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `days` is the 30-day series, oldest first, today last
|
|
106
|
+
function greetingLine(days, hour, fmt) {
|
|
107
|
+
if (!Array.isArray(days) || days.length < 2) return null
|
|
108
|
+
const y = days[days.length - 2]
|
|
109
|
+
if (!y) return null
|
|
110
|
+
const week = days.slice(-8, -1) // the seven days before today, yesterday included
|
|
111
|
+
const worked = week.filter((v) => v > 0)
|
|
112
|
+
const hi = salutation(hour)
|
|
113
|
+
const short = `${hello(hour)} ${fmt(y)} yesterday.`
|
|
114
|
+
if (worked.length > 1 && y >= Math.max(...week))
|
|
115
|
+
return line(
|
|
116
|
+
`${hi} Yesterday was your heaviest day this week, ${fmt(y)} tokens.`,
|
|
117
|
+
`${hello(hour)} Big day yesterday.`,
|
|
118
|
+
)
|
|
119
|
+
const avg = worked.reduce((n, v) => n + v, 0) / (worked.length || 1)
|
|
120
|
+
if (worked.length > 2 && y < avg * 0.5)
|
|
121
|
+
return line(`${hi} Yesterday was a quiet one, just ${fmt(y)} tokens.`, short)
|
|
122
|
+
return line(`${hi} You spent ${fmt(y)} tokens yesterday.`, short)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fireLine(pct, etaMs, resetMs, fmtDur) {
|
|
126
|
+
const p = Math.round(pct)
|
|
127
|
+
if (etaMs != null)
|
|
128
|
+
return line(
|
|
129
|
+
`Getting warm, ${p}% already. At this pace you'll run out in about ${fmtDur(etaMs)}.`,
|
|
130
|
+
`${p}%, getting warm!`,
|
|
131
|
+
)
|
|
132
|
+
if (resetMs != null)
|
|
133
|
+
return line(
|
|
134
|
+
`Getting warm, ${p}% already. It resets in ${fmtDur(resetMs)}.`,
|
|
135
|
+
`${p}%, getting warm!`,
|
|
136
|
+
)
|
|
137
|
+
return line(`Getting warm, ${p}% already.`, `${p}%, getting warm!`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resetLine(was) {
|
|
141
|
+
return line(`Fresh window! The last one closed at ${Math.round(was)}%.`, 'Fresh window!')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// what each activity was, said the way you'd say it
|
|
145
|
+
const DOING = {
|
|
146
|
+
editing: 'mostly editing code',
|
|
147
|
+
reading: 'mostly reading',
|
|
148
|
+
planning: 'mostly planning',
|
|
149
|
+
running: 'mostly running commands',
|
|
150
|
+
researching: 'mostly researching',
|
|
151
|
+
delegating: 'mostly delegating to agents',
|
|
152
|
+
waiting: 'mostly waiting on you',
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// the recap of the window that just closed (#31), built from whatever is
|
|
156
|
+
// known, so a pet launched mid-session still says something true
|
|
157
|
+
function recapLine({ was, tokens, activeMs, top }, fmt, fmtDur) {
|
|
158
|
+
if (!(tokens > 0)) return resetLine(was ?? 0)
|
|
159
|
+
let text = `Fresh window! That was ${fmt(tokens)} tokens`
|
|
160
|
+
if (activeMs >= 60000) text += ` over ${fmtDur(activeMs)}`
|
|
161
|
+
if (DOING[top]) text += `, ${DOING[top]}`
|
|
162
|
+
return line(`${text}.`, `Fresh window! ${fmt(tokens)} last time.`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Codex speaks through the same pet, so its lines always say whose they are
|
|
166
|
+
function codexFireLine(pct, resetMs, fmtDur) {
|
|
167
|
+
const p = Math.round(pct)
|
|
168
|
+
const text =
|
|
169
|
+
resetMs != null
|
|
170
|
+
? `Codex is at ${p}% now. It resets in ${fmtDur(resetMs)}.`
|
|
171
|
+
: `Codex is at ${p}% now.`
|
|
172
|
+
return line(text, `Codex at ${p}%!`)
|
|
173
|
+
}
|
|
174
|
+
function codexMaxedLine(resetAt) {
|
|
175
|
+
return line(
|
|
176
|
+
resetAt ? `Codex is maxed out. It's back at ${resetAt}.` : 'Codex is maxed out.',
|
|
177
|
+
'Codex is maxed out.',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
function codexResetLine(was) {
|
|
181
|
+
return line(
|
|
182
|
+
`Codex has a fresh window! The last one closed at ${Math.round(was)}%.`,
|
|
183
|
+
'Codex: fresh window!',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function maxedLine(resetAt) {
|
|
188
|
+
return resetAt
|
|
189
|
+
? line(`That's the limit. I'll be back at ${resetAt}.`, `Maxed out till ${resetAt}.`)
|
|
190
|
+
: line("That's the limit. Nap time.", 'Maxed out.')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function welcomeLine(awayMs, pct, fmtDur) {
|
|
194
|
+
const away = fmtDur(awayMs)
|
|
195
|
+
const text =
|
|
196
|
+
pct != null
|
|
197
|
+
? `Welcome back! You were gone ${away}. The session's at ${Math.round(pct)}%.`
|
|
198
|
+
: `Welcome back! You were gone ${away}.`
|
|
199
|
+
return line(text, 'Welcome back!')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function streakLine(ms, fmtDur) {
|
|
203
|
+
return line(`You've been at it for ${fmtDur(ms)} straight. Stretch break?`, 'Stretch break?')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// a record only counts against a real history, and past a floor that makes
|
|
207
|
+
// it more than noise
|
|
208
|
+
const RECORD_FLOOR = 1e6
|
|
209
|
+
function recordLine(days, fmt) {
|
|
210
|
+
if (!Array.isArray(days) || days.length < 8) return null
|
|
211
|
+
const today = days[days.length - 1]
|
|
212
|
+
const before = days.slice(0, -1)
|
|
213
|
+
const best = Math.max(...before)
|
|
214
|
+
if (best <= 0 || today < RECORD_FLOOR || today <= best) return null
|
|
215
|
+
return line(
|
|
216
|
+
`New record! ${fmt(today)} tokens today, your biggest day in a month.`,
|
|
217
|
+
`New record: ${fmt(today)}!`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const api = {
|
|
222
|
+
BASE_HZ,
|
|
223
|
+
SPREAD_HZ,
|
|
224
|
+
STEP_MS,
|
|
225
|
+
MAX_BLIPS,
|
|
226
|
+
pitchOf,
|
|
227
|
+
blipPlan,
|
|
228
|
+
createBlipper,
|
|
229
|
+
greetingLine,
|
|
230
|
+
fireLine,
|
|
231
|
+
resetLine,
|
|
232
|
+
recapLine,
|
|
233
|
+
codexFireLine,
|
|
234
|
+
codexMaxedLine,
|
|
235
|
+
codexResetLine,
|
|
236
|
+
maxedLine,
|
|
237
|
+
welcomeLine,
|
|
238
|
+
streakLine,
|
|
239
|
+
recordLine,
|
|
240
|
+
}
|
|
241
|
+
if (typeof module === 'object' && module.exports) module.exports = api
|
|
242
|
+
else globalThis.Voice = api
|
|
243
|
+
})()
|